From 00bcee36a6b7a58f76ba04fc1a23de88b991a575 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=BA=92?= Date: Thu, 27 Aug 2026 15:54:23 +0800 Subject: [PATCH 1/5] feat(runtime): add unified confirmation gate for high-risk commands --- docs/agents/cli-e2e-tests.md | 7 + docs/agents/command-add-remove.md | 2 + docs/agents/telemetry-change.md | 36 ++--- docs/knowledge/doc.md | 12 +- docs/kscli/doc.md | 12 +- .../cli/tests/e2e/command-packs.e2e.test.ts | 30 ++++ .../tests/fixtures/command-pack/commands.mjs | 21 +++ .../src/commands/knowledge/doc-delete.ts | 29 ++-- .../knowledge-doc-delete.e2e.test.ts | 15 +- packages/core/src/errors/codes.ts | 1 + packages/core/src/telemetry/event.ts | 4 +- packages/core/src/telemetry/tracker.ts | 4 +- packages/core/src/types/command.ts | 14 ++ packages/core/src/types/index.ts | 2 + packages/core/tests/command-types.test.ts | 48 +++++++ packages/core/tests/telemetry-tracker.test.ts | 54 +++++++ .../runtime/src/command-packs/validate.ts | 4 + packages/runtime/src/confirm.ts | 68 +++++++-- packages/runtime/src/create-cli.ts | 41 +++++- packages/runtime/src/middleware.ts | 22 +++ packages/runtime/src/registry.ts | 8 +- packages/runtime/tests/command-packs.test.ts | 9 ++ packages/runtime/tests/confirm.test.ts | 136 ++++++++++++++++-- packages/runtime/tests/error-handler.test.ts | 44 +++++- packages/runtime/tests/public-api.test.ts | 11 ++ packages/runtime/tests/registry-guard.test.ts | 46 ++++++ skills/bailian-cli/reference/knowledge.md | 4 +- tools/generate-reference.ts | 9 +- 28 files changed, 605 insertions(+), 88 deletions(-) create mode 100644 packages/core/tests/command-types.test.ts create mode 100644 packages/core/tests/telemetry-tracker.test.ts create mode 100644 packages/runtime/tests/public-api.test.ts diff --git a/docs/agents/cli-e2e-tests.md b/docs/agents/cli-e2e-tests.md index cfae07704..554dbda67 100644 --- a/docs/agents/cli-e2e-tests.md +++ b/docs/agents/cli-e2e-tests.md @@ -79,6 +79,13 @@ describe.skipIf()("e2e: (DashScope …)", () => { 3. **--dry-run**:实现在联网/上传/写盘**之前**返回;断言 stdout JSON/文本 4. **真实集成**:放在 skip 块**末尾** +高风险命令额外要求: + +- `--help` 展示 runtime 注入的 `--yes` +- 无 `--yes` 返回 exit code 7 和 JSON `type: "requires_confirmation"` +- `--dry-run` 无需 `--yes`,且必须证明在任何远端请求或本地写入之前返回 +- runtime 的离线 high-risk fixture 必须覆盖带 `--yes` 确实进入 `run()`,并断言 `yes` 不进入 command 自有 flags + ## Journey 层(用户旅程全链路) - **定位**:命令 E2E 验单命令契约;journey 验“用户带着目标跨命令走通回路”,结构性断言不在 journey 重复 diff --git a/docs/agents/command-add-remove.md b/docs/agents/command-add-remove.md index bbea87b1d..0d8ea2c30 100644 --- a/docs/agents/command-add-remove.md +++ b/docs/agents/command-add-remove.md @@ -71,7 +71,9 @@ packages/commands/src/index.ts - `usageArgs`(不含 bin/path 前缀) - `exampleArgs`(不含 bin/path 前缀) - `validate`(跨 flag 校验) + - 高风险命令必须声明 `risk: { level: "high", message: <双语文案> }`;`--yes` 由 runtime 注入,命令不得自行声明 - 普通业务命令的 `run(ctx)` 只读 `ctx.flags` / `ctx.settings` / `ctx.client` + - 声明 `risk` 的 `run(ctx)` 必须在任何远端请求或本地写入之前处理 `ctx.settings.dryRun` 并返回预览;runtime 只负责确认闸门,不替命令实现 dry-run - `commands/auth/**` 可用 `ctx.authStore`,`commands/config/**` 可用 `ctx.configStore`;不要把这些持久化能力扩散到普通业务命令 - `commands/plugin/**` 可用 `ctx.commandPacks`;产品 policy 由 runtime 绑定,命令不要自行 import 产品入口 - [ ] 用户可见 Help 文案在命令文件中就近提供 `en-US` / `zh-CN`:命令 `description`、flag `description`、`notes` 和包含自然语言的 `exampleArgs`;纯命令语法示例可保留为字符串,服务端错误不翻译 diff --git a/docs/agents/telemetry-change.md b/docs/agents/telemetry-change.md index 9838e924f..7f43767f4 100644 --- a/docs/agents/telemetry-change.md +++ b/docs/agents/telemetry-change.md @@ -17,11 +17,12 @@ │ ├─ ~/.bailian/telemetry.jsonl │ └─ AEM(pid=bailian-cli-node, event name=命令路径) │ - └─ authStage - ├─ apiKey → DashScope / 模型域 - ├─ console → Bailian Console Gateway - ├─ openapi → 阿里云 OpenAPI - └─ none → 无凭证域;本地命令也仍有 AEM 命令事件 + └─ confirmationStage + └─ versionCheckStage → authStage + ├─ apiKey → DashScope / 模型域 + ├─ console → Bailian Console Gateway + ├─ openapi → 阿里云 OpenAPI + └─ none → 无凭证域;本地命令也仍有 AEM 命令事件 ``` ### 1. 三套鉴权与埋点标识 @@ -77,7 +78,7 @@ source-config 只用于百炼 / DashScope API 侧消费,不发送到通用网 ### 3. 全命令 AEM 客户端埋点 -`packages/runtime/src/middleware.ts` 的 `telemetryStage` 包裹 `authStage` 与命令执行,因此成功、业务失败、网络失败和鉴权失败都会形成一次命令事件。事件名是空格连接的命令路径,例如 `text chat`。 +`packages/runtime/src/middleware.ts` 的 `telemetryStage` 包裹确认闸门、`authStage` 与命令执行,因此成功、确认未通过、业务失败、网络失败和鉴权失败都会形成一次命令事件。事件名是空格连接的命令路径,例如 `text chat`。确认闸门仍位于版本检查、鉴权和业务执行之前,不会因为埋点而放行高风险操作。 以下情况不会形成命令事件,因为没有进入 middleware 的 `run`: @@ -92,7 +93,7 @@ source-config 只用于百炼 / DashScope API 侧消费,不发送到通用网 - `command`、`timestamp`、`durationMs`、`success` - `cliVersion`、`nodeVersion`、`os` - `authMethod` -- 失败时的 `errorMessage`、`httpStatus`、`requestId` +- 失败时的 `errorMessage`、`exitCode`、`httpStatus`、`requestId` - 安全 allowlist 过滤后的 `params` 参数默认不上传,只有 `packages/core/src/telemetry/tracker.ts` 的 `PARAM_ALLOWLIST` 中字段会进入事件。不得加入 prompt、凭证、文件路径、URL、账号/租户/工作空间 ID 或其他用户内容。 @@ -108,16 +109,16 @@ source-config 只用于百炼 / DashScope API 侧消费,不发送到通用网 AEM 映射: -| AEM 字段 | 内容 | -| ---------- | ----------------------------------------- | -| event name | 命令路径 | -| `et` | `EXP` | -| `ext` | 除 `command`、`params` 外的结构化事件字段 | -| `c1` | allowlist 参数 | -| `c2` | `success` / `failure` | -| `c3` | HTTP status | -| `c4` | 错误文案,最多 500 字符 | -| `c5` | request ID | +| AEM 字段 | 内容 | +| ---------- | ------------------------------------------------------------------ | +| event name | 命令路径 | +| `et` | `EXP` | +| `ext` | 除 `command`、`params` 外的结构化事件字段,包含失败时的 `exitCode` | +| `c1` | allowlist 参数 | +| `c2` | `success` / `failure` | +| `c3` | HTTP status | +| `c4` | 错误文案,最多 500 字符 | +| `c5` | request ID | 远端发送是 best-effort,不得阻塞命令或改变退出码。正常退出最多等待 1 秒,SIGINT 最多等待 500 ms。 @@ -144,6 +145,7 @@ AEM 映射: - [ ] 更新 `TrackingEvent`、`createTrackingEvent()` 与 `buildRemoteAemOptions()` 的字段映射 - [ ] 本地 JSONL 与远端 AEM 必须基于同一结构化事件,不能维护两套字段口径 - [ ] 成功与失败均覆盖;遥测异常必须静默且不改变业务退出码 +- [ ] runtime 本地语义错误应记录 `exitCode`;新增字段默认随 AEM `ext` 上报,无需占用新的 `c1`—`c5` - [ ] 检查 `DO_NOT_TRACK=1` 与 `telemetry: false` 两个关闭入口 - [ ] 错误字段不得额外拼接 token、请求体、prompt 或本地路径 diff --git a/docs/knowledge/doc.md b/docs/knowledge/doc.md index 5689d484a..714fdf056 100644 --- a/docs/knowledge/doc.md +++ b/docs/knowledge/doc.md @@ -190,11 +190,11 @@ bl knowledge doc delete --index-id --doc-id [flags] **参数** -| 参数 | 类型 | 必填 | 说明 | -| ----------------- | ------ | ---- | ----------------- | -| `--index-id ` | string | 是 | 知识库 ID | -| `--doc-id ` | array | 是 | 文档 ID(可重复) | -| `--yes` | switch | 否 | 跳过确认提示 | +| 参数 | 类型 | 必填 | 说明 | +| ----------------- | ------ | ---- | ------------------ | +| `--index-id ` | string | 是 | 知识库 ID | +| `--doc-id ` | array | 是 | 文档 ID(可重复) | +| `--yes` | switch | 否 | 显式确认高风险操作 | **输出** @@ -223,7 +223,7 @@ json 模式:返回 API 原始响应,`data.deleted[]` 为实际删除的 ID # 删除单个文档 bl knowledge doc delete --index-id idx-xxx --doc-id doc-xxx --workspace-id ws-xxx -# 批量删除,跳过确认 +# 用户明确确认后批量删除 bl knowledge doc delete --index-id idx-xxx --doc-id doc-a --doc-id doc-b --yes ``` diff --git a/docs/kscli/doc.md b/docs/kscli/doc.md index 60cea6776..6b024ecbc 100644 --- a/docs/kscli/doc.md +++ b/docs/kscli/doc.md @@ -190,11 +190,11 @@ kscli doc delete --index-id --doc-id [flags] **参数** -| 参数 | 类型 | 必填 | 说明 | -| ----------------- | ------ | ---- | ----------------- | -| `--index-id ` | string | 是 | 知识库 ID | -| `--doc-id ` | array | 是 | 文档 ID(可重复) | -| `--yes` | switch | 否 | 跳过确认提示 | +| 参数 | 类型 | 必填 | 说明 | +| ----------------- | ------ | ---- | ------------------ | +| `--index-id ` | string | 是 | 知识库 ID | +| `--doc-id ` | array | 是 | 文档 ID(可重复) | +| `--yes` | switch | 否 | 显式确认高风险操作 | **输出** @@ -223,7 +223,7 @@ json 模式:返回 API 原始响应,`data.deleted[]` 为实际删除的 ID # 删除单个文档 kscli doc delete --index-id idx-xxx --doc-id doc-xxx --workspace-id ws-xxx -# 批量删除,跳过确认 +# 用户明确确认后批量删除 kscli doc delete --index-id idx-xxx --doc-id doc-a --doc-id doc-b --yes ``` diff --git a/packages/cli/tests/e2e/command-packs.e2e.test.ts b/packages/cli/tests/e2e/command-packs.e2e.test.ts index 0ee79bee5..f2e740b57 100644 --- a/packages/cli/tests/e2e/command-packs.e2e.test.ts +++ b/packages/cli/tests/e2e/command-packs.e2e.test.ts @@ -53,6 +53,7 @@ describe("e2e: Command Pack", () => { expect(linkedJson.linked.commands).toEqual([ "agent credential", "agent credential-denied", + "agent dangerous", "agent fail", "agent output", "agent ping", @@ -96,6 +97,34 @@ describe("e2e: Command Pack", () => { expect(failed.stderr).toContain("Use agent fail only in tests."); }); + test("high-risk 命令由 runtime 统一确认并支持安全 dry-run", async () => { + const dangerousHelp = await runCli(["agent", "dangerous", "--help"], env()); + expect(dangerousHelp.exitCode, dangerousHelp.stderr).toBe(0); + expect(dangerousHelp.stderr).toContain("--yes"); + + const unconfirmed = await runCli(["agent", "dangerous", "--output", "json"], env()); + expect(unconfirmed.exitCode).toBe(7); + expect(JSON.parse(unconfirmed.stderr)).toMatchObject({ + error: { code: 7, type: "requires_confirmation" }, + }); + + const confirmed = await runCli(["agent", "dangerous", "--yes", "--output", "json"], env()); + expect(confirmed.exitCode, confirmed.stderr).toBe(0); + expect(parseStdoutJson(confirmed.stdout)).toEqual({ + executed: true, + dry_run: false, + command_flags: [], + }); + + const preview = await runCli(["agent", "dangerous", "--dry-run", "--output", "json"], env()); + expect(preview.exitCode, preview.stderr).toBe(0); + expect(parseStdoutJson(preview.stdout)).toEqual({ + executed: false, + dry_run: true, + command_flags: [], + }); + }); + test("plugin list 输出加载状态", async () => { const result = await runCli(["plugin", "list", "--output", "json"], env()); expect(result.exitCode, result.stderr).toBe(0); @@ -109,6 +138,7 @@ describe("e2e: Command Pack", () => { commands: [ "agent credential", "agent credential-denied", + "agent dangerous", "agent fail", "agent output", "agent ping", diff --git a/packages/cli/tests/fixtures/command-pack/commands.mjs b/packages/cli/tests/fixtures/command-pack/commands.mjs index 698c1b398..04a0488f7 100644 --- a/packages/cli/tests/fixtures/command-pack/commands.mjs +++ b/packages/cli/tests/fixtures/command-pack/commands.mjs @@ -19,6 +19,26 @@ const ping = { }, }; +const dangerous = { + description: "Exercise runtime confirmation for a high-risk Command Pack command", + auth: "none", + risk: { + level: "high", + message: { + "en-US": "This fixture represents a high-risk operation.", + "zh-CN": "该测试命令代表高风险操作。", + }, + }, + async run(ctx) { + const dryRun = ctx.settings.dryRun; + ctx.output.result({ + executed: !dryRun, + dry_run: dryRun, + command_flags: Object.keys(ctx.flags), + }); + }, +}; + const credential = { description: "Read an API key through the Command Pack host adapter", auth: "apiKey", @@ -55,6 +75,7 @@ const fail = { export default { "agent credential": credential, "agent credential-denied": credentialDenied, + "agent dangerous": dangerous, "agent fail": fail, "agent output": output, "agent ping": ping, diff --git a/packages/commands/src/commands/knowledge/doc-delete.ts b/packages/commands/src/commands/knowledge/doc-delete.ts index 915b9e7c0..d424f27f3 100644 --- a/packages/commands/src/commands/knowledge/doc-delete.ts +++ b/packages/commands/src/commands/knowledge/doc-delete.ts @@ -6,7 +6,7 @@ import { type FlagsDef, type RagDeleteFileResponse, } from "bailian-cli-core"; -import { emitResult, emitBare, confirmDangerousAction } from "bailian-cli-runtime"; +import { emitResult, emitBare } from "bailian-cli-runtime"; import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; const DOC_DELETE_FLAGS = { @@ -25,28 +25,22 @@ const DOC_DELETE_FLAGS = { }, required: true, }, - yes: { - type: "switch", - description: { "en-US": "Skip the confirmation prompt", "zh-CN": "跳过确认提示" }, - }, ...WORKSPACE_FLAG, } satisfies FlagsDef; -/** Confirmation summary: list all doc_ids up to 5, otherwise show the first 5 + total count */ -function buildDeleteSummary(indexId: string, docIds: string[]): string { - const listed = - docIds.length <= 5 - ? docIds.join("\n ") - : `${docIds.slice(0, 5).join("\n ")}\n ... (${docIds.length} documents total)`; - return `Delete ${docIds.length} document(s) from knowledge base ${indexId}:\n ${listed}\nDocuments and all their chunks are permanently removed from the index. This cannot be undone.`; -} - export default defineCommand({ description: { "en-US": "Delete documents and their chunks from a knowledge base", "zh-CN": "从知识库中删除文档及其 Chunk", }, auth: "apiKey", + risk: { + level: "high", + message: { + "en-US": "This permanently deletes the selected documents and all of their chunks.", + "zh-CN": "该操作会永久删除所选文档及其全部 Chunk,且无法撤销。", + }, + }, usageArgs: "--index-id --doc-id [flags]", flags: DOC_DELETE_FLAGS, notes: [ @@ -72,7 +66,7 @@ export default defineCommand({ }, ], exampleArgs: [ - "--index-id idx-xxx --doc-id file-xxx --workspace-id ws-xxx", + "--index-id idx-xxx --doc-id file-xxx --workspace-id ws-xxx --dry-run", "--index-id idx-xxx --doc-id file-a --doc-id file-b --yes", ], async run(ctx) { @@ -89,11 +83,6 @@ export default defineCommand({ return; } - await confirmDangerousAction( - buildDeleteSummary(flags.indexId, flags.docId), - flags.yes ?? false, - ); - const response = await ctx.client.requestJson({ path: endpoint, method: "POST", diff --git a/packages/commands/tests/e2e/knowledge/knowledge-doc-delete.e2e.test.ts b/packages/commands/tests/e2e/knowledge/knowledge-doc-delete.e2e.test.ts index dba73e3b5..53c0a908b 100644 --- a/packages/commands/tests/e2e/knowledge/knowledge-doc-delete.e2e.test.ts +++ b/packages/commands/tests/e2e/knowledge/knowledge-doc-delete.e2e.test.ts @@ -66,7 +66,7 @@ describe("e2e: knowledge doc delete", () => { expect(data.request?.doc_ids).toEqual(["file_a", "file_b"]); }); - test("非 TTY 无 --yes 报 USAGE (2)", async () => { + test("无 --yes 返回确认请求 (7)", async () => { const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_DOC_DELETE_ROUTES, [ "knowledge", "doc", @@ -79,9 +79,18 @@ describe("e2e: knowledge doc delete", () => { "sk-fake", "--workspace-id", "ws_test", + "--output", + "json", ]); - expect(exitCode).toBe(2); - expect(stderr).toMatch(/--yes/); + expect(exitCode).toBe(7); + expect(JSON.parse(stderr)).toMatchObject({ + error: { + code: 7, + type: "requires_confirmation", + hint: expect.stringContaining("--yes"), + }, + }); + expect(stderr).not.toContain("sk-fake"); }); }); diff --git a/packages/core/src/errors/codes.ts b/packages/core/src/errors/codes.ts index 83e4f8bc7..f0c440020 100644 --- a/packages/core/src/errors/codes.ts +++ b/packages/core/src/errors/codes.ts @@ -6,6 +6,7 @@ export const ExitCode = { QUOTA: 4, TIMEOUT: 5, NETWORK: 6, + CONFIRMATION_REQUIRED: 7, CONTENT_FILTER: 10, } as const; diff --git a/packages/core/src/telemetry/event.ts b/packages/core/src/telemetry/event.ts index 25adf8495..228bf699f 100644 --- a/packages/core/src/telemetry/event.ts +++ b/packages/core/src/telemetry/event.ts @@ -6,6 +6,7 @@ export interface TrackingEvent { durationMs: number; success: boolean; errorMessage?: string; + exitCode?: number; httpStatus?: number; requestId?: string; cliVersion: string; @@ -19,7 +20,7 @@ export function createTrackingEvent(opts: { command: string; durationMs: number; success: boolean; - error?: { message?: string; httpStatus?: number; requestId?: string }; + error?: { message?: string; exitCode?: number; httpStatus?: number; requestId?: string }; cliVersion: string; authMethod?: AuthRequirement; params?: Record; @@ -40,6 +41,7 @@ export function createTrackingEvent(opts: { if (!opts.success && opts.error) { if (opts.error.message) event.errorMessage = opts.error.message; + if (opts.error.exitCode !== undefined) event.exitCode = opts.error.exitCode; if (opts.error.httpStatus !== undefined) event.httpStatus = opts.error.httpStatus; if (opts.error.requestId) event.requestId = opts.error.requestId; } diff --git a/packages/core/src/telemetry/tracker.ts b/packages/core/src/telemetry/tracker.ts index aace9df65..0335d3a02 100644 --- a/packages/core/src/telemetry/tracker.ts +++ b/packages/core/src/telemetry/tracker.ts @@ -103,6 +103,7 @@ export async function trackCommandExecution( const start = performance.now(); let success = true; let errorMessage: string | undefined; + let exitCode: number | undefined; let httpStatus: number | undefined; let requestId: string | undefined; @@ -112,6 +113,7 @@ export async function trackCommandExecution( success = false; if (err instanceof BailianError) { errorMessage = err.message; + exitCode = err.exitCode; httpStatus = err.api?.httpStatus; requestId = err.api?.requestId; } else if (err instanceof Error) { @@ -125,7 +127,7 @@ export async function trackCommandExecution( command: commandPath.join(" "), durationMs, success, - error: success ? undefined : { message: errorMessage, httpStatus, requestId }, + error: success ? undefined : { message: errorMessage, exitCode, httpStatus, requestId }, cliVersion: deps.identity.version, authMethod: deps.authMethod, params: extractParams(flags), diff --git a/packages/core/src/types/command.ts b/packages/core/src/types/command.ts index 95e524deb..66b626817 100644 --- a/packages/core/src/types/command.ts +++ b/packages/core/src/types/command.ts @@ -258,10 +258,24 @@ export interface CommandContext { * typed flags (`ParsedFlags` = 命令自有 flag). Stored heterogeneously as * {@link AnyCommand}; the precise typing lives at the `defineCommand` call site. */ +export type CommandRiskLevel = "high"; + +export interface CommandRisk { + level: CommandRiskLevel; + message: LocalizedText; +} + export interface Command { description: LocalizedText; /** Credential this command requires. See {@link AuthRequirement}. */ auth: AuthRequirement; + /** + * Runtime-classified operation risk and its user-facing consequence message. + * Omit for normal commands. + * High-risk commands must return from `run` on `settings.dryRun` before any + * remote request or local write; runtime only owns the confirmation gate. + */ + risk?: CommandRisk; /** Usage line arg portion, e.g. "--prompt [flags]". Manually written. */ usageArgs?: string; /** Example args (without the ` ` prefix). */ diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index b617bdd31..cf240e3f2 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -1,5 +1,7 @@ export type { Command, + CommandRisk, + CommandRiskLevel, AnyCommand, CommandContext, LocalizedText, diff --git a/packages/core/tests/command-types.test.ts b/packages/core/tests/command-types.test.ts new file mode 100644 index 000000000..617979ea7 --- /dev/null +++ b/packages/core/tests/command-types.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from "vite-plus/test"; +import { defineCommand, type CommandPack, type CommandRiskLevel } from "../src/index.ts"; + +const noopRun = async () => {}; + +test("high-risk commands keep level and message in one typed object", () => { + const command = defineCommand({ + description: "danger", + auth: "none", + risk: { level: "high", message: "dangerous operation" }, + run: noopRun, + }); + const pack = { + "agent dangerous": { + description: "danger", + auth: "none", + risk: { level: "high", message: "dangerous operation" }, + run: noopRun, + }, + } satisfies CommandPack; + + expect(command.risk).toEqual({ level: "high", message: "dangerous operation" }); + expect(pack["agent dangerous"].risk.level).toBe("high"); +}); + +test("risk types reject flat or incomplete declarations", () => { + const high = "high" satisfies CommandRiskLevel; + // @ts-expect-error unsupported levels must be added to CommandRiskLevel first. + const low = "low" satisfies CommandRiskLevel; + + defineCommand({ + description: "danger", + auth: "none", + // @ts-expect-error high-risk metadata requires a message. + risk: { level: "high" }, + run: noopRun, + }); + defineCommand({ + description: "danger", + auth: "none", + // @ts-expect-error risk metadata is a single object, not a flat level. + risk: "high", + run: noopRun, + }); + + expect(high).toBe("high"); + expect(low).toBe("low"); +}); diff --git a/packages/core/tests/telemetry-tracker.test.ts b/packages/core/tests/telemetry-tracker.test.ts new file mode 100644 index 000000000..68397111f --- /dev/null +++ b/packages/core/tests/telemetry-tracker.test.ts @@ -0,0 +1,54 @@ +import { expect, test, vi } from "vite-plus/test"; +import type { Identity, Settings } from "../src/config/schema.ts"; +import { BailianError } from "../src/errors/base.ts"; +import { ExitCode } from "../src/errors/codes.ts"; +import { buildRemoteAemOptions, type TrackingEvent } from "../src/telemetry/event.ts"; + +const sinkMocks = vi.hoisted(() => ({ + localSink: vi.fn<(event: TrackingEvent) => Promise>(async () => {}), + remoteSink: vi.fn<(event: TrackingEvent) => Promise>(async () => {}), +})); + +vi.mock("../src/telemetry/sink.ts", () => sinkMocks); + +import { trackCommandExecution } from "../src/telemetry/tracker.ts"; + +const identity: Identity = { + binName: "bl", + version: "0.0.0-test", + clientName: "bailian-cli-test", + npmPackage: "bailian-cli", +}; + +test("records BailianError exitCode in local events and AEM ext", async () => { + sinkMocks.localSink.mockClear(); + sinkMocks.remoteSink.mockClear(); + const error = new BailianError("该操作会永久删除文档。", ExitCode.CONFIRMATION_REQUIRED); + + await expect( + trackCommandExecution( + { + identity, + settings: { telemetry: true } as Settings, + authMethod: "apiKey", + }, + ["knowledge", "doc", "delete"], + {}, + async () => { + throw error; + }, + ), + ).rejects.toBe(error); + + expect(sinkMocks.localSink).toHaveBeenCalledOnce(); + const event = sinkMocks.localSink.mock.calls[0]![0]; + expect(event).toMatchObject({ + command: "knowledge doc delete", + success: false, + exitCode: 7, + errorMessage: "该操作会永久删除文档。", + }); + expect(buildRemoteAemOptions(event)).toMatchObject({ + ext: expect.objectContaining({ exitCode: 7 }), + }); +}); diff --git a/packages/runtime/src/command-packs/validate.ts b/packages/runtime/src/command-packs/validate.ts index 79e47d4e8..5906a16af 100644 --- a/packages/runtime/src/command-packs/validate.ts +++ b/packages/runtime/src/command-packs/validate.ts @@ -90,6 +90,9 @@ function assertCommand(path: string, value: unknown): asserts value is CommandPa if (!command.auth || !AUTH_REQUIREMENTS.has(command.auth)) { throw new Error(`Command "${path}" has an invalid auth requirement.`); } + if (command.risk !== undefined && !isLocalizedText(command.risk.message)) { + throw new Error(`Command "${path}" has an invalid risk message.`); + } if (typeof command.run !== "function") { throw new Error(`Command "${path}" is missing run(ctx).`); } @@ -106,6 +109,7 @@ function adaptCommandPack( { description: command.description, auth: command.auth, + risk: command.risk, usageArgs: command.usageArgs, exampleArgs: command.exampleArgs, notes: command.notes, diff --git a/packages/runtime/src/confirm.ts b/packages/runtime/src/confirm.ts index 43cf6c62d..29e1680d8 100644 --- a/packages/runtime/src/confirm.ts +++ b/packages/runtime/src/confirm.ts @@ -1,14 +1,34 @@ -// Confirmation guard for dangerous operations — used by irreversible or -// production-affecting commands (kb/doc/chunk/category/file delete, service -// delete/deploy, ...). import { createInterface } from "node:readline/promises"; -import { BailianError, ExitCode } from "bailian-cli-core"; +import { + BailianError, + ExitCode, + type CommandRisk, + type FlagsDef, + type LocalizedText, +} from "bailian-cli-core"; + +/** Runtime-owned flag: commands declare risk, never their own confirmation flag. */ +export const CONFIRMATION_FLAGS = { + yes: { + type: "switch", + description: { + "en-US": "Confirm this high-risk operation", + "zh-CN": "确认执行此高风险操作", + }, + }, +} satisfies FlagsDef; + +export function confirmationFlagDefs(command: { risk?: CommandRisk }): FlagsDef { + return command.risk === undefined ? {} : CONFIRMATION_FLAGS; +} /** - * - `yes` (the command's own --yes switch) → pass through - * - TTY: print the summary and wait for y/yes (case-insensitive); any other - * input cancels with exit SUCCESS (cancellation is not an error) - * - non-TTY without --yes: throw USAGE + * Transitional compatibility for commands that have not moved to command-level + * risk metadata yet. + * TODO(next commit): migrate every remaining caller to command-level risk metadata, then + * remove this helper and update their confirmation wording/examples together. + * + * @deprecated Declare command-level risk metadata and let runtime gate confirmation. */ export async function confirmDangerousAction(summary: string, yes: boolean): Promise { if (yes) return; @@ -34,3 +54,35 @@ export async function confirmDangerousAction(summary: string, yes: boolean): Pro readline.close(); } } + +export function confirmationHint(): LocalizedText { + return { + "en-US": + "This command performs a high-risk operation. To continue, add --yes to the original command and re-run it.", + "zh-CN": "此命令将执行高风险操作。如确认继续,请在原命令中添加 --yes 后重新执行。", + }; +} + +interface ConfirmationRequiredErrorOptions { + message: string; + hint: string; +} + +/** Semantic runtime error consumed by both humans and Agent callers. */ +export class ConfirmationRequiredError extends BailianError { + constructor(options: ConfirmationRequiredErrorOptions) { + super(options.message, ExitCode.CONFIRMATION_REQUIRED, options.hint); + this.name = "ConfirmationRequiredError"; + } + + override toJSON() { + return { + error: { + code: this.exitCode, + type: "requires_confirmation", + message: this.message, + hint: this.hint, + }, + }; + } +} diff --git a/packages/runtime/src/create-cli.ts b/packages/runtime/src/create-cli.ts index be75345f6..767e0fcc7 100644 --- a/packages/runtime/src/create-cli.ts +++ b/packages/runtime/src/create-cli.ts @@ -6,10 +6,18 @@ import { authStage, telemetryStage, versionCheckStage, + confirmationStage, runCommandStage, type RunContext, } from "./middleware.ts"; -import type { AnyCommand, FlagsDef, Identity, ParsedFlags, SourceFlags } from "bailian-cli-core"; +import type { + AnyCommand, + FlagsDef, + Identity, + LocalizedText, + ParsedFlags, + SourceFlags, +} from "bailian-cli-core"; import { CONSOLE_AUTH_FLAGS, DEFAULT_LANGUAGE, @@ -33,6 +41,7 @@ import { loadCommandPacks } from "./command-packs/load.ts"; import { createCommandPackManager } from "./command-packs/manager.ts"; import type { CommandPackPolicy } from "./command-packs/types.ts"; import { createTranslator } from "./i18n.ts"; +import { confirmationFlagDefs } from "./confirm.ts"; /** Per-product identity injected by each CLI entrypoint (bl / rag / …). */ export interface CliOptions { @@ -113,7 +122,13 @@ export function createCli(commands: Record, opts: CliOptions installProcessHandlers(binName); - const runMiddleware = compose([versionCheckStage, telemetryStage, authStage, runCommandStage]); + const runMiddleware = compose([ + telemetryStage, + confirmationStage, + versionCheckStage, + authStage, + runCommandStage, + ]); function getLoadedCommandPacks(): ReturnType { if (!loadedCommandPacksPromise) { @@ -122,11 +137,17 @@ export function createCli(commands: Record, opts: CliOptions return loadedCommandPacksPromise; } - async function getRegistry(argv: string[]): Promise { + async function getRegistry(argv: string[]): Promise<{ + registry: CommandRegistry; + localize: (text: LocalizedText) => string; + }> { const localeSources = buildSources(pickConfigFlag(argv)); const translator = createTranslator(localeSources.file.language ?? DEFAULT_LANGUAGE); const loaded = await getLoadedCommandPacks(); - return new CommandRegistry(loaded.commands, binName, translator); + return { + registry: new CommandRegistry(loaded.commands, binName, translator), + localize: (text) => translator.localize(text), + }; } /** Render help for `path`; root ([]) doubles as the onboarding / login guide. */ @@ -157,7 +178,11 @@ export function createCli(commands: Record, opts: CliOptions } } - async function dispatch(registry: CommandRegistry, argv: string[]): Promise { + async function dispatch( + registry: CommandRegistry, + argv: string[], + localize: (text: LocalizedText) => string, + ): Promise { const res = resolve(argv, registry); switch (res.kind) { @@ -177,9 +202,11 @@ export function createCli(commands: Record, opts: CliOptions try { // 全局与凭证域 flag 进 sources,命令自有 flag 进 ctx.flags。 const credDefs = credentialFlagDefs(res.command); + const confirmationDefs = confirmationFlagDefs(res.command); const parsedFlags = parseFlags(res.rest, { ...GLOBAL_FLAGS, ...credDefs, + ...confirmationDefs, ...res.command.flags, }) as Record; const globalFlags = pick(parsedFlags, [ @@ -201,6 +228,8 @@ export function createCli(commands: Record, opts: CliOptions path: res.path, command: res.command, flags: ownFlags, + confirmed: parsedFlags.yes === true, + localize, settings, sources, configStore: makeConfigStore(sources.configName), @@ -229,7 +258,7 @@ export function createCli(commands: Record, opts: CliOptions run(argv: string[] = process.argv.slice(2)) { return Promise.resolve() .then(() => getRegistry(argv)) - .then((registry) => dispatch(registry, argv)) + .then(({ registry, localize }) => dispatch(registry, argv, localize)) .catch( (err) => flushTelemetry(1000).finally(() => handleError(err, binName)) as unknown as void, ); diff --git a/packages/runtime/src/middleware.ts b/packages/runtime/src/middleware.ts index c1579a9b5..07f0d5346 100644 --- a/packages/runtime/src/middleware.ts +++ b/packages/runtime/src/middleware.ts @@ -11,6 +11,7 @@ import type { ParsedFlags, ResolutionSources, Settings, + LocalizedText, } from "bailian-cli-core"; import { Client, @@ -28,6 +29,7 @@ import { performAutoUpdate, shouldAutoUpdate, } from "./utils/update-checker.ts"; +import { ConfirmationRequiredError, confirmationHint } from "./confirm.ts"; /** * What each middleware stage gets for the invocation in flight: the matched @@ -42,6 +44,10 @@ export interface RunContext { readonly command: AnyCommand; /** 只含本命令声明的 flag(分流后);全局 flag 在 sources/settings。 */ flags: ParsedFlags; + /** Whether the runtime-owned --yes flag was explicitly supplied. */ + readonly confirmed: boolean; + /** Locale selector for runtime-owned command metadata and messages. */ + readonly localize: (text: LocalizedText) => string; /** 解析后的有效配置面(命令的新读取面;双轨迁移期与 config 并存)。 */ settings: Settings; /** 解析源:provider/访问器用;业务命令不可见(窄视图类型不含此字段)。 */ @@ -162,5 +168,21 @@ export const versionCheckStage: Middleware = async (ctx, next) => { } }; +/** + * Safety gate before update/auth/command stages. Telemetry may wrap this stage + * so confirmation-required failures remain observable. + */ +export const confirmationStage: Middleware = async (ctx, next) => { + if (ctx.command.risk === undefined || ctx.confirmed || ctx.settings.dryRun) { + await next(); + return; + } + + throw new ConfirmationRequiredError({ + message: ctx.localize(ctx.command.risk.message), + hint: ctx.localize(confirmationHint()), + }); +}; + /** Innermost stage: hand control to the command with its full context. */ export const runCommandStage: Middleware = (ctx) => ctx.command.run(ctx); diff --git a/packages/runtime/src/registry.ts b/packages/runtime/src/registry.ts index 8bf89f73d..4b2fbcf1b 100644 --- a/packages/runtime/src/registry.ts +++ b/packages/runtime/src/registry.ts @@ -17,6 +17,7 @@ import { camelToKebab } from "./args.ts"; import type { Translator } from "./i18n.ts"; import { printQuickStart, printWelcomeBanner } from "./output/banner.ts"; import { ansi } from "./output/color.ts"; +import { confirmationFlagDefs } from "./confirm.ts"; export type { Command, AnyCommand, FlagDef, FlagsDef } from "bailian-cli-core"; @@ -101,7 +102,11 @@ export class CommandRegistry { private register(path: string, command: AnyCommand): void { // 同名守卫:命令自有 flag 不得与全局或其可见凭证域 flag 同名。 - const reserved = { ...GLOBAL_FLAGS, ...credentialFlagDefs(command) }; + const reserved = { + ...GLOBAL_FLAGS, + ...confirmationFlagDefs(command), + ...credentialFlagDefs(command), + }; for (const key of Object.keys(command.flags ?? {})) { if (key in reserved) { throw new Error(`Command "${path}" redeclares reserved flag "${key}".`); @@ -429,6 +434,7 @@ ${authFlagSections ? `${authFlagSections}\n\n` : ""}${b(this.localize(HELP_TEXT. ); const flagEntries = [ ...Object.entries(cmd.flags ?? {}), + ...Object.entries(confirmationFlagDefs(cmd)), ...Object.entries(credentialFlagDefs(cmd)), ] as [string, FlagDef][]; if (flagEntries.length > 0) { diff --git a/packages/runtime/tests/command-packs.test.ts b/packages/runtime/tests/command-packs.test.ts index fe227f4a7..37e3d9b3f 100644 --- a/packages/runtime/tests/command-packs.test.ts +++ b/packages/runtime/tests/command-packs.test.ts @@ -141,6 +141,7 @@ test("loads an API 1 Command Pack and preserves its command contract", async () expect(Object.keys(commands)).toEqual([ "agent credential", "agent credential-denied", + "agent dangerous", "agent fail", "agent output", "agent ping", @@ -151,6 +152,14 @@ test("loads an API 1 Command Pack and preserves its command contract", async () "en-US": "Ping the Command Pack fixture", "zh-CN": "调用 Command Pack 测试命令", }); + expect(commands["agent ping"]?.risk).toBeUndefined(); + expect(commands["agent dangerous"]?.risk).toEqual({ + level: "high", + message: { + "en-US": "This fixture represents a high-risk operation.", + "zh-CN": "该测试命令代表高风险操作。", + }, + }); expect(commands["agent ping"]?.flags?.message).toMatchObject({ required: true, type: "string" }); }); diff --git a/packages/runtime/tests/confirm.test.ts b/packages/runtime/tests/confirm.test.ts index e9fdba632..d487c2360 100644 --- a/packages/runtime/tests/confirm.test.ts +++ b/packages/runtime/tests/confirm.test.ts @@ -1,25 +1,133 @@ -import { afterEach, describe, expect, test } from "vite-plus/test"; -import { ExitCode } from "bailian-cli-core"; -import { confirmDangerousAction } from "../src/confirm.ts"; +import { afterEach, describe, expect, test, vi } from "vite-plus/test"; +import { defineCommand, ExitCode, type CommandRisk, type LocalizedText } from "bailian-cli-core"; +import { + ConfirmationRequiredError, + confirmDangerousAction, + confirmationFlagDefs, +} from "../src/confirm.ts"; +import { confirmationStage, type RunContext } from "../src/middleware.ts"; + +const readlineMocks = vi.hoisted(() => { + const question = vi.fn(async () => "y"); + const close = vi.fn(); + return { + question, + close, + createInterface: vi.fn(() => ({ question, close })), + }; +}); + +vi.mock("node:readline/promises", () => ({ createInterface: readlineMocks.createInterface })); const originalIsTTY = process.stdin.isTTY; + afterEach(() => { process.stdin.isTTY = originalIsTTY; + vi.restoreAllMocks(); + vi.clearAllMocks(); }); -describe("confirmDangerousAction", () => { - test("--yes 时直接通过,不触碰 stdin", async () => { - await expect(confirmDangerousAction("Delete kb idx-1", true)).resolves.toBeUndefined(); +const HIGH_RISK_MESSAGE = { + "en-US": "This permanently deletes the document and its chunks.", + "zh-CN": "该操作会永久删除文档及其 Chunk,且无法撤销。", +} satisfies LocalizedText; + +function makeContext(options: { + risk?: CommandRisk; + confirmed?: boolean; + dryRun?: boolean; +}): RunContext { + const command = defineCommand({ + description: "Delete a document", + auth: "none", + risk: options.risk, + async run() {}, + }); + return { + identity: { + binName: "bl", + version: "0.0.0-test", + clientName: "bailian-cli-test", + npmPackage: "bailian-cli", + }, + path: ["knowledge", "doc", "delete"], + command, + flags: {}, + confirmed: options.confirmed ?? false, + localize: (text: LocalizedText) => (typeof text === "string" ? text : text["zh-CN"]), + settings: { dryRun: options.dryRun ?? false } as RunContext["settings"], + } as unknown as RunContext; +} + +describe("confirmation metadata", () => { + test("injects --yes only for high-risk commands", () => { + expect( + confirmationFlagDefs({ risk: { level: "high", message: HIGH_RISK_MESSAGE } }), + ).toHaveProperty("yes"); + expect(confirmationFlagDefs({})).toEqual({}); + }); + + test("serializes the stable Agent-readable confirmation contract", () => { + const error = new ConfirmationRequiredError({ + message: HIGH_RISK_MESSAGE["zh-CN"], + hint: "此命令将执行高风险操作。如确认继续,请在原命令中添加 --yes 后重新执行。", + }); + + expect(error.exitCode).toBe(ExitCode.CONFIRMATION_REQUIRED); + expect(error.toJSON()).toEqual({ + error: { + code: 7, + type: "requires_confirmation", + message: HIGH_RISK_MESSAGE["zh-CN"], + hint: "此命令将执行高风险操作。如确认继续,请在原命令中添加 --yes 后重新执行。", + }, + }); }); - test("非 TTY 且无 --yes 时抛 USAGE 并引导 --yes", async () => { + test("legacy commands fail closed without opening a TTY prompt", async () => { process.stdin.isTTY = false; - try { - await confirmDangerousAction("Delete kb idx-1", false); - expect.unreachable("should throw"); - } catch (error) { - expect((error as { exitCode: number }).exitCode).toBe(ExitCode.USAGE); - expect((error as { hint?: string }).hint).toMatch(/--yes/); - } + await expect(confirmDangerousAction("legacy summary", false)).rejects.toMatchObject({ + exitCode: ExitCode.USAGE, + }); + await expect(confirmDangerousAction("legacy summary", true)).resolves.toBeUndefined(); + }); + + test("legacy commands retain their existing TTY confirmation during migration", async () => { + process.stdin.isTTY = true; + const stderrWrite = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + + await expect(confirmDangerousAction("legacy summary", false)).resolves.toBeUndefined(); + + expect(stderrWrite).toHaveBeenCalledWith("legacy summary\n"); + expect(readlineMocks.createInterface).toHaveBeenCalledOnce(); + expect(readlineMocks.question).toHaveBeenCalledWith("Proceed? [y/N] "); + expect(readlineMocks.close).toHaveBeenCalledOnce(); + }); +}); + +describe("confirmationStage", () => { + test("blocks high-risk execution without echoing the original command", async () => { + const next = vi.fn(async () => {}); + + const promise = confirmationStage( + makeContext({ risk: { level: "high", message: HIGH_RISK_MESSAGE } }), + next, + ); + await expect(promise).rejects.toMatchObject({ + exitCode: 7, + message: HIGH_RISK_MESSAGE["zh-CN"], + hint: "此命令将执行高风险操作。如确认继续,请在原命令中添加 --yes 后重新执行。", + }); + expect(next).not.toHaveBeenCalled(); + }); + + test.each([ + ["explicit --yes", { risk: { level: "high", message: HIGH_RISK_MESSAGE }, confirmed: true }], + ["dry-run", { risk: { level: "high", message: HIGH_RISK_MESSAGE }, dryRun: true }], + ["normal command", {}], + ] as const)("passes %s through", async (_label, options) => { + const next = vi.fn(async () => {}); + await confirmationStage(makeContext(options), next); + expect(next).toHaveBeenCalledOnce(); }); }); diff --git a/packages/runtime/tests/error-handler.test.ts b/packages/runtime/tests/error-handler.test.ts index a12865760..a217e8c47 100644 --- a/packages/runtime/tests/error-handler.test.ts +++ b/packages/runtime/tests/error-handler.test.ts @@ -1,6 +1,7 @@ import { ExitCode } from "bailian-cli-core"; import { expect, test } from "vite-plus/test"; import { handleError } from "../src/error-handler.ts"; +import { ConfirmationRequiredError } from "../src/confirm.ts"; test("handleError: fetch failed JSON includes cause.code from errno", () => { const previousOutput = process.env.DASHSCOPE_OUTPUT; @@ -8,7 +9,7 @@ test("handleError: fetch failed JSON includes cause.code from errno", () => { let stderr = ""; const originalWrite = process.stderr.write.bind(process.stderr); - const originalExit = process.exit; + const originalExit = process.exit.bind(process); process.stderr.write = ((chunk: string | Uint8Array) => { stderr += String(chunk); return true; @@ -52,7 +53,7 @@ test("handleError: fetch failed without nested cause still maps to NETWORK", () let stderr = ""; const originalWrite = process.stderr.write.bind(process.stderr); - const originalExit = process.exit; + const originalExit = process.exit.bind(process); process.stderr.write = ((chunk: string | Uint8Array) => { stderr += String(chunk); return true; @@ -83,3 +84,42 @@ test("handleError: fetch failed without nested cause still maps to NETWORK", () } } }); + +test("handleError: confirmation text uses the standard message and hint layout", () => { + const previousOutput = process.env.DASHSCOPE_OUTPUT; + delete process.env.DASHSCOPE_OUTPUT; + + let stderr = ""; + const originalWrite = process.stderr.write.bind(process.stderr); + const originalExit = process.exit.bind(process); + process.stderr.write = ((chunk: string | Uint8Array) => { + stderr += String(chunk); + return true; + }) as typeof process.stderr.write; + process.exit = ((code?: number) => { + throw new Error(`process.exit:${code ?? 0}`); + }) as typeof process.exit; + + const confirmation = new ConfirmationRequiredError({ + message: "This permanently deletes the selected documents and all of their chunks.", + hint: "This command performs a high-risk operation. To continue, add --yes to the original command and re-run it.", + }); + + try { + expect(() => handleError(confirmation, "bl")).toThrow( + new RegExp(`process\\.exit:${ExitCode.CONFIRMATION_REQUIRED}`), + ); + expect(stderr).toContain( + "This command performs a high-risk operation. To continue, add --yes to the original command and re-run it.", + ); + expect(stderr).not.toContain("bl knowledge doc delete"); + expect(stderr).not.toContain("Risk:"); + expect(stderr).not.toContain("Action:"); + expect(stderr).not.toContain("Note:"); + } finally { + process.stderr.write = originalWrite; + process.exit = originalExit; + if (previousOutput === undefined) delete process.env.DASHSCOPE_OUTPUT; + else process.env.DASHSCOPE_OUTPUT = previousOutput; + } +}); diff --git a/packages/runtime/tests/public-api.test.ts b/packages/runtime/tests/public-api.test.ts new file mode 100644 index 000000000..a03ea2662 --- /dev/null +++ b/packages/runtime/tests/public-api.test.ts @@ -0,0 +1,11 @@ +import { expect, test } from "vite-plus/test"; +import * as runtimeApi from "../src/index.ts"; + +test("confirmation orchestration stays internal to createCli", () => { + expect(runtimeApi).toHaveProperty("confirmDangerousAction"); + expect(runtimeApi).not.toHaveProperty("confirmationStage"); + expect(runtimeApi).not.toHaveProperty("CONFIRMATION_FLAGS"); + expect(runtimeApi).not.toHaveProperty("confirmationFlagDefs"); + expect(runtimeApi).not.toHaveProperty("ConfirmationRequiredError"); + expect(runtimeApi.CommandRegistry.prototype).not.toHaveProperty("localizeText"); +}); diff --git a/packages/runtime/tests/registry-guard.test.ts b/packages/runtime/tests/registry-guard.test.ts index cdc270ee2..87c1e21bb 100644 --- a/packages/runtime/tests/registry-guard.test.ts +++ b/packages/runtime/tests/registry-guard.test.ts @@ -33,3 +33,49 @@ test("命令重声明其可见域的凭证 flag → 抛错;不可见域的同名 }); expect(() => new CommandRegistry({ "x y": modelCmd }, "bl")).not.toThrow(); }); + +test("high risk 命令不能自行声明 runtime 保留的 yes", () => { + const high = defineCommand({ + description: "test", + auth: "none", + risk: { level: "high", message: "dangerous operation" }, + flags: { yes: { type: "switch", description: "duplicate" } }, + run: noopRun, + }); + const normal = defineCommand({ + description: "test", + auth: "none", + flags: { yes: { type: "switch", description: "command-owned" } }, + run: noopRun, + }); + + expect(() => new CommandRegistry({ "x high": high }, "bl")).toThrow(/yes/); + expect(() => new CommandRegistry({ "x normal": normal }, "bl")).not.toThrow(); +}); + +test("命令 help 只为 high risk 展示 runtime 注入的 --yes", () => { + const high = defineCommand({ + description: "danger", + auth: "none", + risk: { level: "high", message: "dangerous operation" }, + run: noopRun, + }); + const normal = defineCommand({ + description: "safe", + auth: "none", + run: noopRun, + }); + const registry = new CommandRegistry({ "asset delete": high, "asset list": normal }, "bl"); + + let highHelp = ""; + let normalHelp = ""; + registry.printHelp(["asset", "delete"], { + write: (chunk: string) => (highHelp += chunk), + } as unknown as NodeJS.WriteStream); + registry.printHelp(["asset", "list"], { + write: (chunk: string) => (normalHelp += chunk), + } as unknown as NodeJS.WriteStream); + + expect(highHelp).toContain("--yes"); + expect(normalHelp).not.toContain("--yes"); +}); diff --git a/skills/bailian-cli/reference/knowledge.md b/skills/bailian-cli/reference/knowledge.md index 1fc2aab71..918914964 100644 --- a/skills/bailian-cli/reference/knowledge.md +++ b/skills/bailian-cli/reference/knowledge.md @@ -508,8 +508,8 @@ bl knowledge delete --index-id idx-xxx --yes | --------------------- | ------ | -------- | --------------------------------------------------------------- | | `--index-id ` | string | yes | Knowledge base ID | | `--doc-id ` | array | yes | Document ID to delete (repeatable) | -| `--yes` | switch | no | Skip the confirmation prompt | | `--workspace-id ` | string | no | Workspace ID for API endpoint URL (or set BAILIAN_WORKSPACE_ID) | +| `--yes` | switch | no | Confirm this high-risk operation | | `--api-key ` | string | no | API key | | `--base-url ` | string | no | API base URL | @@ -523,7 +523,7 @@ bl knowledge delete --index-id idx-xxx --yes #### Examples ```bash -bl knowledge doc delete --index-id idx-xxx --doc-id file-xxx --workspace-id ws-xxx +bl knowledge doc delete --index-id idx-xxx --doc-id file-xxx --workspace-id ws-xxx --dry-run ``` ```bash diff --git a/tools/generate-reference.ts b/tools/generate-reference.ts index 98f2c6b94..d1ad007e6 100644 --- a/tools/generate-reference.ts +++ b/tools/generate-reference.ts @@ -29,6 +29,7 @@ import { type LocalizedText, } from "../packages/core/src/index.ts"; import { commands } from "../packages/cli/src/commands.ts"; +import { confirmationFlagDefs } from "../packages/runtime/src/confirm.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); const SKILLS_DIR = join(__dirname, "../skills"); @@ -160,7 +161,13 @@ function commandSection(path: string, cmd: AnyCommand): string { // 与命令 help 的 Flags 区一致:自有 + 该命令可见的凭证域 flag。 lines.push("#### Flags", ""); - lines.push(formatFlagsTable({ ...cmd.flags, ...credentialFlagDefs(cmd) })); + lines.push( + formatFlagsTable({ + ...cmd.flags, + ...confirmationFlagDefs(cmd), + ...credentialFlagDefs(cmd), + }), + ); if (cmd.notes?.length) { lines.push("#### Notes", ""); From 890610874470e33372cebebd05e8bd3b3758c9aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=BA=92?= Date: Thu, 27 Aug 2026 19:42:51 +0800 Subject: [PATCH 2/5] refactor(knowledge): migrate high-risk commands to runtime confirmation --- docs/knowledge/chunk.md | 2 +- docs/knowledge/collection-category.md | 8 +-- docs/knowledge/file.md | 8 +-- docs/knowledge/kb.md | 10 ++-- docs/knowledge/knowledge-cli-guide.md | 2 +- docs/knowledge/service.md | 18 +++--- docs/kscli/chunk.md | 2 +- docs/kscli/collection-category.md | 8 +-- docs/kscli/file.md | 8 +-- docs/kscli/kb.md | 10 ++-- docs/kscli/kscli-cli-guide.md | 2 +- docs/kscli/service.md | 18 +++--- packages/cli/doc-commands.md | 4 +- .../src/commands/knowledge/category-delete.ts | 18 +++--- .../src/commands/knowledge/chunk-delete.ts | 18 +++--- .../src/commands/knowledge/file-delete.ts | 41 +++---------- .../src/commands/knowledge/kb-delete.ts | 52 +++------------- .../src/commands/knowledge/service-delete.ts | 50 +++------------- .../src/commands/knowledge/service-deploy.ts | 51 +++------------- .../knowledge-chunk-category-file.e2e.test.ts | 60 ++++++++++++++++--- .../knowledge/knowledge-kb-delete.e2e.test.ts | 14 ++++- .../knowledge/knowledge-service.e2e.test.ts | 39 ++++++++++-- .../runtime/src/command-packs/validate.ts | 11 +++- packages/runtime/src/confirm.ts | 34 ----------- packages/runtime/src/index.ts | 1 - packages/runtime/tests/command-packs.test.ts | 36 +++++++++++ packages/runtime/tests/confirm.test.ts | 48 +-------------- packages/runtime/tests/public-api.test.ts | 11 ---- skills/bailian-cli/reference/knowledge.md | 12 ++-- 29 files changed, 250 insertions(+), 346 deletions(-) delete mode 100644 packages/runtime/tests/public-api.test.ts diff --git a/docs/knowledge/chunk.md b/docs/knowledge/chunk.md index 378ee8087..90926eeb8 100644 --- a/docs/knowledge/chunk.md +++ b/docs/knowledge/chunk.md @@ -213,7 +213,7 @@ bl knowledge chunk delete --index-id --chunk-id [flags] | ----------------- | ------ | ---- | ------------------------------------------------ | | `--index-id ` | string | 是 | 知识库 ID | | `--chunk-id ` | array | 是 | Chunk ID(可重复,每批最多 10 个,超出自动分批) | -| `--yes` | switch | 否 | 跳过确认提示 | +| `--yes` | switch | 否 | 显式确认执行高风险操作 | **输出** diff --git a/docs/knowledge/collection-category.md b/docs/knowledge/collection-category.md index 92a24aa65..e2e03a18e 100644 --- a/docs/knowledge/collection-category.md +++ b/docs/knowledge/collection-category.md @@ -232,10 +232,10 @@ bl knowledge category delete --category-id [flags] **参数** -| 参数 | 类型 | 必填 | 说明 | -| -------------------- | ------ | ---- | ------------ | -| `--category-id ` | string | 是 | 分类 ID | -| `--yes` | switch | 否 | 跳过确认提示 | +| 参数 | 类型 | 必填 | 说明 | +| -------------------- | ------ | ---- | ---------------------- | +| `--category-id ` | string | 是 | 分类 ID | +| `--yes` | switch | 否 | 显式确认执行高风险操作 | **输出** diff --git a/docs/knowledge/file.md b/docs/knowledge/file.md index 4f88a1984..ce5b02b73 100644 --- a/docs/knowledge/file.md +++ b/docs/knowledge/file.md @@ -120,10 +120,10 @@ bl knowledge file delete --file-id [flags] **参数** -| 参数 | 类型 | 必填 | 说明 | -| ---------------- | ------ | ---- | --------------- | -| `--file-id ` | string | 是 | 数据中心文件 ID | -| `--yes` | switch | 否 | 跳过确认提示 | +| 参数 | 类型 | 必填 | 说明 | +| ---------------- | ------ | ---- | ---------------------- | +| `--file-id ` | string | 是 | 数据中心文件 ID | +| `--yes` | switch | 否 | 显式确认执行高风险操作 | **输出** diff --git a/docs/knowledge/kb.md b/docs/knowledge/kb.md index 83fc10e5d..a1be43879 100644 --- a/docs/knowledge/kb.md +++ b/docs/knowledge/kb.md @@ -254,10 +254,10 @@ bl knowledge delete --index-id [flags] **参数** -| 参数 | 类型 | 必填 | 说明 | -| ----------------- | ------ | ---- | ------------ | -| `--index-id ` | string | 是 | 知识库 ID | -| `--yes` | switch | 否 | 跳过确认提示 | +| 参数 | 类型 | 必填 | 说明 | +| ----------------- | ------ | ---- | ---------------------- | +| `--index-id ` | string | 是 | 知识库 ID | +| `--yes` | switch | 否 | 显式确认执行高风险操作 | **输出** @@ -275,7 +275,7 @@ json 模式:返回 API 原始响应。 - **不可逆操作**:知识库及所有索引内容被永久删除。 - 数据中心中的源文件不受影响,仅删除知识库索引。 -- 不带 `--yes` 时,CLI 会先查询知识库名称和文档数量作为确认摘要。 +- 不带 `--yes` 时,runtime 会在调用知识库业务 API、执行删除前返回确认请求。 **示例** diff --git a/docs/knowledge/knowledge-cli-guide.md b/docs/knowledge/knowledge-cli-guide.md index 5080938ff..1f1f618a6 100644 --- a/docs/knowledge/knowledge-cli-guide.md +++ b/docs/knowledge/knowledge-cli-guide.md @@ -135,7 +135,7 @@ ### 危险操作确认 -涉及删除的命令(`kb delete`、`doc delete`、`chunk delete`、`file delete`、`category delete`、`service delete`、`service deploy`)在执行前会弹出二次确认提示。使用 `--yes` 可跳过确认,适用于自动化脚本。 +涉及删除的命令(`kb delete`、`doc delete`、`chunk delete`、`file delete`、`category delete`、`service delete`、`service deploy`)属于高风险操作。未带 `--yes` 时 CLI 不会执行,也不会弹出交互式 Y/N,而是返回 exit code 7 和 `requires_confirmation`;确认后在原命令中添加 `--yes` 重新执行。 ### Dry-run 模式 diff --git a/docs/knowledge/service.md b/docs/knowledge/service.md index a4cb2d61d..321af8068 100644 --- a/docs/knowledge/service.md +++ b/docs/knowledge/service.md @@ -270,11 +270,11 @@ bl knowledge service deploy --agent-id [flags] **参数** -| 参数 | 类型 | 必填 | 说明 | -| ----------------------- | ------ | ---- | ---------------- | -| `--agent-id ` | string | 是 | 服务(agent)ID | -| `--version-desc ` | string | 否 | 新版本的描述说明 | -| `--yes` | switch | 否 | 跳过确认提示 | +| 参数 | 类型 | 必填 | 说明 | +| ----------------------- | ------ | ---- | ---------------------- | +| `--agent-id ` | string | 是 | 服务(agent)ID | +| `--version-desc ` | string | 否 | 新版本的描述说明 | +| `--yes` | switch | 否 | 显式确认执行高风险操作 | **输出** @@ -319,10 +319,10 @@ bl knowledge service delete --agent-id [flags] **参数** -| 参数 | 类型 | 必填 | 说明 | -| ----------------- | ------ | ---- | --------------- | -| `--agent-id ` | string | 是 | 服务(agent)ID | -| `--yes` | switch | 否 | 跳过确认提示 | +| 参数 | 类型 | 必填 | 说明 | +| ----------------- | ------ | ---- | ---------------------- | +| `--agent-id ` | string | 是 | 服务(agent)ID | +| `--yes` | switch | 否 | 显式确认执行高风险操作 | **输出** diff --git a/docs/kscli/chunk.md b/docs/kscli/chunk.md index e75b8691a..ee97c344b 100644 --- a/docs/kscli/chunk.md +++ b/docs/kscli/chunk.md @@ -213,7 +213,7 @@ kscli chunk delete --index-id --chunk-id [flags] | ----------------- | ------ | ---- | ------------------------------------------------ | | `--index-id ` | string | 是 | 知识库 ID | | `--chunk-id ` | array | 是 | Chunk ID(可重复,每批最多 10 个,超出自动分批) | -| `--yes` | switch | 否 | 跳过确认提示 | +| `--yes` | switch | 否 | 显式确认执行高风险操作 | **输出** diff --git a/docs/kscli/collection-category.md b/docs/kscli/collection-category.md index b4061c49f..898fe6a91 100644 --- a/docs/kscli/collection-category.md +++ b/docs/kscli/collection-category.md @@ -232,10 +232,10 @@ kscli category delete --category-id [flags] **参数** -| 参数 | 类型 | 必填 | 说明 | -| -------------------- | ------ | ---- | ------------ | -| `--category-id ` | string | 是 | 分类 ID | -| `--yes` | switch | 否 | 跳过确认提示 | +| 参数 | 类型 | 必填 | 说明 | +| -------------------- | ------ | ---- | ---------------------- | +| `--category-id ` | string | 是 | 分类 ID | +| `--yes` | switch | 否 | 显式确认执行高风险操作 | **输出** diff --git a/docs/kscli/file.md b/docs/kscli/file.md index e4edce0cd..dda80b09f 100644 --- a/docs/kscli/file.md +++ b/docs/kscli/file.md @@ -120,10 +120,10 @@ kscli file delete --file-id [flags] **参数** -| 参数 | 类型 | 必填 | 说明 | -| ---------------- | ------ | ---- | --------------- | -| `--file-id ` | string | 是 | 数据中心文件 ID | -| `--yes` | switch | 否 | 跳过确认提示 | +| 参数 | 类型 | 必填 | 说明 | +| ---------------- | ------ | ---- | ---------------------- | +| `--file-id ` | string | 是 | 数据中心文件 ID | +| `--yes` | switch | 否 | 显式确认执行高风险操作 | **输出** diff --git a/docs/kscli/kb.md b/docs/kscli/kb.md index 5fa4c34b8..802ca03e6 100644 --- a/docs/kscli/kb.md +++ b/docs/kscli/kb.md @@ -254,10 +254,10 @@ kscli kb delete --index-id [flags] **参数** -| 参数 | 类型 | 必填 | 说明 | -| ----------------- | ------ | ---- | ------------ | -| `--index-id ` | string | 是 | 知识库 ID | -| `--yes` | switch | 否 | 跳过确认提示 | +| 参数 | 类型 | 必填 | 说明 | +| ----------------- | ------ | ---- | ---------------------- | +| `--index-id ` | string | 是 | 知识库 ID | +| `--yes` | switch | 否 | 显式确认执行高风险操作 | **输出** @@ -275,7 +275,7 @@ json 模式:返回 API 原始响应。 - **不可逆操作**:知识库及所有索引内容被永久删除。 - 数据中心中的源文件不受影响,仅删除知识库索引。 -- 不带 `--yes` 时,CLI 会先查询知识库名称和文档数量作为确认摘要。 +- 不带 `--yes` 时,runtime 会在调用知识库业务 API、执行删除前返回确认请求。 **示例** diff --git a/docs/kscli/kscli-cli-guide.md b/docs/kscli/kscli-cli-guide.md index c9c7e6dda..fdaf8596f 100644 --- a/docs/kscli/kscli-cli-guide.md +++ b/docs/kscli/kscli-cli-guide.md @@ -152,7 +152,7 @@ kscli --help ### 危险操作确认 -涉及删除的命令(`kb delete`、`doc delete`、`chunk delete`、`file delete`、`category delete`、`service delete`)以及 `service deploy` 在执行前会弹出二次确认提示。使用 `--yes` 可跳过确认,适用于自动化脚本。 +涉及删除的命令(`kb delete`、`doc delete`、`chunk delete`、`file delete`、`category delete`、`service delete`)以及 `service deploy` 属于高风险操作。未带 `--yes` 时 CLI 不会执行,也不会弹出交互式 Y/N,而是返回 exit code 7 和 `requires_confirmation`;确认后在原命令中添加 `--yes` 重新执行。 ### Dry-run 模式 diff --git a/docs/kscli/service.md b/docs/kscli/service.md index 42bf09c2c..85a09d2dc 100644 --- a/docs/kscli/service.md +++ b/docs/kscli/service.md @@ -270,11 +270,11 @@ kscli service deploy --agent-id [flags] **参数** -| 参数 | 类型 | 必填 | 说明 | -| ----------------------- | ------ | ---- | ---------------- | -| `--agent-id ` | string | 是 | 服务(agent)ID | -| `--version-desc ` | string | 否 | 新版本的描述说明 | -| `--yes` | switch | 否 | 跳过确认提示 | +| 参数 | 类型 | 必填 | 说明 | +| ----------------------- | ------ | ---- | ---------------------- | +| `--agent-id ` | string | 是 | 服务(agent)ID | +| `--version-desc ` | string | 否 | 新版本的描述说明 | +| `--yes` | switch | 否 | 显式确认执行高风险操作 | **输出** @@ -319,10 +319,10 @@ kscli service delete --agent-id [flags] **参数** -| 参数 | 类型 | 必填 | 说明 | -| ----------------- | ------ | ---- | --------------- | -| `--agent-id ` | string | 是 | 服务(agent)ID | -| `--yes` | switch | 否 | 跳过确认提示 | +| 参数 | 类型 | 必填 | 说明 | +| ----------------- | ------ | ---- | ---------------------- | +| `--agent-id ` | string | 是 | 服务(agent)ID | +| `--yes` | switch | 否 | 显式确认执行高风险操作 | **输出** diff --git a/packages/cli/doc-commands.md b/packages/cli/doc-commands.md index f2f57b947..f628e37a4 100644 --- a/packages/cli/doc-commands.md +++ b/packages/cli/doc-commands.md @@ -116,9 +116,9 @@ **Flags**:`--index-id` 必填;`--doc-id` array 必填(可重复);`--yes`。 -**实现方案**:`doc-delete.ts`;确认摘要含 index_id + doc_id 列表(≤5 个全列,超出显示前 5 + 总数);输出以 `data.deleted` 为准(与入参数量不一致时 text 模式警告差异)。 +**实现方案**:`doc-delete.ts`;命令在 `risk` 对象中同时声明 `level: "high"` 和双语 `message`,由 runtime 在 `run()` 前统一确认;输出以 `data.deleted` 为准(与入参数量不一致时 text 模式警告差异)。 -**测试方案**:help / 缺参×2 / dry-run 断言 `doc_ids` 数组 / 非 TTY 无 `--yes` exitCode 2 / live 配合 upload 清理链。 +**测试方案**:help / 缺参×2 / dry-run 断言 `doc_ids` 数组 / 无 `--yes` 返回 exitCode 7 + `requires_confirmation` / live 配合 upload 清理链。 ## doc tag — 批量更新文档标签 diff --git a/packages/commands/src/commands/knowledge/category-delete.ts b/packages/commands/src/commands/knowledge/category-delete.ts index 3c94e9f97..cc208d240 100644 --- a/packages/commands/src/commands/knowledge/category-delete.ts +++ b/packages/commands/src/commands/knowledge/category-delete.ts @@ -6,7 +6,7 @@ import { type FlagsDef, type RagConnectorResponse, } from "bailian-cli-core"; -import { emitResult, emitBare, confirmDangerousAction } from "bailian-cli-runtime"; +import { emitResult, emitBare } from "bailian-cli-runtime"; import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; const CATEGORY_DELETE_FLAGS = { @@ -16,16 +16,19 @@ const CATEGORY_DELETE_FLAGS = { description: { "en-US": "Category ID to delete", "zh-CN": "要删除的类目 ID" }, required: true, }, - yes: { - type: "switch", - description: { "en-US": "Skip the confirmation prompt", "zh-CN": "跳过确认提示" }, - }, ...WORKSPACE_FLAG, } satisfies FlagsDef; export default defineCommand({ description: { "en-US": "Delete a data-center category", "zh-CN": "删除数据中心类目" }, auth: "apiKey", + risk: { + level: "high", + message: { + "en-US": "This deletes the selected data-center category and cannot be undone.", + "zh-CN": "该操作会删除所选数据中心类目,且无法撤销。", + }, + }, usageArgs: "--category-id [flags]", flags: CATEGORY_DELETE_FLAGS, notes: [ @@ -49,11 +52,6 @@ export default defineCommand({ return; } - await confirmDangerousAction( - `Delete category ${flags.categoryId}\nThis cannot be undone.`, - flags.yes ?? false, - ); - const response = await ctx.client.requestJson< RagConnectorResponse | undefined> >({ diff --git a/packages/commands/src/commands/knowledge/chunk-delete.ts b/packages/commands/src/commands/knowledge/chunk-delete.ts index e89593623..5afc2665e 100644 --- a/packages/commands/src/commands/knowledge/chunk-delete.ts +++ b/packages/commands/src/commands/knowledge/chunk-delete.ts @@ -7,7 +7,7 @@ import { type FlagsDef, type RagMutationResponse, } from "bailian-cli-core"; -import { emitResult, emitBare, confirmDangerousAction } from "bailian-cli-runtime"; +import { emitResult, emitBare } from "bailian-cli-runtime"; import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; const CHUNK_DELETE_FLAGS = { @@ -26,10 +26,6 @@ const CHUNK_DELETE_FLAGS = { }, required: true, }, - yes: { - type: "switch", - description: { "en-US": "Skip the confirmation prompt", "zh-CN": "跳过确认提示" }, - }, ...WORKSPACE_FLAG, } satisfies FlagsDef; @@ -48,6 +44,13 @@ export default defineCommand({ "zh-CN": "从知识库中删除 Chunk(不可撤销)", }, auth: "apiKey", + risk: { + level: "high", + message: { + "en-US": "This permanently deletes the selected chunks and cannot be undone.", + "zh-CN": "该操作会永久删除所选 Chunk,且无法撤销。", + }, + }, usageArgs: "--index-id --chunk-id [flags]", flags: CHUNK_DELETE_FLAGS, notes: [ @@ -81,11 +84,6 @@ export default defineCommand({ return; } - await confirmDangerousAction( - `Delete ${flags.chunkId.length} chunk(s) from knowledge base ${flags.indexId} in ${batches.length} batch(es).\nChunks are permanently removed. This cannot be undone.`, - flags.yes ?? false, - ); - // Sequential batches; any batch failure aborts, listing already-deleted batches in the error let deletedCount = 0; for (const batchIds of batches) { diff --git a/packages/commands/src/commands/knowledge/file-delete.ts b/packages/commands/src/commands/knowledge/file-delete.ts index f73b94cab..7242d3879 100644 --- a/packages/commands/src/commands/knowledge/file-delete.ts +++ b/packages/commands/src/commands/knowledge/file-delete.ts @@ -3,12 +3,10 @@ import { ragEndpoint, RAG_PATHS, detectOutputFormat, - type Client, type FlagsDef, type RagConnectorResponse, - type RagDescribeFileResponse, } from "bailian-cli-core"; -import { emitResult, emitBare, confirmDangerousAction } from "bailian-cli-runtime"; +import { emitResult, emitBare } from "bailian-cli-runtime"; import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; const FILE_DELETE_FLAGS = { @@ -21,39 +19,23 @@ const FILE_DELETE_FLAGS = { }, required: true, }, - yes: { - type: "switch", - description: { "en-US": "Skip the confirmation prompt", "zh-CN": "跳过确认提示" }, - }, ...WORKSPACE_FLAG, } satisfies FlagsDef; -/** Confirmation summary lookup (file name/size); failure degrades to id-only */ -async function buildDeleteSummary( - client: Client, - workspaceId: string, - fileId: string, -): Promise { - let infoPart = ""; - try { - const detail = await client.requestJson({ - path: ragEndpoint(workspaceId, RAG_PATHS.describeFile), - method: "POST", - body: { fileId }, - }); - if (detail.data?.fileName) infoPart = ` name: ${detail.data.fileName}`; - } catch { - // Degrade gracefully: a failed lookup does not block confirmation - } - return `Delete data-center file ${fileId}${infoPart}\nPERMANENT: if the file is referenced by knowledge bases, their document indexes break too. This differs from removing a document from one knowledge base.`; -} - export default defineCommand({ description: { "en-US": "Permanently delete a file from the data center", "zh-CN": "从数据中心永久删除文件", }, auth: "apiKey", + risk: { + level: "high", + message: { + "en-US": + "This permanently deletes the data-center file. Knowledge-base document indexes that reference it may become invalid.", + "zh-CN": "该操作会永久删除数据中心文件;引用该文件的知识库文档索引可能失效。", + }, + }, usageArgs: "--file-id [flags]", flags: FILE_DELETE_FLAGS, notes: [ @@ -82,11 +64,6 @@ export default defineCommand({ return; } - const summary = flags.yes - ? "" - : await buildDeleteSummary(ctx.client, workspaceId, flags.fileId); - await confirmDangerousAction(summary, flags.yes ?? false); - const response = await ctx.client.requestJson< RagConnectorResponse | undefined> >({ diff --git a/packages/commands/src/commands/knowledge/kb-delete.ts b/packages/commands/src/commands/knowledge/kb-delete.ts index 945173e9a..03bc90375 100644 --- a/packages/commands/src/commands/knowledge/kb-delete.ts +++ b/packages/commands/src/commands/knowledge/kb-delete.ts @@ -4,12 +4,10 @@ import { RAG_PATHS, detectOutputFormat, type FlagsDef, - type RagIndexFilesResponse, type RagMutationResponse, } from "bailian-cli-core"; -import { emitResult, emitBare, confirmDangerousAction } from "bailian-cli-runtime"; +import { emitResult, emitBare } from "bailian-cli-runtime"; import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; -import { fetchIndexDetail } from "./kb-info.ts"; const KB_DELETE_FLAGS = { indexId: { @@ -18,50 +16,23 @@ const KB_DELETE_FLAGS = { description: { "en-US": "Knowledge base ID", "zh-CN": "知识库 ID" }, required: true, }, - yes: { - type: "switch", - description: { "en-US": "Skip the confirmation prompt", "zh-CN": "跳过确认提示" }, - }, ...WORKSPACE_FLAG, } satisfies FlagsDef; -/** Confirmation summary lookup: name + document count; any lookup failure degrades to id-only (never blocks deletion) */ -async function buildDeleteSummary( - ctx: { client: Parameters[0] }, - workspaceId: string, - indexId: string, -): Promise { - let namePart = ""; - let docCountPart = ""; - try { - const detail = await fetchIndexDetail(ctx.client, workspaceId, indexId); - namePart = ` name: ${detail.name}`; - } catch { - // Degrade gracefully: a missing name does not block confirmation - } - try { - const filesUrl = new URL(ragEndpoint(workspaceId, RAG_PATHS.indexFiles)); - filesUrl.searchParams.set("index_id", indexId); - filesUrl.searchParams.set("page_num", "1"); - filesUrl.searchParams.set("page_size", "1"); - const files = await ctx.client.requestJson({ - path: filesUrl.toString(), - method: "GET", - }); - const totalCount = files.data?.total_count; - if (typeof totalCount === "number") docCountPart = ` documents: ${totalCount}`; - } catch { - // Same graceful degradation as above - } - return `Delete knowledge base ${indexId}${namePart}${docCountPart}\nThis permanently removes the knowledge base with all documents and chunks. It cannot be undone.`; -} - export default defineCommand({ description: { "en-US": "Delete a knowledge base with all its documents and chunks", "zh-CN": "删除知识库及其所有文档和 Chunk", }, auth: "apiKey", + risk: { + level: "high", + message: { + "en-US": + "This permanently deletes the knowledge base and all of its documents and chunks. Data-center files are not deleted.", + "zh-CN": "该操作会永久删除知识库及其全部文档和 Chunk,但不会删除数据中心文件。", + }, + }, usageArgs: "--index-id [flags]", flags: KB_DELETE_FLAGS, notes: [ @@ -90,11 +61,6 @@ export default defineCommand({ return; } - const summary = flags.yes - ? "" // --yes bypasses the prompt, so skip the summary lookups - : await buildDeleteSummary(ctx, workspaceId, flags.indexId); - await confirmDangerousAction(summary, flags.yes ?? false); - const response = await ctx.client.requestJson({ path: endpoint, method: "POST", diff --git a/packages/commands/src/commands/knowledge/service-delete.ts b/packages/commands/src/commands/knowledge/service-delete.ts index a4af5a642..027e2d617 100644 --- a/packages/commands/src/commands/knowledge/service-delete.ts +++ b/packages/commands/src/commands/knowledge/service-delete.ts @@ -3,12 +3,10 @@ import { ragEndpoint, RAG_PATHS, detectOutputFormat, - type Client, type FlagsDef, - type RagAgentGetResponse, type RagAgentMutationResponse, } from "bailian-cli-core"; -import { emitResult, emitBare, confirmDangerousAction } from "bailian-cli-runtime"; +import { emitResult, emitBare } from "bailian-cli-runtime"; import { agentMutationField, resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; const SERVICE_DELETE_FLAGS = { @@ -18,48 +16,23 @@ const SERVICE_DELETE_FLAGS = { description: { "en-US": "Service (agent) ID", "zh-CN": "服务(Agent)ID" }, required: true, }, - yes: { - type: "switch", - description: { "en-US": "Skip the confirmation prompt", "zh-CN": "跳过确认提示" }, - }, ...WORKSPACE_FLAG, } satisfies FlagsDef; -/** Confirmation summary lookup (name/status); failure degrades to id-only */ -async function buildDeleteSummary( - client: Client, - workspaceId: string, - agentId: string, -): Promise { - let infoPart = ""; - let liveWarning = ""; - try { - const detail = await client.requestJson({ - path: ragEndpoint(workspaceId, RAG_PATHS.agentGet), - method: "POST", - body: { agent_id: agentId }, - }); - const name = detail.data?.agent_name; - const status = detail.data?.agent_status; - if (name) infoPart += ` name: ${name}`; - if (status) { - infoPart += ` status: ${status}`; - if (status === "deployed" || status === "edited") { - liveWarning = "\nWARNING: this service is LIVE — deleting it breaks existing callers."; - } - } - } catch { - // Degrade gracefully: a failed lookup does not block confirmation - } - return `Delete service ${agentId}${infoPart}${liveWarning}\nDeletion cannot be undone; the agent_id can no longer be used for search or chat calls.`; -} - export default defineCommand({ description: { "en-US": "Delete a retrieval / Q&A service (soft delete, idempotent)", "zh-CN": "删除检索/问答服务(软删除,幂等)", }, auth: "apiKey", + risk: { + level: "high", + message: { + "en-US": + "This deletes the service and makes its agent ID unavailable for search and chat calls. The operation cannot be undone.", + "zh-CN": "该操作会删除服务,使其 Agent ID 无法再用于搜索和对话调用,且无法撤销。", + }, + }, usageArgs: "--agent-id [flags]", flags: SERVICE_DELETE_FLAGS, notes: [ @@ -91,11 +64,6 @@ export default defineCommand({ return; } - const summary = flags.yes - ? "" - : await buildDeleteSummary(ctx.client, workspaceId, flags.agentId); - await confirmDangerousAction(summary, flags.yes ?? false); - const response = await ctx.client.requestJson({ path: endpoint, method: "POST", diff --git a/packages/commands/src/commands/knowledge/service-deploy.ts b/packages/commands/src/commands/knowledge/service-deploy.ts index 6a0dc9c3f..38ced5736 100644 --- a/packages/commands/src/commands/knowledge/service-deploy.ts +++ b/packages/commands/src/commands/knowledge/service-deploy.ts @@ -3,12 +3,10 @@ import { ragEndpoint, RAG_PATHS, detectOutputFormat, - type Client, type FlagsDef, - type RagAgentGetResponse, type RagAgentMutationResponse, } from "bailian-cli-core"; -import { emitResult, emitBare, confirmDangerousAction } from "bailian-cli-runtime"; +import { emitResult, emitBare } from "bailian-cli-runtime"; import { agentMutationField, resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; const SERVICE_DEPLOY_FLAGS = { @@ -26,49 +24,23 @@ const SERVICE_DEPLOY_FLAGS = { "zh-CN": "新发布版本的描述", }, }, - yes: { - type: "switch", - description: { "en-US": "Skip the confirmation prompt", "zh-CN": "跳过确认提示" }, - }, ...WORKSPACE_FLAG, } satisfies FlagsDef; -/** Confirmation summary lookup (name/status); warns that deploying an edited draft overwrites live behavior; failure degrades to id-only */ -async function buildDeploySummary( - client: Client, - workspaceId: string, - agentId: string, -): Promise { - let infoPart = ""; - let editedWarning = ""; - try { - const detail = await client.requestJson({ - path: ragEndpoint(workspaceId, RAG_PATHS.agentGet), - method: "POST", - body: { agent_id: agentId }, - }); - const name = detail.data?.agent_name; - const status = detail.data?.agent_status; - if (name) infoPart += ` name: ${name}`; - if (status) { - infoPart += ` status: ${status}`; - if (status === "edited") { - editedWarning = - "\nWARNING: a published version is live — deploying replaces its behavior with the current draft."; - } - } - } catch { - // Degrade gracefully: a failed lookup does not block confirmation - } - return `Deploy service ${agentId}${infoPart}${editedWarning}\nPublishing changes what live callers get from this service.`; -} - export default defineCommand({ description: { "en-US": "Publish the beta draft of a service as a new version", "zh-CN": "将服务的 beta 草稿发布为新版本", }, auth: "apiKey", + risk: { + level: "high", + message: { + "en-US": + "This publishes the current draft as a new version and changes the behavior seen by live callers.", + "zh-CN": "该操作会将当前草稿发布为新版本,并改变线上调用方使用的服务行为。", + }, + }, usageArgs: "--agent-id [flags]", flags: SERVICE_DEPLOY_FLAGS, notes: [ @@ -109,11 +81,6 @@ export default defineCommand({ return; } - const summary = flags.yes - ? "" - : await buildDeploySummary(ctx.client, workspaceId, flags.agentId); - await confirmDangerousAction(summary, flags.yes ?? false); - const response = await ctx.client.requestJson({ path: endpoint, method: "POST", diff --git a/packages/commands/tests/e2e/knowledge/knowledge-chunk-category-file.e2e.test.ts b/packages/commands/tests/e2e/knowledge/knowledge-chunk-category-file.e2e.test.ts index ef3e4a604..a1f708914 100644 --- a/packages/commands/tests/e2e/knowledge/knowledge-chunk-category-file.e2e.test.ts +++ b/packages/commands/tests/e2e/knowledge/knowledge-chunk-category-file.e2e.test.ts @@ -433,7 +433,7 @@ describe("e2e: knowledge chunk 组 (静态)", () => { expect(data.batches[1]!.request.chunkIds).toHaveLength(2); }); - test("chunk delete: 非 TTY 无 --yes 报 USAGE (2)", async () => { + test("chunk delete: 无 --yes 返回确认请求 (7)", async () => { const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ "knowledge", "chunk", @@ -446,9 +446,13 @@ describe("e2e: knowledge chunk 组 (静态)", () => { "sk-fake", "--workspace-id", "ws_test", + "--output", + "json", ]); - expect(exitCode).toBe(2); - expect(stderr).toMatch(/--yes/); + expect(exitCode).toBe(7); + expect(JSON.parse(stderr)).toMatchObject({ + error: { code: 7, type: "requires_confirmation" }, + }); }); }); @@ -549,7 +553,22 @@ describe("e2e: kb stats / category / file / connector / import-oss (静态)", () expect(data.request?.connectorId).toBe("conn_test"); }); - test("category delete: 非 TTY 无 --yes 报 USAGE (2)", async () => { + test("category delete: dry-run 断言 categoryId", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ + "knowledge", + "category", + "delete", + "--category-id", + "cate_test", + ...COMMON, + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson(stdout); + expect(data.endpoint).toMatch(/deleteCategory/); + expect(data.request?.categoryId).toBe("cate_test"); + }); + + test("category delete: 无 --yes 返回确认请求 (7)", async () => { const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ "knowledge", "category", @@ -560,9 +579,13 @@ describe("e2e: kb stats / category / file / connector / import-oss (静态)", () "sk-fake", "--workspace-id", "ws_test", + "--output", + "json", ]); - expect(exitCode).toBe(2); - expect(stderr).toMatch(/--yes/); + expect(exitCode).toBe(7); + expect(JSON.parse(stderr)).toMatchObject({ + error: { code: 7, type: "requires_confirmation" }, + }); }); test("file list: 缺 --category-id 报 USAGE (2)", async () => { @@ -618,7 +641,22 @@ describe("e2e: kb stats / category / file / connector / import-oss (静态)", () expect(data.request?.fileId).toBe("file_test"); }); - test("file delete: 非 TTY 无 --yes 报 USAGE (2)", async () => { + test("file delete: dry-run 断言 fileId", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ + "knowledge", + "file", + "delete", + "--file-id", + "file_test", + ...COMMON, + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson(stdout); + expect(data.endpoint).toMatch(/deleteFile/); + expect(data.request?.fileId).toBe("file_test"); + }); + + test("file delete: 无 --yes 返回确认请求 (7)", async () => { const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ "knowledge", "file", @@ -629,9 +667,13 @@ describe("e2e: kb stats / category / file / connector / import-oss (静态)", () "sk-fake", "--workspace-id", "ws_test", + "--output", + "json", ]); - expect(exitCode).toBe(2); - expect(stderr).toMatch(/--yes/); + expect(exitCode).toBe(7); + expect(JSON.parse(stderr)).toMatchObject({ + error: { code: 7, type: "requires_confirmation" }, + }); }); test("collection create: --name 21 字符 USAGE (2)", async () => { diff --git a/packages/commands/tests/e2e/knowledge/knowledge-kb-delete.e2e.test.ts b/packages/commands/tests/e2e/knowledge/knowledge-kb-delete.e2e.test.ts index 04629e751..a1637ac65 100644 --- a/packages/commands/tests/e2e/knowledge/knowledge-kb-delete.e2e.test.ts +++ b/packages/commands/tests/e2e/knowledge/knowledge-kb-delete.e2e.test.ts @@ -48,7 +48,7 @@ describe("e2e: knowledge kb delete", () => { expect(data.request?.index_id).toBe("idx_test"); }); - test("非 TTY 无 --yes 报 USAGE (2)", async () => { + test("无 --yes 返回确认请求 (7)", async () => { const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_KB_DELETE_ROUTES, [ "knowledge", "delete", @@ -58,9 +58,17 @@ describe("e2e: knowledge kb delete", () => { "sk-fake", "--workspace-id", "ws_test", + "--output", + "json", ]); - expect(exitCode).toBe(2); - expect(stderr).toMatch(/--yes/); + expect(exitCode).toBe(7); + expect(JSON.parse(stderr)).toMatchObject({ + error: { + code: 7, + type: "requires_confirmation", + hint: expect.stringContaining("--yes"), + }, + }); }); }); diff --git a/packages/commands/tests/e2e/knowledge/knowledge-service.e2e.test.ts b/packages/commands/tests/e2e/knowledge/knowledge-service.e2e.test.ts index 5f60768cf..0a54af9c5 100644 --- a/packages/commands/tests/e2e/knowledge/knowledge-service.e2e.test.ts +++ b/packages/commands/tests/e2e/knowledge/knowledge-service.e2e.test.ts @@ -454,7 +454,7 @@ describe("e2e: knowledge service update", () => { }); describe("e2e: knowledge service deploy / delete (危险)", () => { - test("deploy: 非 TTY 无 --yes 报 USAGE (2)", async () => { + test("deploy: 无 --yes 返回确认请求 (7)", async () => { const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [ "knowledge", "service", @@ -465,9 +465,13 @@ describe("e2e: knowledge service deploy / delete (危险)", () => { "sk-fake", "--workspace-id", "ws_test", + "--output", + "json", ]); - expect(exitCode).toBe(2); - expect(stderr).toMatch(/--yes/); + expect(exitCode).toBe(7); + expect(JSON.parse(stderr)).toMatchObject({ + error: { code: 7, type: "requires_confirmation" }, + }); }); test("deploy: --dry-run 断言 body", async () => { @@ -491,7 +495,7 @@ describe("e2e: knowledge service deploy / delete (危险)", () => { expect(data.request?.agent_version_desc).toBe("v1 desc"); }); - test("delete: 非 TTY 无 --yes 报 USAGE (2)", async () => { + test("delete: 无 --yes 返回确认请求 (7)", async () => { const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [ "knowledge", "service", @@ -502,9 +506,32 @@ describe("e2e: knowledge service deploy / delete (危险)", () => { "sk-fake", "--workspace-id", "ws_test", + "--output", + "json", ]); - expect(exitCode).toBe(2); - expect(stderr).toMatch(/--yes/); + expect(exitCode).toBe(7); + expect(JSON.parse(stderr)).toMatchObject({ + error: { code: 7, type: "requires_confirmation" }, + }); + }); + + test("delete: --dry-run 断言 body", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [ + "knowledge", + "service", + "delete", + "--agent-id", + "aid_test", + "--workspace-id", + "ws_test", + "--dry-run", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson(stdout); + expect(data.endpoint).toMatch(/rag\/app\/delete/); + expect(data.request?.agent_id).toBe("aid_test"); }); }); diff --git a/packages/runtime/src/command-packs/validate.ts b/packages/runtime/src/command-packs/validate.ts index 5906a16af..f6c0db9bf 100644 --- a/packages/runtime/src/command-packs/validate.ts +++ b/packages/runtime/src/command-packs/validate.ts @@ -79,6 +79,13 @@ function isLocalizedText(value: unknown): value is LocalizedText { ); } +function isCommandRisk(value: unknown): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + + const risk = value as Record; + return risk.level === "high" && isLocalizedText(risk.message); +} + function assertCommand(path: string, value: unknown): asserts value is CommandPackCommand { if (!value || typeof value !== "object") { throw new Error(`Command "${path}" must export an object.`); @@ -90,8 +97,8 @@ function assertCommand(path: string, value: unknown): asserts value is CommandPa if (!command.auth || !AUTH_REQUIREMENTS.has(command.auth)) { throw new Error(`Command "${path}" has an invalid auth requirement.`); } - if (command.risk !== undefined && !isLocalizedText(command.risk.message)) { - throw new Error(`Command "${path}" has an invalid risk message.`); + if (command.risk !== undefined && !isCommandRisk(command.risk)) { + throw new Error(`Command "${path}" has invalid risk metadata.`); } if (typeof command.run !== "function") { throw new Error(`Command "${path}" is missing run(ctx).`); diff --git a/packages/runtime/src/confirm.ts b/packages/runtime/src/confirm.ts index 29e1680d8..cfe39e1d5 100644 --- a/packages/runtime/src/confirm.ts +++ b/packages/runtime/src/confirm.ts @@ -1,4 +1,3 @@ -import { createInterface } from "node:readline/promises"; import { BailianError, ExitCode, @@ -22,39 +21,6 @@ export function confirmationFlagDefs(command: { risk?: CommandRisk }): FlagsDef return command.risk === undefined ? {} : CONFIRMATION_FLAGS; } -/** - * Transitional compatibility for commands that have not moved to command-level - * risk metadata yet. - * TODO(next commit): migrate every remaining caller to command-level risk metadata, then - * remove this helper and update their confirmation wording/examples together. - * - * @deprecated Declare command-level risk metadata and let runtime gate confirmation. - */ -export async function confirmDangerousAction(summary: string, yes: boolean): Promise { - if (yes) return; - if (!process.stdin.isTTY) { - throw new BailianError( - "Confirmation required for this destructive action.", - ExitCode.USAGE, - "Re-run with --yes to confirm in non-interactive mode", - ); - } - process.stderr.write(`${summary}\n`); - const readline = createInterface({ input: process.stdin, output: process.stderr }); - try { - const answer = (await readline.question("Proceed? [y/N] ")).trim().toLowerCase(); - if (answer !== "y" && answer !== "yes") { - process.stderr.write("Cancelled.\n"); - // Intentional: a user-initiated cancellation is not an error, and we want - // to exit here rather than unwind through the middleware stack (which - // would still print a success report for an action that did not happen). - process.exit(ExitCode.SUCCESS); - } - } finally { - readline.close(); - } -} - export function confirmationHint(): LocalizedText { return { "en-US": diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 215ec102c..ce41f46c9 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -63,7 +63,6 @@ export { // Utility facilities consumed by commands export { poll } from "./utils/polling.ts"; -export { confirmDangerousAction } from "./confirm.ts"; export { downloadFile, formatBytes } from "./utils/download.ts"; export { runConcurrent, getConcurrency, downloadParallel } from "./utils/concurrent.ts"; export { resolveImageSize } from "./utils/image-size.ts"; diff --git a/packages/runtime/tests/command-packs.test.ts b/packages/runtime/tests/command-packs.test.ts index 37e3d9b3f..d024db386 100644 --- a/packages/runtime/tests/command-packs.test.ts +++ b/packages/runtime/tests/command-packs.test.ts @@ -163,6 +163,42 @@ test("loads an API 1 Command Pack and preserves its command contract", async () expect(commands["agent ping"]?.flags?.message).toMatchObject({ required: true, type: "string" }); }); +test.each([ + ["null", null], + ["a non-object value", "high"], + ["a missing level", { message: "Dangerous operation." }], + ["an unsupported level", { level: "low", message: "Dangerous operation." }], + ["an invalid message", { level: "high", message: "" }], +])("rejects Command Pack risk metadata with %s", async (_caseName, risk) => { + const root = await mkdtemp(join(tmpdir(), "command-pack-risk-test-")); + + try { + await writeFile( + join(root, "commands.mjs"), + `export default { + "agent dangerous": { + description: "Dangerous command", + auth: "none", + risk: ${JSON.stringify(risk)}, + async run() {}, + }, + };\n`, + ); + + await expect( + loadAndValidateCommandPack( + "@ali/bailian-plugin-agent", + packageJson, + policy.supported["@ali/bailian-plugin-agent"]!, + identity, + root, + ), + ).rejects.toThrow(/invalid risk/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("rejects incompatible protocol versions and invalid command prefixes", async () => { await expect( loadAndValidateCommandPack( diff --git a/packages/runtime/tests/confirm.test.ts b/packages/runtime/tests/confirm.test.ts index d487c2360..0eb9e2e5f 100644 --- a/packages/runtime/tests/confirm.test.ts +++ b/packages/runtime/tests/confirm.test.ts @@ -1,32 +1,8 @@ -import { afterEach, describe, expect, test, vi } from "vite-plus/test"; +import { describe, expect, test, vi } from "vite-plus/test"; import { defineCommand, ExitCode, type CommandRisk, type LocalizedText } from "bailian-cli-core"; -import { - ConfirmationRequiredError, - confirmDangerousAction, - confirmationFlagDefs, -} from "../src/confirm.ts"; +import { ConfirmationRequiredError, confirmationFlagDefs } from "../src/confirm.ts"; import { confirmationStage, type RunContext } from "../src/middleware.ts"; -const readlineMocks = vi.hoisted(() => { - const question = vi.fn(async () => "y"); - const close = vi.fn(); - return { - question, - close, - createInterface: vi.fn(() => ({ question, close })), - }; -}); - -vi.mock("node:readline/promises", () => ({ createInterface: readlineMocks.createInterface })); - -const originalIsTTY = process.stdin.isTTY; - -afterEach(() => { - process.stdin.isTTY = originalIsTTY; - vi.restoreAllMocks(); - vi.clearAllMocks(); -}); - const HIGH_RISK_MESSAGE = { "en-US": "This permanently deletes the document and its chunks.", "zh-CN": "该操作会永久删除文档及其 Chunk,且无法撤销。", @@ -83,26 +59,6 @@ describe("confirmation metadata", () => { }, }); }); - - test("legacy commands fail closed without opening a TTY prompt", async () => { - process.stdin.isTTY = false; - await expect(confirmDangerousAction("legacy summary", false)).rejects.toMatchObject({ - exitCode: ExitCode.USAGE, - }); - await expect(confirmDangerousAction("legacy summary", true)).resolves.toBeUndefined(); - }); - - test("legacy commands retain their existing TTY confirmation during migration", async () => { - process.stdin.isTTY = true; - const stderrWrite = vi.spyOn(process.stderr, "write").mockImplementation(() => true); - - await expect(confirmDangerousAction("legacy summary", false)).resolves.toBeUndefined(); - - expect(stderrWrite).toHaveBeenCalledWith("legacy summary\n"); - expect(readlineMocks.createInterface).toHaveBeenCalledOnce(); - expect(readlineMocks.question).toHaveBeenCalledWith("Proceed? [y/N] "); - expect(readlineMocks.close).toHaveBeenCalledOnce(); - }); }); describe("confirmationStage", () => { diff --git a/packages/runtime/tests/public-api.test.ts b/packages/runtime/tests/public-api.test.ts deleted file mode 100644 index a03ea2662..000000000 --- a/packages/runtime/tests/public-api.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { expect, test } from "vite-plus/test"; -import * as runtimeApi from "../src/index.ts"; - -test("confirmation orchestration stays internal to createCli", () => { - expect(runtimeApi).toHaveProperty("confirmDangerousAction"); - expect(runtimeApi).not.toHaveProperty("confirmationStage"); - expect(runtimeApi).not.toHaveProperty("CONFIRMATION_FLAGS"); - expect(runtimeApi).not.toHaveProperty("confirmationFlagDefs"); - expect(runtimeApi).not.toHaveProperty("ConfirmationRequiredError"); - expect(runtimeApi.CommandRegistry.prototype).not.toHaveProperty("localizeText"); -}); diff --git a/skills/bailian-cli/reference/knowledge.md b/skills/bailian-cli/reference/knowledge.md index 918914964..44056a452 100644 --- a/skills/bailian-cli/reference/knowledge.md +++ b/skills/bailian-cli/reference/knowledge.md @@ -94,8 +94,8 @@ bl knowledge category add --name sub --parent-id cate-xxx | Flag | Type | Required | Description | | --------------------- | ------ | -------- | --------------------------------------------------------------- | | `--category-id ` | string | yes | Category ID to delete | -| `--yes` | switch | no | Skip the confirmation prompt | | `--workspace-id ` | string | no | Workspace ID for API endpoint URL (or set BAILIAN_WORKSPACE_ID) | +| `--yes` | switch | no | Confirm this high-risk operation | | `--api-key ` | string | no | API key | | `--base-url ` | string | no | API base URL | @@ -255,8 +255,8 @@ bl knowledge chunk add --index-id idx-xxx --field columnA=v1 --field columnB=v2 | --------------------- | ------ | -------- | --------------------------------------------------------------------- | | `--index-id ` | string | yes | Knowledge base ID | | `--chunk-id ` | array | yes | Chunk ID to delete (repeatable; batches of 10 are sent automatically) | -| `--yes` | switch | no | Skip the confirmation prompt | | `--workspace-id ` | string | no | Workspace ID for API endpoint URL (or set BAILIAN_WORKSPACE_ID) | +| `--yes` | switch | no | Confirm this high-risk operation | | `--api-key ` | string | no | API key | | `--base-url ` | string | no | API base URL | @@ -473,8 +473,8 @@ bl knowledge create --name demo --description 'product docs' --category-id cate- | Flag | Type | Required | Description | | --------------------- | ------ | -------- | --------------------------------------------------------------- | | `--index-id ` | string | yes | Knowledge base ID | -| `--yes` | switch | no | Skip the confirmation prompt | | `--workspace-id ` | string | no | Workspace ID for API endpoint URL (or set BAILIAN_WORKSPACE_ID) | +| `--yes` | switch | no | Confirm this high-risk operation | | `--api-key ` | string | no | API key | | `--base-url ` | string | no | API base URL | @@ -740,8 +740,8 @@ bl knowledge doc upload --file ./docs/ --dry-run --verbose | Flag | Type | Required | Description | | --------------------- | ------ | -------- | --------------------------------------------------------------- | | `--file-id ` | string | yes | Data-center file ID to delete | -| `--yes` | switch | no | Skip the confirmation prompt | | `--workspace-id ` | string | no | Workspace ID for API endpoint URL (or set BAILIAN_WORKSPACE_ID) | +| `--yes` | switch | no | Confirm this high-risk operation | | `--api-key ` | string | no | API key | | `--base-url ` | string | no | API base URL | @@ -1044,8 +1044,8 @@ bl knowledge service create --name my-search --scene search --index-id idx-xxx | Flag | Type | Required | Description | | --------------------- | ------ | -------- | --------------------------------------------------------------- | | `--agent-id ` | string | yes | Service (agent) ID | -| `--yes` | switch | no | Skip the confirmation prompt | | `--workspace-id ` | string | no | Workspace ID for API endpoint URL (or set BAILIAN_WORKSPACE_ID) | +| `--yes` | switch | no | Confirm this high-risk operation | | `--api-key ` | string | no | API key | | `--base-url ` | string | no | API base URL | @@ -1080,8 +1080,8 @@ bl knowledge service delete --agent-id aid-xxx --yes | ----------------------- | ------ | -------- | --------------------------------------------------------------- | | `--agent-id ` | string | yes | Service (agent) ID | | `--version-desc ` | string | no | Description for the newly published version | -| `--yes` | switch | no | Skip the confirmation prompt | | `--workspace-id ` | string | no | Workspace ID for API endpoint URL (or set BAILIAN_WORKSPACE_ID) | +| `--yes` | switch | no | Confirm this high-risk operation | | `--api-key ` | string | no | API key | | `--base-url ` | string | no | API base URL | From afb547e0c8d04264fa53abd72cd3c7a3afc14667 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=BA=92?= Date: Fri, 28 Aug 2026 14:35:17 +0800 Subject: [PATCH 3/5] refactor(commands): migrate remaining confirmations to runtime gate --- .../src/commands/managed-agent/apply.ts | 29 ++++-------- .../src/commands/managed-agent/destroy.ts | 25 ++++------- .../src/commands/permission/revoke.ts | 39 ++++++++-------- .../tests/e2e/managed-agent.e2e.test.ts | 18 +++++++- .../commands/tests/e2e/permission.e2e.test.ts | 31 ++++++++++--- skills/bailian-cli/reference/permission.md | 19 ++++---- .../reference/managed-agent.md | 44 +++++++++---------- 7 files changed, 112 insertions(+), 93 deletions(-) diff --git a/packages/commands/src/commands/managed-agent/apply.ts b/packages/commands/src/commands/managed-agent/apply.ts index 6040a0add..90cfca8e0 100644 --- a/packages/commands/src/commands/managed-agent/apply.ts +++ b/packages/commands/src/commands/managed-agent/apply.ts @@ -34,13 +34,6 @@ const APPLY_FLAGS = { "zh-CN": "目标 Provider(默认:全部已配置项)", }, }, - yes: { - type: "switch", - description: { - "en-US": "Confirm and apply without an interactive prompt (required to mutate)", - "zh-CN": "无需交互提示直接确认并应用(执行变更时必填)", - }, - }, noRefresh: { type: "switch", description: { @@ -64,7 +57,15 @@ export default defineCommand({ "zh-CN": "应用规划的变更,创建、更新或删除 Agent 资源", }, auth: "apiKey", - usageArgs: "[--file ] [--provider ] [--yes] [--concurrency ]", + risk: { + level: "high", + message: { + "en-US": + "This applies the current plan and may create, update, or delete remote managed Agent resources.", + "zh-CN": "该操作会应用当前计划,可能创建、更新或删除远端托管 Agent 资源。", + }, + }, + usageArgs: "[--file ] [--provider ] [--concurrency ]", flags: APPLY_FLAGS, exampleArgs: ["--yes", "--provider bailian --yes"], notes: CREDENTIALS_NOTE, @@ -124,23 +125,11 @@ export default defineCommand({ return; } - const creates = actionable.filter((action) => action.action === "create").length; - const updates = actionable.filter((action) => action.action === "update").length; - const deletes = planned.destructiveActions; - for (const action of actionable) { const icon = action.action === "create" ? "+" : action.action === "update" ? "~" : "-"; emitProgress(` ${icon} ${formatResourceLabel(action.address)}`); } - if (!flags.yes) { - throw new BailianError( - `Refusing to apply ${actionable.length} change(s) (${creates} create, ${updates} update, ${deletes.length} destroy) without confirmation.`, - ExitCode.USAGE, - "Review with `bl managed-agent plan`, then re-run with --yes to apply.", - ); - } - const result = await withAgentErrors(() => withStdoutProtected(() => executePlannedProject(planned, { diff --git a/packages/commands/src/commands/managed-agent/destroy.ts b/packages/commands/src/commands/managed-agent/destroy.ts index 722faea76..7cafc4469 100644 --- a/packages/commands/src/commands/managed-agent/destroy.ts +++ b/packages/commands/src/commands/managed-agent/destroy.ts @@ -21,13 +21,6 @@ const DESTROY_FLAGS = { "zh-CN": "配置文件路径(默认:agents.yaml)", }, }, - yes: { - type: "switch", - description: { - "en-US": "Confirm and destroy without an interactive prompt (required)", - "zh-CN": "无需交互提示直接确认并销毁(必填)", - }, - }, cascade: { type: "switch", description: { @@ -43,7 +36,15 @@ export default defineCommand({ "zh-CN": "销毁 State 中跟踪的全部托管 Agent 资源", }, auth: "apiKey", - usageArgs: "[--file ] [--yes] [--cascade]", + risk: { + level: "high", + message: { + "en-US": + "This deletes every managed Agent resource tracked in state; --cascade may also delete dependent resources.", + "zh-CN": "该操作会删除 State 中跟踪的全部托管 Agent 资源;--cascade 还可能删除依赖资源。", + }, + }, + usageArgs: "[--file ] [--cascade]", flags: DESTROY_FLAGS, exampleArgs: ["--yes", "--yes --cascade"], notes: CREDENTIALS_NOTE, @@ -86,14 +87,6 @@ export default defineCommand({ else emitBare(line); } - if (!flags.yes) { - throw new BailianError( - `Refusing to destroy ${resources.length} resource(s) without confirmation.`, - ExitCode.USAGE, - "Re-run with --yes to destroy (add --cascade to remove dependents).", - ); - } - const result = await withAgentErrors(() => withStdoutProtected(() => destroyPlannedProjectResources(planned, { diff --git a/packages/commands/src/commands/permission/revoke.ts b/packages/commands/src/commands/permission/revoke.ts index d85d9e2da..c02c8e8a6 100644 --- a/packages/commands/src/commands/permission/revoke.ts +++ b/packages/commands/src/commands/permission/revoke.ts @@ -1,4 +1,4 @@ -import { defineCommand, BailianError, ExitCode } from "bailian-cli-core"; +import { defineCommand } from "bailian-cli-core"; import { runPermissionChange, validatePermissionChange } from "./shared.ts"; export default defineCommand({ @@ -7,7 +7,16 @@ export default defineCommand({ "zh-CN": "撤销模型权限(推理 / 微调 / 部署)", }, auth: "apiKey", - usageArgs: "--model [--action ] | --all --yes", + risk: { + level: "high", + message: { + "en-US": + "This revokes model permissions and may interrupt inference, fine-tuning, or deployment workloads. With --all, it also clears all historical inference grants.", + "zh-CN": + "该操作会撤销模型权限,可能导致推理、精调或部署任务中断;使用 --all 时会清除全部历史推理授权。", + }, + }, + usageArgs: "--model [--action ] | --all [flags]", flags: { model: { type: "string", @@ -33,17 +42,10 @@ export default defineCommand({ "zh-CN": "关闭一键授权并清除所有历史推理授权", }, }, - yes: { - type: "switch", - description: { - "en-US": "Confirm --all without an interactive prompt (required)", - "zh-CN": "无需交互提示确认执行 --all(必填)", - }, - }, }, exampleArgs: [ - "--model qwen-plus", - "--model qwen-plus,qwen3-max --action inference,finetune", + "--model qwen-plus --yes", + "--model qwen-plus,qwen3-max --action inference,finetune --yes", "--all --yes", "--model qwen-plus --dry-run --output json", ], @@ -52,6 +54,11 @@ export default defineCommand({ "en-US": "Grants apply to the business workspace your API key belongs to.", "zh-CN": "授权将应用于 API Key 所属的业务 Workspace。", }, + { + "en-US": + "All revoke operations require --yes; use --dry-run to preview the request without confirmation.", + "zh-CN": "所有撤权操作均需使用 --yes;可通过 --dry-run 免确认预览请求。", + }, { "en-US": "--all maps to the server one-key switch (access_all_entities: CLOSE): it clears every historical inference grant and cannot be undone, so it requires --yes.", @@ -65,14 +72,6 @@ export default defineCommand({ ], validate: (flags) => validatePermissionChange(flags), async run(ctx) { - const { flags, settings } = ctx; - if (flags.all && !flags.yes && !settings.dryRun) { - throw new BailianError( - "Refusing to clear all historical inference grants without confirmation.", - ExitCode.USAGE, - "Re-run with --yes to close one-key authorization (or preview with --dry-run).", - ); - } - await runPermissionChange(ctx, flags, false); + await runPermissionChange(ctx, ctx.flags, false); }, }); diff --git a/packages/commands/tests/e2e/managed-agent.e2e.test.ts b/packages/commands/tests/e2e/managed-agent.e2e.test.ts index fc6b05881..e17aec01f 100644 --- a/packages/commands/tests/e2e/managed-agent.e2e.test.ts +++ b/packages/commands/tests/e2e/managed-agent.e2e.test.ts @@ -132,6 +132,23 @@ describe("e2e: managed-agent", () => { expect(stderr).toMatch(/--file|--provider|--yes/i); }); + test.each(["apply", "destroy"])("managed-agent %s 无 --yes 返回确认请求 (7)", async (command) => { + const { stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [ + "managed-agent", + command, + "--file", + "agents.e2e-missing.yaml", + "--api-key", + "e2e-dummy-key", + "--output", + "json", + ]); + expect(exitCode).toBe(7); + expect(JSON.parse(stderr)).toMatchObject({ + error: { code: 7, type: "requires_confirmation" }, + }); + }); + test("managed-agent session delete 缺少 --session-id 时退出为用法错误 (2)", async () => { const { stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [ "managed-agent", @@ -223,7 +240,6 @@ describe("e2e: managed-agent(--dry-run 短路,不联网不写盘)", () => "managed-agent", "apply", "--dry-run", - "--yes", "--output", "json", ]); diff --git a/packages/commands/tests/e2e/permission.e2e.test.ts b/packages/commands/tests/e2e/permission.e2e.test.ts index f2d03e6bb..210e01c8b 100644 --- a/packages/commands/tests/e2e/permission.e2e.test.ts +++ b/packages/commands/tests/e2e/permission.e2e.test.ts @@ -98,18 +98,39 @@ describe("e2e: permission", () => { expect(stderr).toContain("at most 20"); }); - test("permission revoke --all 缺 --yes 拒绝执行", async () => { - // --yes 护栏在 run() 开头、任何网络调用之前抛出;带 dummy key 让用例不依赖环境凭证(否则 auth stage 先报 AUTH(3))。 + test("permission revoke --all 无 --yes 返回确认请求 (7)", async () => { const { stderr, exitCode } = await runCommandE2e(PERMISSION_ROUTES, [ "permission", "revoke", "--all", "--api-key", "e2e-dummy-key", + "--output", + "json", ]); - expect(exitCode).toBe(2); - expect(stderr).toContain("Refusing"); - expect(stderr).toContain("--yes"); + expect(exitCode).toBe(7); + expect(JSON.parse(stderr)).toMatchObject({ + error: { code: 7, type: "requires_confirmation" }, + }); + }); + + test("permission revoke --model 无 --yes 返回确认请求 (7)", async () => { + const { stderr, exitCode } = await runCommandE2e(PERMISSION_ROUTES, [ + "permission", + "revoke", + "--model", + "qwen-plus", + "--api-key", + "e2e-dummy-key", + "--base-url", + "http://127.0.0.1:1", + "--output", + "json", + ]); + expect(exitCode).toBe(7); + expect(JSON.parse(stderr)).toMatchObject({ + error: { code: 7, type: "requires_confirmation" }, + }); }); // --dry-run 跳过 auth stage(见 runtime middleware),无需凭证即可断言请求形状。 diff --git a/skills/bailian-cli/reference/permission.md b/skills/bailian-cli/reference/permission.md index 9666cfeab..776f60f6d 100644 --- a/skills/bailian-cli/reference/permission.md +++ b/skills/bailian-cli/reference/permission.md @@ -109,12 +109,12 @@ bl permission list --output text ### `bl permission revoke` -| Field | Value | -| ------------------ | --------------------------------------------------------------------------- | -| **Name** | `permission revoke` | -| **Description** | Revoke model permissions (inference / finetune / deploy) | -| **Authentication** | API Key | -| **Usage** | `bl permission revoke --model [--action ] \| --all --yes` | +| Field | Value | +| ------------------ | ----------------------------------------------------------------------------- | +| **Name** | `permission revoke` | +| **Description** | Revoke model permissions (inference / finetune / deploy) | +| **Authentication** | API Key | +| **Usage** | `bl permission revoke --model [--action ] \| --all [flags]` | #### Flags @@ -123,24 +123,25 @@ bl permission list --output text | `--model ` | string | no | Model ID(s), comma-separated (max 20) | | `--action ` | string | no | Permission action(s), comma-separated: inference, finetune, deploy (default: inference) | | `--all` | switch | no | Close one-key authorization and clear ALL historical inference grants | -| `--yes` | switch | no | Confirm --all without an interactive prompt (required) | +| `--yes` | switch | no | Confirm this high-risk operation | | `--api-key ` | string | no | API key | | `--base-url ` | string | no | API base URL | #### Notes - Grants apply to the business workspace your API key belongs to. +- All revoke operations require --yes; use --dry-run to preview the request without confirmation. - --all maps to the server one-key switch (access_all_entities: CLOSE): it clears every historical inference grant and cannot be undone, so it requires --yes. - Actions you omit keep their current grants (server-side tri-state patch). #### Examples ```bash -bl permission revoke --model qwen-plus +bl permission revoke --model qwen-plus --yes ``` ```bash -bl permission revoke --model qwen-plus,qwen3-max --action inference,finetune +bl permission revoke --model qwen-plus,qwen3-max --action inference,finetune --yes ``` ```bash diff --git a/skills/bailian-managed-agent/reference/managed-agent.md b/skills/bailian-managed-agent/reference/managed-agent.md index e3975b7e5..e5ce1b71f 100644 --- a/skills/bailian-managed-agent/reference/managed-agent.md +++ b/skills/bailian-managed-agent/reference/managed-agent.md @@ -31,24 +31,24 @@ Index: [index.md](index.md) ### `bl managed-agent apply` -| Field | Value | -| ------------------ | ---------------------------------------------------------------------------------------- | -| **Name** | `managed-agent apply` | -| **Description** | Apply planned changes to create/update/delete agent resources | -| **Authentication** | API Key | -| **Usage** | `bl managed-agent apply [--file ] [--provider ] [--yes] [--concurrency ]` | +| Field | Value | +| ------------------ | -------------------------------------------------------------------------------- | +| **Name** | `managed-agent apply` | +| **Description** | Apply planned changes to create/update/delete agent resources | +| **Authentication** | API Key | +| **Usage** | `bl managed-agent apply [--file ] [--provider ] [--concurrency ]` | #### Flags -| Flag | Type | Required | Description | -| ------------------- | ------ | -------- | -------------------------------------------------------------------- | -| `--file ` | string | no | Config file path (default: agents.yaml) | -| `--provider ` | string | no | Target provider (default: all configured) | -| `--yes` | switch | no | Confirm and apply without an interactive prompt (required to mutate) | -| `--no-refresh` | switch | no | Skip refreshing state from remote before planning | -| `--concurrency ` | number | no | Max independent resources to apply in parallel (default 6, max 10) | -| `--api-key ` | string | no | API key | -| `--base-url ` | string | no | API base URL | +| Flag | Type | Required | Description | +| ------------------- | ------ | -------- | ------------------------------------------------------------------ | +| `--file ` | string | no | Config file path (default: agents.yaml) | +| `--provider ` | string | no | Target provider (default: all configured) | +| `--no-refresh` | switch | no | Skip refreshing state from remote before planning | +| `--concurrency ` | number | no | Max independent resources to apply in parallel (default 6, max 10) | +| `--yes` | switch | no | Confirm this high-risk operation | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | #### Notes @@ -68,20 +68,20 @@ bl managed-agent apply --provider bailian --yes ### `bl managed-agent destroy` -| Field | Value | -| ------------------ | -------------------------------------------------------------- | -| **Name** | `managed-agent destroy` | -| **Description** | Destroy all managed agent resources tracked in state | -| **Authentication** | API Key | -| **Usage** | `bl managed-agent destroy [--file ] [--yes] [--cascade]` | +| Field | Value | +| ------------------ | ------------------------------------------------------ | +| **Name** | `managed-agent destroy` | +| **Description** | Destroy all managed agent resources tracked in state | +| **Authentication** | API Key | +| **Usage** | `bl managed-agent destroy [--file ] [--cascade]` | #### Flags | Flag | Type | Required | Description | | ------------------ | ------ | -------- | -------------------------------------------------------------------------- | | `--file ` | string | no | Config file path (default: agents.yaml) | -| `--yes` | switch | no | Confirm and destroy without an interactive prompt (required) | | `--cascade` | switch | no | Auto-delete dependent resources (e.g. sessions referencing an environment) | +| `--yes` | switch | no | Confirm this high-risk operation | | `--api-key ` | string | no | API key | | `--base-url ` | string | no | API base URL | From 0872ff6a20ab3be5d908cced12855f775f07803a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=BA=92?= Date: Fri, 28 Aug 2026 17:19:24 +0800 Subject: [PATCH 4/5] feat(cli): expose high-risk confirmation guidance in help and skills --- docs/agents/skill-change.md | 3 + .../cli/tests/skill-risk-confirmation.test.ts | 47 ++++++++ packages/runtime/src/registry.ts | 20 ++++ packages/runtime/tests/i18n.test.ts | 9 ++ packages/runtime/tests/registry-guard.test.ts | 8 +- skills/bailian-cli/SKILL.md | 51 +++++---- skills/bailian-cli/reference/knowledge.md | 107 ++++++++++++------ skills/bailian-cli/reference/permission.md | 19 +++- skills/bailian-finetune/SKILL.md | 2 +- skills/bailian-managed-agent/SKILL.md | 17 +-- .../reference/managed-agent.md | 36 ++++-- skills/bailian-protocol/SKILL.md | 9 ++ .../assets/issue-reporting.md | 48 ++++---- tools/generate-reference.ts | 26 ++++- 14 files changed, 288 insertions(+), 114 deletions(-) create mode 100644 packages/cli/tests/skill-risk-confirmation.test.ts diff --git a/docs/agents/skill-change.md b/docs/agents/skill-change.md index a5b6de8c7..7a6acdf2e 100644 --- a/docs/agents/skill-change.md +++ b/docs/agents/skill-change.md @@ -40,6 +40,8 @@ bailian-gen bailian-finetune bailian-managed-agent bailian-web-search - [ ] **整包装齐**:安装/升级文案主推 `bl skill init`;业务 skill **不**声明 `companions` - [ ] **协议读取**:CRITICAL / references 可链 `../bailian-protocol/…`;若读不到 → 停止执行 `bl`,提示 `bl skill init` +- [ ] **高风险确认**:统一由 `bailian-protocol` 定义;reference / leaf help 以 `risk: high` 明示风险,业务 skill 不得引导 Agent 自动补 `--yes`。遇到 exit code 7 / `requires_confirmation` 时停止执行并请求确认;目标或范围变化后重新确认 +- [ ] **正常控制流**:`requires_confirmation` 不是 CLI bug,`assets/issue-reporting.md` 必须将 exit code 7 保持在 EXCLUDE 范围 - [ ] **软 hand-off**:兄弟业务 skill **只写 skill 名**;已安装则 Read,未安装则 `bl … --help` 或提示整包安装;**不要**把 `../bailian-gen/…` 等写成执行前提 - [ ] **Hub vs 领域**:`bailian-cli` 的「When to use which command」只列 hub 拥有的意图;媒体 / 精调 / managed-agent 各留 hand-off 行,**不抄**领域默认模型与子命令明细 - [ ] **渐进披露**:SKILL 写意图路由与领域硬规则;flags / usage / examples 以 `reference/` 或 `bl --help` 为准,表后保留「勿猜 flag」指向句 @@ -55,6 +57,7 @@ bailian-gen bailian-finetune bailian-managed-agent bailian-web-search - [ ] 新一级命令组归属领域时:改 `tools/generate-reference.ts` 的 `GROUP_OWNER_SKILL`,并更新**拥有方** skill 的路由表;hub 最多加一行 hand-off - [ ] 跑 `pnpm run sync:skill-assets`(或 commit 走 pre-commit),提交生成的 `reference/` 与 version 同步结果 +- [ ] 高风险命令生成的 reference 必须包含 `Risk` / `Risk message` 和简短 Agent safety 提示;带 `--yes` 的示例必须标注只能在确认后执行,不要手改生成物 - [ ] 默认模型若写在领域路由表(如 `bailian-gen`):与命令 default / [model-add-remove.md](model-add-remove.md) 一并核对 ## 完成后自查 diff --git a/packages/cli/tests/skill-risk-confirmation.test.ts b/packages/cli/tests/skill-risk-confirmation.test.ts new file mode 100644 index 000000000..a7bca904c --- /dev/null +++ b/packages/cli/tests/skill-risk-confirmation.test.ts @@ -0,0 +1,47 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "vite-plus/test"; + +const repositoryRoot = join(dirname(fileURLToPath(import.meta.url)), "../../.."); +const skillsRoot = join(repositoryRoot, "skills"); + +test("every generated high-risk command reference requires user confirmation before --yes", () => { + let highRiskCommandCount = 0; + + for (const skillDirectory of readdirSync(skillsRoot, { withFileTypes: true })) { + if (!skillDirectory.isDirectory()) continue; + const referenceDirectory = join(skillsRoot, skillDirectory.name, "reference"); + + let referenceFiles: string[]; + try { + referenceFiles = readdirSync(referenceDirectory).filter( + (fileName) => fileName.endsWith(".md") && fileName !== "index.md", + ); + } catch { + continue; + } + + for (const referenceFile of referenceFiles) { + const markdown = readFileSync(join(referenceDirectory, referenceFile), "utf8"); + const commandSections = markdown.split(/(?=^### `bl )/m).slice(1); + + for (const commandSection of commandSections) { + if (!commandSection.includes("`--yes`")) continue; + highRiskCommandCount += 1; + expect(commandSection).toMatch(/\|\s+\*\*Risk\*\*\s+\|\s+`high`\s+\|/); + expect(commandSection).toMatch(/\|\s+\*\*Risk message\*\*\s+\|\s+.+\|/); + expect(commandSection).toMatch(/type=.*requires_confirmation/); + const agentSafetyLine = commandSection + .split("\n") + .find((line) => line.startsWith("> **Agent safety:**")); + expect(agentSafetyLine).toBeDefined(); + expect(agentSafetyLine).toMatch(/never add `--yes` automatically/i); + expect(agentSafetyLine).toMatch(/explicit user confirmation/i); + expect(agentSafetyLine).not.toContain("`--dry-run`"); + } + } + } + + expect(highRiskCommandCount).toBeGreaterThan(0); +}); diff --git a/packages/runtime/src/registry.ts b/packages/runtime/src/registry.ts index 4b2fbcf1b..eb3260e78 100644 --- a/packages/runtime/src/registry.ts +++ b/packages/runtime/src/registry.ts @@ -1,6 +1,7 @@ import type { AnyCommand, AuthRequirement, + CommandRiskLevel, FlagDef, FlagsDef, LocalizedText, @@ -41,10 +42,16 @@ const AUTH_LABELS = { none: { "en-US": "No Auth", "zh-CN": "无需鉴权" }, } satisfies Record; +const RISK_LEVEL_LABELS = { + high: { "en-US": "high", "zh-CN": "高风险" }, +} satisfies Record; + const HELP_TEXT = { usage: { "en-US": "Usage:", "zh-CN": "用法:" }, commands: { "en-US": "Commands:", "zh-CN": "命令:" }, authentication: { "en-US": "Authentication:", "zh-CN": "鉴权方式:" }, + risk: { "en-US": "Risk:", "zh-CN": "风险等级:" }, + riskMessage: { "en-US": "Risk message:", "zh-CN": "风险说明:" }, flags: { "en-US": "Flags:", "zh-CN": "选项:" }, globalFlags: { "en-US": "Global Flags:", "zh-CN": "全局选项:" }, modelAuthFlags: { "en-US": "Model Auth Flags:", "zh-CN": "模型鉴权选项:" }, @@ -64,6 +71,10 @@ const HELP_TEXT = { }, notes: { "en-US": "Notes:", "zh-CN": "说明:" }, examples: { "en-US": "Examples:", "zh-CN": "示例:" }, + confirmedExample: { + "en-US": "# Only after explicit confirmation:", + "zh-CN": "# 仅在明确确认后执行:", + }, minimalWorkflow: { "en-US": "Minimal workflow.yaml:", "zh-CN": "最小 workflow.yaml:" }, tryIt: { "en-US": "Try it:", "zh-CN": "试一试:" }, } satisfies Record; @@ -432,6 +443,12 @@ ${authFlagSections ? `${authFlagSections}\n\n` : ""}${b(this.localize(HELP_TEXT. out.write( `${b(this.localize(HELP_TEXT.authentication))} ${a(this.localize(AUTH_LABELS[cmd.auth]))}\n`, ); + if (cmd.risk !== undefined) { + out.write( + `${b(this.localize(HELP_TEXT.risk))} ${a(this.localize(RISK_LEVEL_LABELS[cmd.risk.level]))}\n`, + ); + out.write(`${b(this.localize(HELP_TEXT.riskMessage))} ${this.localize(cmd.risk.message)}\n`); + } const flagEntries = [ ...Object.entries(cmd.flags ?? {}), ...Object.entries(confirmationFlagDefs(cmd)), @@ -460,6 +477,9 @@ ${authFlagSections ? `${authFlagSections}\n\n` : ""}${b(this.localize(HELP_TEXT. out.write(`\n${b(this.localize(HELP_TEXT.examples))}\n`); for (const example of cmd.exampleArgs) { const localizedExample = this.localize(example); + if (cmd.risk !== undefined && /(?:^|\s)--yes(?:\s|$)/.test(localizedExample)) { + out.write(` ${d(this.localize(HELP_TEXT.confirmedExample))}\n`); + } const line = localizedExample.startsWith("#") ? localizedExample : localizedExample diff --git a/packages/runtime/tests/i18n.test.ts b/packages/runtime/tests/i18n.test.ts index ed1397dc3..2fffb7de0 100644 --- a/packages/runtime/tests/i18n.test.ts +++ b/packages/runtime/tests/i18n.test.ts @@ -47,6 +47,13 @@ test("registry renders runtime help copy with the selected language", async () = }, ], auth: "none", + risk: { + level: "high", + message: { + "en-US": "This operation is permanent.", + "zh-CN": "该操作无法撤销。", + }, + }, run: async () => {}, }); const registry = new CommandRegistry({ test: command }, "bl", translator); @@ -69,6 +76,8 @@ test("registry renders runtime help copy with the selected language", async () = output = ""; registry.printHelp(["test"], stream); + expect(output).toContain("风险等级: 高风险"); + expect(output).toContain("风险说明: 该操作无法撤销。"); expect(output).toContain("测试说明"); expect(output).toContain('bl test --message "你好"'); expect(output).toContain(" # 流式输出响应"); diff --git a/packages/runtime/tests/registry-guard.test.ts b/packages/runtime/tests/registry-guard.test.ts index 87c1e21bb..1c1918d60 100644 --- a/packages/runtime/tests/registry-guard.test.ts +++ b/packages/runtime/tests/registry-guard.test.ts @@ -53,11 +53,12 @@ test("high risk 命令不能自行声明 runtime 保留的 yes", () => { expect(() => new CommandRegistry({ "x normal": normal }, "bl")).not.toThrow(); }); -test("命令 help 只为 high risk 展示 runtime 注入的 --yes", () => { +test("命令 help 只为 high risk 展示风险信息和 runtime 注入的 --yes", () => { const high = defineCommand({ description: "danger", auth: "none", risk: { level: "high", message: "dangerous operation" }, + exampleArgs: ["--dry-run", "--yes"], run: noopRun, }); const normal = defineCommand({ @@ -77,5 +78,10 @@ test("命令 help 只为 high risk 展示 runtime 注入的 --yes", () => { } as unknown as NodeJS.WriteStream); expect(highHelp).toContain("--yes"); + expect(highHelp).toContain("Risk: high"); + expect(highHelp).toContain("Risk message: dangerous operation"); + expect(highHelp).toMatch(/# Only after explicit confirmation:\n\s+bl asset delete --yes/); expect(normalHelp).not.toContain("--yes"); + expect(normalHelp).not.toContain("Risk:"); + expect(normalHelp).not.toContain("Risk message:"); }); diff --git a/skills/bailian-cli/SKILL.md b/skills/bailian-cli/SKILL.md index b1a8d1328..17761a268 100644 --- a/skills/bailian-cli/SKILL.md +++ b/skills/bailian-cli/SKILL.md @@ -56,30 +56,30 @@ Do not guess flags — use the reference files or `--help`. Use this table only after the decision table in [`bailian-protocol`](../bailian-protocol/SKILL.md#provider-selection-and-consent) has routed the request to `bl` (class 4, or class 2 after the user picks Bailian). Hub-owned intents only — for media / fine-tune / agents.yaml, soft hand-off to the domain skill. -| User intent | Command | Notes | -| ------------------------------------------------ | --------------------------------------------- | -------------------------------------------------------------------------------- | -| Explicit Bailian model chat / text execution | `bl text chat` | Default `qwen3.8-max` | -| Bailian agent / workflow | `bl app call` | Needs `--app-id` | -| Find app by name | `bl app list` then `bl app call` | Console auth | -| Bailian app memory CRUD (not host-agent memory) | `bl memory *` | [`reference/memory.md`](reference/memory.md) | -| Bailian knowledge base RAG | `bl knowledge search` / `chat` | API key + agent/workspace IDs | -| Upload a file as a step of a Bailian workflow | `bl file upload` | When you need `oss://` URL explicitly; not for generic hosting | -| Bailian model selection / recommendation | `bl advisor recommend` | Intent → candidate recall → LLM ranking | -| Bailian model catalog / pricing / params | `bl model list` | Console auth; `--model ` for detail, `--enrich` for input params | -| Install / list / update / remove registry skills | `bl skill add` / `list` / `update` / `remove` | Bailian skill registry; see [`reference/skill.md`](reference/skill.md) | -| Bailian MCP marketplace discovery / call | `bl mcp list` / `tools` / `call` | — | -| Bailian pipeline workflow (a step in a bl flow) | `bl pipeline run` / `validate` | JSON/YAML workflow definitions | -| Bailian rate limits / quota | `bl quota list` / `check` / `request` | Console auth; class 2 — ask which product first if unnamed | -| Bailian free tier / usage stats | `bl usage free` / `stats` / `freetier` | Console auth; class 2 — ask which product first if unnamed | -| Bailian Token Plan quota usage | `bl usage token-plan` | Console auth; class 2 — ask which product first if unnamed | -| Bailian Coding Plan quota usage | `bl usage coding-plan` | Console auth; class 2 — ask which product first if unnamed | -| Console API (advanced) | `bl console call` | Console auth | -| Bailian workspace listing | `bl workspace list` | Console auth | -| Switch CLI Help / Quick Start language | `bl config set --key language --value zh-CN` | Use `en-US` to switch back; follows the active config profile | -| Image / video / speech / omni / vision | → skill `bailian-gen` | Fallback: `bl image\|video\|speech\|omni\|vision --help` | -| Dataset / fine-tune / deploy | → skill `bailian-finetune` | Fallback: `bl dataset\|finetune\|deploy --help` | -| agents.yaml IaC / managed-agent sessions | → skill `bailian-managed-agent` | Fallback: `bl managed-agent --help`; `apply`/`destroy` need `--yes` after `plan` | -| Web search (model-aware routing) | → skill `bailian-web-search` | Token Plan vs MCP path + fallback; fallback: `bl search web --help` | +| User intent | Command | Notes | +| ------------------------------------------------ | --------------------------------------------- | -------------------------------------------------------------------------- | +| Explicit Bailian model chat / text execution | `bl text chat` | Default `qwen3.8-max` | +| Bailian agent / workflow | `bl app call` | Needs `--app-id` | +| Find app by name | `bl app list` then `bl app call` | Console auth | +| Bailian app memory CRUD (not host-agent memory) | `bl memory *` | [`reference/memory.md`](reference/memory.md) | +| Bailian knowledge base RAG | `bl knowledge search` / `chat` | API key + agent/workspace IDs | +| Upload a file as a step of a Bailian workflow | `bl file upload` | When you need `oss://` URL explicitly; not for generic hosting | +| Bailian model selection / recommendation | `bl advisor recommend` | Intent → candidate recall → LLM ranking | +| Bailian model catalog / pricing / params | `bl model list` | Console auth; `--model ` for detail, `--enrich` for input params | +| Install / list / update / remove registry skills | `bl skill add` / `list` / `update` / `remove` | Bailian skill registry; see [`reference/skill.md`](reference/skill.md) | +| Bailian MCP marketplace discovery / call | `bl mcp list` / `tools` / `call` | — | +| Bailian pipeline workflow (a step in a bl flow) | `bl pipeline run` / `validate` | JSON/YAML workflow definitions | +| Bailian rate limits / quota | `bl quota list` / `check` / `request` | Console auth; class 2 — ask which product first if unnamed | +| Bailian free tier / usage stats | `bl usage free` / `stats` / `freetier` | Console auth; class 2 — ask which product first if unnamed | +| Bailian Token Plan quota usage | `bl usage token-plan` | Console auth; class 2 — ask which product first if unnamed | +| Bailian Coding Plan quota usage | `bl usage coding-plan` | Console auth; class 2 — ask which product first if unnamed | +| Console API (advanced) | `bl console call` | Console auth | +| Bailian workspace listing | `bl workspace list` | Console auth | +| Switch CLI Help / Quick Start language | `bl config set --key language --value zh-CN` | Use `en-US` to switch back; follows the active config profile | +| Image / video / speech / omni / vision | → skill `bailian-gen` | Fallback: `bl image\|video\|speech\|omni\|vision --help` | +| Dataset / fine-tune / deploy | → skill `bailian-finetune` | Fallback: `bl dataset\|finetune\|deploy --help` | +| agents.yaml IaC / managed-agent sessions | → skill `bailian-managed-agent` | Fallback: `bl managed-agent --help`; `apply`/`destroy` also require `plan` | +| Web search (model-aware routing) | → skill `bailian-web-search` | Token Plan vs MCP path + fallback; fallback: `bl search web --help` | Flags, usage, and examples: see hub [`reference/`](reference/index.md) or `bl --help` — do not guess flags. Domain command details live in the owning skill's `reference/`. @@ -123,6 +123,7 @@ schema-export commands. - Usage / quota / credits questions that do not name a product → ask which product (Bailian or another AI service) first; run `bl usage` / `bl quota` only after the user picks Bailian or Bailian context is already established. - "Remember this" and memory requests default to the host agent's own memory; `bl memory *` is only for Bailian app memory resources. - `bl file upload` and `bl pipeline run` are steps inside a Bailian workflow; do not use them to capture generic "upload this file" or "run a pipeline" requests. -- `bl managed-agent apply` / `destroy` mutate remote resources and only execute with `--yes`; run `plan` first and show the diff before confirming a mutation. +- For `risk: high` commands or `requires_confirmation`, follow the shared protocol; never add `--yes` automatically. +- `bl managed-agent apply` / `destroy` have an additional domain rule: run `plan` first and show the diff before asking for confirmation. - When a matched `bl` command accepts a file URL, pass local paths directly; never require the user to host the file first. - Console login → always `--console-site domestic|international`; see [`../bailian-protocol/assets/setup.md`](../bailian-protocol/assets/setup.md#console-site-selection). diff --git a/skills/bailian-cli/reference/knowledge.md b/skills/bailian-cli/reference/knowledge.md index 44056a452..8e49e9972 100644 --- a/skills/bailian-cli/reference/knowledge.md +++ b/skills/bailian-cli/reference/knowledge.md @@ -82,12 +82,16 @@ bl knowledge category add --name sub --parent-id cate-xxx ### `bl knowledge category delete` -| Field | Value | -| ------------------ | --------------------------------------------------------- | -| **Name** | `knowledge category delete` | -| **Description** | Delete a data-center category | -| **Authentication** | API Key | -| **Usage** | `bl knowledge category delete --category-id [flags]` | +| Field | Value | +| ------------------ | -------------------------------------------------------------------- | +| **Name** | `knowledge category delete` | +| **Description** | Delete a data-center category | +| **Authentication** | API Key | +| **Usage** | `bl knowledge category delete --category-id [flags]` | +| **Risk** | `high` | +| **Risk message** | This deletes the selected data-center category and cannot be undone. | + +> **Agent safety:** Never add `--yes` automatically. On `type="requires_confirmation"`, stop and ask for explicit user confirmation of the same action and scope. #### Flags @@ -110,6 +114,7 @@ bl knowledge category delete --category-id cate-xxx --workspace-id ws-xxx ``` ```bash +# Only after explicit user confirmation: bl knowledge category delete --category-id cate-xxx --yes ``` @@ -248,6 +253,10 @@ bl knowledge chunk add --index-id idx-xxx --field columnA=v1 --field columnB=v2 | **Description** | Delete chunks from a knowledge base (irreversible) | | **Authentication** | API Key | | **Usage** | `bl knowledge chunk delete --index-id --chunk-id [flags]` | +| **Risk** | `high` | +| **Risk message** | This permanently deletes the selected chunks and cannot be undone. | + +> **Agent safety:** Never add `--yes` automatically. On `type="requires_confirmation"`, stop and ask for explicit user confirmation of the same action and scope. #### Flags @@ -271,6 +280,7 @@ bl knowledge chunk delete --index-id idx-xxx --chunk-id chunk-a --chunk-id chunk ``` ```bash +# Only after explicit user confirmation: bl knowledge chunk delete --index-id idx-xxx --chunk-id chunk-a --yes ``` @@ -461,12 +471,16 @@ bl knowledge create --name demo --description 'product docs' --category-id cate- ### `bl knowledge delete` -| Field | Value | -| ------------------ | --------------------------------------------------------- | -| **Name** | `knowledge delete` | -| **Description** | Delete a knowledge base with all its documents and chunks | -| **Authentication** | API Key | -| **Usage** | `bl knowledge delete --index-id [flags]` | +| Field | Value | +| ------------------ | ------------------------------------------------------------------------------------------------------------------- | +| **Name** | `knowledge delete` | +| **Description** | Delete a knowledge base with all its documents and chunks | +| **Authentication** | API Key | +| **Usage** | `bl knowledge delete --index-id [flags]` | +| **Risk** | `high` | +| **Risk message** | This permanently deletes the knowledge base and all of its documents and chunks. Data-center files are not deleted. | + +> **Agent safety:** Never add `--yes` automatically. On `type="requires_confirmation"`, stop and ask for explicit user confirmation of the same action and scope. #### Flags @@ -490,17 +504,22 @@ bl knowledge delete --index-id idx-xxx --workspace-id ws-xxx ``` ```bash +# Only after explicit user confirmation: bl knowledge delete --index-id idx-xxx --yes ``` ### `bl knowledge doc delete` -| Field | Value | -| ------------------ | --------------------------------------------------------------- | -| **Name** | `knowledge doc delete` | -| **Description** | Delete documents and their chunks from a knowledge base | -| **Authentication** | API Key | -| **Usage** | `bl knowledge doc delete --index-id --doc-id [flags]` | +| Field | Value | +| ------------------ | ------------------------------------------------------------------------ | +| **Name** | `knowledge doc delete` | +| **Description** | Delete documents and their chunks from a knowledge base | +| **Authentication** | API Key | +| **Usage** | `bl knowledge doc delete --index-id --doc-id [flags]` | +| **Risk** | `high` | +| **Risk message** | This permanently deletes the selected documents and all of their chunks. | + +> **Agent safety:** Never add `--yes` automatically. On `type="requires_confirmation"`, stop and ask for explicit user confirmation of the same action and scope. #### Flags @@ -527,6 +546,7 @@ bl knowledge doc delete --index-id idx-xxx --doc-id file-xxx --workspace-id ws-x ``` ```bash +# Only after explicit user confirmation: bl knowledge doc delete --index-id idx-xxx --doc-id file-a --doc-id file-b --yes ``` @@ -728,12 +748,16 @@ bl knowledge doc upload --file ./docs/ --dry-run --verbose ### `bl knowledge file delete` -| Field | Value | -| ------------------ | ------------------------------------------------- | -| **Name** | `knowledge file delete` | -| **Description** | Permanently delete a file from the data center | -| **Authentication** | API Key | -| **Usage** | `bl knowledge file delete --file-id [flags]` | +| Field | Value | +| ------------------ | -------------------------------------------------------------------------------------------------------------------- | +| **Name** | `knowledge file delete` | +| **Description** | Permanently delete a file from the data center | +| **Authentication** | API Key | +| **Usage** | `bl knowledge file delete --file-id [flags]` | +| **Risk** | `high` | +| **Risk message** | This permanently deletes the data-center file. Knowledge-base document indexes that reference it may become invalid. | + +> **Agent safety:** Never add `--yes` automatically. On `type="requires_confirmation"`, stop and ask for explicit user confirmation of the same action and scope. #### Flags @@ -757,6 +781,7 @@ bl knowledge file delete --file-id file-xxx --workspace-id ws-xxx ``` ```bash +# Only after explicit user confirmation: bl knowledge file delete --file-id file-xxx --yes ``` @@ -1032,12 +1057,16 @@ bl knowledge service create --name my-search --scene search --index-id idx-xxx ### `bl knowledge service delete` -| Field | Value | -| ------------------ | ---------------------------------------------------------- | -| **Name** | `knowledge service delete` | -| **Description** | Delete a retrieval / Q&A service (soft delete, idempotent) | -| **Authentication** | API Key | -| **Usage** | `bl knowledge service delete --agent-id [flags]` | +| Field | Value | +| ------------------ | ---------------------------------------------------------------------------------------------------------------------- | +| **Name** | `knowledge service delete` | +| **Description** | Delete a retrieval / Q&A service (soft delete, idempotent) | +| **Authentication** | API Key | +| **Usage** | `bl knowledge service delete --agent-id [flags]` | +| **Risk** | `high` | +| **Risk message** | This deletes the service and makes its agent ID unavailable for search and chat calls. The operation cannot be undone. | + +> **Agent safety:** Never add `--yes` automatically. On `type="requires_confirmation"`, stop and ask for explicit user confirmation of the same action and scope. #### Flags @@ -1062,17 +1091,22 @@ bl knowledge service delete --agent-id aid-xxx --workspace-id ws-xxx ``` ```bash +# Only after explicit user confirmation: bl knowledge service delete --agent-id aid-xxx --yes ``` ### `bl knowledge service deploy` -| Field | Value | -| ------------------ | ----------------------------------------------------- | -| **Name** | `knowledge service deploy` | -| **Description** | Publish the beta draft of a service as a new version | -| **Authentication** | API Key | -| **Usage** | `bl knowledge service deploy --agent-id [flags]` | +| Field | Value | +| ------------------ | ------------------------------------------------------------------------------------------------ | +| **Name** | `knowledge service deploy` | +| **Description** | Publish the beta draft of a service as a new version | +| **Authentication** | API Key | +| **Usage** | `bl knowledge service deploy --agent-id [flags]` | +| **Risk** | `high` | +| **Risk message** | This publishes the current draft as a new version and changes the behavior seen by live callers. | + +> **Agent safety:** Never add `--yes` automatically. On `type="requires_confirmation"`, stop and ask for explicit user confirmation of the same action and scope. #### Flags @@ -1098,6 +1132,7 @@ bl knowledge service deploy --agent-id aid-xxx --workspace-id ws-xxx ``` ```bash +# Only after explicit user confirmation: bl knowledge service deploy --agent-id aid-xxx --version-desc 'tuned rerank params' --yes ``` diff --git a/skills/bailian-cli/reference/permission.md b/skills/bailian-cli/reference/permission.md index 776f60f6d..54e04ab33 100644 --- a/skills/bailian-cli/reference/permission.md +++ b/skills/bailian-cli/reference/permission.md @@ -109,12 +109,16 @@ bl permission list --output text ### `bl permission revoke` -| Field | Value | -| ------------------ | ----------------------------------------------------------------------------- | -| **Name** | `permission revoke` | -| **Description** | Revoke model permissions (inference / finetune / deploy) | -| **Authentication** | API Key | -| **Usage** | `bl permission revoke --model [--action ] \| --all [flags]` | +| Field | Value | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Name** | `permission revoke` | +| **Description** | Revoke model permissions (inference / finetune / deploy) | +| **Authentication** | API Key | +| **Usage** | `bl permission revoke --model [--action ] \| --all [flags]` | +| **Risk** | `high` | +| **Risk message** | This revokes model permissions and may interrupt inference, fine-tuning, or deployment workloads. With --all, it also clears all historical inference grants. | + +> **Agent safety:** Never add `--yes` automatically. On `type="requires_confirmation"`, stop and ask for explicit user confirmation of the same action and scope. #### Flags @@ -137,14 +141,17 @@ bl permission list --output text #### Examples ```bash +# Only after explicit user confirmation: bl permission revoke --model qwen-plus --yes ``` ```bash +# Only after explicit user confirmation: bl permission revoke --model qwen-plus,qwen3-max --action inference,finetune --yes ``` ```bash +# Only after explicit user confirmation: bl permission revoke --all --yes ``` diff --git a/skills/bailian-finetune/SKILL.md b/skills/bailian-finetune/SKILL.md index 30339f155..b1e0e6530 100644 --- a/skills/bailian-finetune/SKILL.md +++ b/skills/bailian-finetune/SKILL.md @@ -33,7 +33,7 @@ description: >- - Unsure which training methods a base model supports → `bl finetune capability --base-model ` or `--training-type sft|sft-lora|dpo|cpt`. - Text `--training-type` values: `sft` / `sft-lora` / `dpo` / `dpo-lora` / `cpt`. Audio bases include `cosyvoice-v3-flash`; image bases include `wan2.7-image-pro`. - Deployment plans: audio defaults to `--plan mu`; text/image default to `lora`. -- Preview write operations (create / delete / cancel / scale) with `--dry-run` first, and confirm with the user before deleting a job or dataset. +- For `risk: high` or `requires_confirmation`, follow `bailian-protocol`; never add `--yes` automatically. ## When to use which command diff --git a/skills/bailian-managed-agent/SKILL.md b/skills/bailian-managed-agent/SKILL.md index 7277707b3..ce9016383 100644 --- a/skills/bailian-managed-agent/SKILL.md +++ b/skills/bailian-managed-agent/SKILL.md @@ -8,7 +8,7 @@ description: >- 阿里云百炼托管 Agent 声明式基础设施入口:用户要创建agent、初始化 agents.yaml、校验或预览 agent 配置变更、 创建/更新/销毁百炼托管 Agent 或 Deployment、和托管 agent 对话、查会话事件历史、导入或取消跟踪远端资源时使用 `bl managed-agent`。以 agents.yaml 为唯一事实源做 IaC:init 建脚手架、validate 离线校验、plan 预览 diff、 - apply / destroy 变更远端资源且必须带 `--yes`,务必先 plan 给用户看 diff 再让其确认。 + apply / destroy 变更远端资源且受统一高风险确认闸门保护,务必先 plan 给用户看 diff 再让其确认,禁止自动添加 `--yes`。 反触发:调用已上线的百炼应用/智能体走 bailian-app-call 或 `bl app`;宿主 agent 自身的记忆、技能、 子代理不走本 skill;生图生视频走 bailian-gen。 官方安装:`bl skill init`(与共享协议 bailian-protocol 同装)。 @@ -16,15 +16,17 @@ description: >- # Bailian managed agent IaC (`bl managed-agent`) -**CRITICAL — Before executing, MUST read the shared protocol in [`../bailian-protocol/SKILL.md`](../bailian-protocol/SKILL.md): Version & updates (pre-flight checklist) and CLI errors: report an issue. Command details are authoritative in [`reference/managed-agent.md`](reference/managed-agent.md) and `bl managed-agent --help` — do not guess flags. If that protocol file is missing, stop and run `bl skill init`; do not guess auth/consent.** +**CRITICAL — Before executing, MUST read the shared protocol in [`../bailian-protocol/SKILL.md`](../bailian-protocol/SKILL.md): High-risk operation confirmation, Version & updates (pre-flight checklist), and CLI errors: report an issue. Command details are authoritative in [`reference/managed-agent.md`](reference/managed-agent.md) and `bl managed-agent --help` — do not guess flags. If that protocol file is missing, stop and run `bl skill init`; do not guess auth/consent.** ## Safety guardrail (the most important rule) -`apply` / `destroy` **mutate remote resources** and only execute when `--yes` is passed: +`apply` / `destroy` **mutate remote resources** and add a domain-specific preview requirement on top of the shared high-risk confirmation protocol: 1. Always run `bl managed-agent plan` first and show the diff to the user. -2. Only after explicit user confirmation, retry `apply` / `destroy` with `--yes`. -3. Never add `--yes` on your own initiative before the user has confirmed. +2. Ask the user to confirm the exact action and scope shown in the plan. +3. Only then run `apply` / `destroy` with `--yes`; a changed plan requires confirmation again. + +`session delete` and future `risk: high` commands follow the shared protocol. ## IaC lifecycle @@ -32,8 +34,9 @@ description: >- 1. Init bl managed-agent init # scaffold agents.yaml 2. Validate bl managed-agent validate # offline, no network calls 3. Preview bl managed-agent plan # show the pending change diff -4. Apply bl managed-agent apply --yes # only after user confirmation -5. Destroy bl managed-agent destroy --yes # only after user confirmation +4. Confirm show the plan and ask the user # no automatic --yes +5. Apply bl managed-agent apply --yes # only after explicit confirmation +6. Destroy bl managed-agent destroy --yes # separate explicit confirmation ``` ## Deployment as IaC diff --git a/skills/bailian-managed-agent/reference/managed-agent.md b/skills/bailian-managed-agent/reference/managed-agent.md index e5ce1b71f..5e96846ee 100644 --- a/skills/bailian-managed-agent/reference/managed-agent.md +++ b/skills/bailian-managed-agent/reference/managed-agent.md @@ -31,12 +31,16 @@ Index: [index.md](index.md) ### `bl managed-agent apply` -| Field | Value | -| ------------------ | -------------------------------------------------------------------------------- | -| **Name** | `managed-agent apply` | -| **Description** | Apply planned changes to create/update/delete agent resources | -| **Authentication** | API Key | -| **Usage** | `bl managed-agent apply [--file ] [--provider ] [--concurrency ]` | +| Field | Value | +| ------------------ | ----------------------------------------------------------------------------------------------- | +| **Name** | `managed-agent apply` | +| **Description** | Apply planned changes to create/update/delete agent resources | +| **Authentication** | API Key | +| **Usage** | `bl managed-agent apply [--file ] [--provider ] [--concurrency ]` | +| **Risk** | `high` | +| **Risk message** | This applies the current plan and may create, update, or delete remote managed Agent resources. | + +> **Agent safety:** Never add `--yes` automatically. On `type="requires_confirmation"`, stop and ask for explicit user confirmation of the same action and scope. #### Flags @@ -59,21 +63,27 @@ Index: [index.md](index.md) #### Examples ```bash +# Only after explicit user confirmation: bl managed-agent apply --yes ``` ```bash +# Only after explicit user confirmation: bl managed-agent apply --provider bailian --yes ``` ### `bl managed-agent destroy` -| Field | Value | -| ------------------ | ------------------------------------------------------ | -| **Name** | `managed-agent destroy` | -| **Description** | Destroy all managed agent resources tracked in state | -| **Authentication** | API Key | -| **Usage** | `bl managed-agent destroy [--file ] [--cascade]` | +| Field | Value | +| ------------------ | ---------------------------------------------------------------------------------------------------------- | +| **Name** | `managed-agent destroy` | +| **Description** | Destroy all managed agent resources tracked in state | +| **Authentication** | API Key | +| **Usage** | `bl managed-agent destroy [--file ] [--cascade]` | +| **Risk** | `high` | +| **Risk message** | This deletes every managed Agent resource tracked in state; --cascade may also delete dependent resources. | + +> **Agent safety:** Never add `--yes` automatically. On `type="requires_confirmation"`, stop and ask for explicit user confirmation of the same action and scope. #### Flags @@ -94,10 +104,12 @@ bl managed-agent apply --provider bailian --yes #### Examples ```bash +# Only after explicit user confirmation: bl managed-agent destroy --yes ``` ```bash +# Only after explicit user confirmation: bl managed-agent destroy --yes --cascade ``` diff --git a/skills/bailian-protocol/SKILL.md b/skills/bailian-protocol/SKILL.md index 541a84881..c0c3b1a00 100644 --- a/skills/bailian-protocol/SKILL.md +++ b/skills/bailian-protocol/SKILL.md @@ -41,6 +41,15 @@ Ask templates for classes 2 and 3 (match the user's language): After approval, treat Bailian as selected for the current task. Do not ask again for intermediate commands, polling, downloads, retries, or related follow-ups. Ask again only if the scope changes materially, such as a substantially larger cost or a destructive operation. +## High-risk operation confirmation (mandatory) + +`risk: high` in a command reference or leaf `--help` marks a high-risk operation. For older CLI output without this field, treat `--yes` as the conservative fallback. Exit code **7** with `error.type: "requires_confirmation"` is an expected stop signal, not a CLI bug. + +- Never add `--yes` automatically. +- Show the risk message and a safe summary of the action, target, and scope without exposing credentials, then ask for explicit confirmation. +- Only after confirmation, re-run the same operation with `--yes`. Any material change to the scope requires confirmation again. +- If the user declines or does not answer, stop. + ## Family routing & hand-offs 业务路由(**软 hand-off**:按 skill **名**路由;已安装则 Read 其 `SKILL.md`,未安装则用 `bl --help`,或提示整包安装 diff --git a/skills/bailian-protocol/assets/issue-reporting.md b/skills/bailian-protocol/assets/issue-reporting.md index b1df4d539..28e9faca6 100644 --- a/skills/bailian-protocol/assets/issue-reporting.md +++ b/skills/bailian-protocol/assets/issue-reporting.md @@ -15,7 +15,7 @@ When `bl` fails, the agent first helps the user fix the problem. If the failure function shouldOfferIssueReport(exitCode, apiCode, message, hint): # Step 1: Unambiguous EXCLUDE by exit code - if exitCode in [2 (USAGE), 3 (AUTH), 4 (QUOTA), 10 (CONTENT_FILTER)]: + if exitCode in [2 (USAGE), 3 (AUTH), 4 (QUOTA), 7 (CONFIRMATION_REQUIRED), 10 (CONTENT_FILTER)]: return EXCLUDE # help user fix; never offer reporting # Step 2: NETWORK / TIMEOUT — exclude if hint is actionable @@ -69,18 +69,19 @@ function matchesIncludeCriteria(exitCode, apiCode, message): These are **user**, **environment**, or **service business** errors. Give fix hints; do not ask to file an issue. -| Category | Signal | Examples | -| -------------------------- | -------------------------------------- | ------------------------------------------------------------------------------- | -| **Usage / args** | Exit code **2** (USAGE) | Missing flag, invalid path, unknown subcommand, local file not found | -| **Auth** | Exit code **3** (AUTH) | No API key, invalid key, expired console token | -| **Quota** | Exit code **4** (QUOTA) | Free tier exhausted, rate limit / quota messages | -| **Content filter** | Exit code **10** (CONTENT_FILTER) | Content moderation blocked the request | -| **Model not found** | Message or `api_code` | `ModelNotFound`, `invalid_request_error` naming a bad model, HTTP 404 for model | -| **Invalid API params** | USAGE or service validation | `InvalidParameter`, `invalid_request_error` for bad `--size`, `--format`, etc. | -| **Free quota query** | `bl usage free` business result | Quota used up — not a CLI defect | -| **Obvious local env** | Hint is sufficient | `ENOENT` / `EACCES`, wrong file path, disk full | -| **Network (self-service)** | Exit code **6** (NETWORK) + clear hint | DNS, proxy, TLS — user fixes `DASHSCOPE_BASE_URL`, proxy, or network | -| **Timeout (self-service)** | Exit code **5** (TIMEOUT) + hint works | Increase `--timeout`, check `base_url` with `bl auth status` | +| Category | Signal | Examples | +| -------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------- | +| **Usage / args** | Exit code **2** (USAGE) | Missing flag, invalid path, unknown subcommand, local file not found | +| **Auth** | Exit code **3** (AUTH) | No API key, invalid key, expired console token | +| **Quota** | Exit code **4** (QUOTA) | Free tier exhausted, rate limit / quota messages | +| **Confirmation required** | Exit code **7** + `requires_confirmation` | Expected high-risk control flow; ask the user, never auto-retry with `--yes` | +| **Content filter** | Exit code **10** (CONTENT_FILTER) | Content moderation blocked the request | +| **Model not found** | Message or `api_code` | `ModelNotFound`, `invalid_request_error` naming a bad model, HTTP 404 for model | +| **Invalid API params** | USAGE or service validation | `InvalidParameter`, `invalid_request_error` for bad `--size`, `--format`, etc. | +| **Free quota query** | `bl usage free` business result | Quota used up — not a CLI defect | +| **Obvious local env** | Hint is sufficient | `ENOENT` / `EACCES`, wrong file path, disk full | +| **Network (self-service)** | Exit code **6** (NETWORK) + clear hint | DNS, proxy, TLS — user fixes `DASHSCOPE_BASE_URL`, proxy, or network | +| **Timeout (self-service)** | Exit code **5** (TIMEOUT) + hint works | Increase `--timeout`, check `base_url` with `bl auth status` | **Rule:** If the authoritative source of the error is the **service response** or **user input**, treat it as non-reportable (same boundary as the CLI repo’s error-handling docs). @@ -337,15 +338,16 @@ Do **not** block on `gh` — always provide a manual path. ## Exit codes (reference) -| Code | Name | Usually reportable? | -| ---- | -------------- | ----------------------------------------------- | -| 0 | SUCCESS | — | -| 1 | GENERAL | Sometimes (if CLI bug, not service passthrough) | -| 2 | USAGE | No | -| 3 | AUTH | No | -| 4 | QUOTA | No | -| 5 | TIMEOUT | Rarely (after user fixes env) | -| 6 | NETWORK | Rarely (after user fixes env) | -| 10 | CONTENT_FILTER | No | +| Code | Name | Usually reportable? | +| ---- | --------------------- | ----------------------------------------------- | +| 0 | SUCCESS | — | +| 1 | GENERAL | Sometimes (if CLI bug, not service passthrough) | +| 2 | USAGE | No | +| 3 | AUTH | No | +| 4 | QUOTA | No | +| 5 | TIMEOUT | Rarely (after user fixes env) | +| 6 | NETWORK | Rarely (after user fixes env) | +| 7 | CONFIRMATION_REQUIRED | No — expected high-risk control flow | +| 10 | CONTENT_FILTER | No | JSON errors use the same numeric `error.code` field when `--output json` is set. diff --git a/tools/generate-reference.ts b/tools/generate-reference.ts index d1ad007e6..2565392ae 100644 --- a/tools/generate-reference.ts +++ b/tools/generate-reference.ts @@ -128,7 +128,11 @@ function formatFlagsTable(flags: FlagsDef | undefined): string { ].join("\n"); } -function formatExamples(path: string, exampleArgs: LocalizedText[] | undefined): string { +function formatExamples( + path: string, + exampleArgs: LocalizedText[] | undefined, + highRisk: boolean, +): string { if (!exampleArgs?.length) return "_No examples._\n"; // Commands store argument-only examples; prepend `bl ` for the reference. return ( @@ -136,7 +140,11 @@ function formatExamples(path: string, exampleArgs: LocalizedText[] | undefined): .map((example) => { const text = referenceText(example); const line = text.startsWith("#") ? text : `bl ${path}${text ? ` ${text}` : ""}`; - return ["```bash", line, "```"].join("\n"); + const confirmationComment = + highRisk && /(?:^|\s)--yes(?:\s|$)/.test(text) + ? ["# Only after explicit user confirmation:"] + : []; + return ["```bash", ...confirmationComment, line, "```"].join("\n"); }) .join("\n\n") + "\n" ); @@ -157,8 +165,20 @@ function commandSection(path: string, cmd: AnyCommand): string { // Commands store argument-only usage; the `bl ` prefix is added here. const usage = `bl ${path}${cmd.usageArgs ? ` ${cmd.usageArgs}` : ""}`; lines.push(`| **Usage** | \`${escCell(usage)}\` |`); + if (cmd.risk !== undefined) { + lines.push(`| **Risk** | \`${cmd.risk.level}\` |`); + lines.push(`| **Risk message** | ${escCell(referenceText(cmd.risk.message))} |`); + } lines.push(""); + if (cmd.risk !== undefined) { + lines.push( + '> **Agent safety:** Never add `--yes` automatically. On `type="requires_confirmation"`, ' + + "stop and ask for explicit user confirmation of the same action and scope.", + "", + ); + } + // 与命令 help 的 Flags 区一致:自有 + 该命令可见的凭证域 flag。 lines.push("#### Flags", ""); lines.push( @@ -175,7 +195,7 @@ function commandSection(path: string, cmd: AnyCommand): string { } lines.push("#### Examples", ""); - lines.push(formatExamples(path, cmd.exampleArgs)); + lines.push(formatExamples(path, cmd.exampleArgs, cmd.risk !== undefined)); return lines.join("\n"); } From 676b6c2ece178bbae56396fbef955a66440f88b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=BA=92?= Date: Fri, 28 Aug 2026 17:48:07 +0800 Subject: [PATCH 5/5] test(runtime): update auth fallback fixture for confirmation context --- packages/runtime/tests/auth-profile-fallback.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/runtime/tests/auth-profile-fallback.test.ts b/packages/runtime/tests/auth-profile-fallback.test.ts index 7b62fe712..11221f29e 100644 --- a/packages/runtime/tests/auth-profile-fallback.test.ts +++ b/packages/runtime/tests/auth-profile-fallback.test.ts @@ -12,6 +12,7 @@ import { resolveModelBaseUrl, writeConfigFile, type CommandPackManager, + type LocalizedText, type SourceFlags, } from "bailian-cli-core"; import { authStage, type RunContext } from "../src/middleware.ts"; @@ -56,6 +57,8 @@ function makeContext( path, command, flags: {}, + confirmed: false, + localize: (text: LocalizedText) => (typeof text === "string" ? text : text["en-US"]), settings, sources, configStore: makeConfigStore(sources.configName),