Skip to content

Commit 00bcee3

Browse files
committed
feat(runtime): add unified confirmation gate for high-risk commands
1 parent 4067b2c commit 00bcee3

28 files changed

Lines changed: 605 additions & 88 deletions

File tree

docs/agents/cli-e2e-tests.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,13 @@ describe.skipIf(<ready>)("e2e: <topic>(DashScope …)", () => {
7979
3. **--dry-run**:实现在联网/上传/写盘**之前**返回;断言 stdout JSON/文本
8080
4. **真实集成**:放在 skip 块**末尾**
8181

82+
高风险命令额外要求:
83+
84+
- `--help` 展示 runtime 注入的 `--yes`
85+
-`--yes` 返回 exit code 7 和 JSON `type: "requires_confirmation"`
86+
- `--dry-run` 无需 `--yes`,且必须证明在任何远端请求或本地写入之前返回
87+
- runtime 的离线 high-risk fixture 必须覆盖带 `--yes` 确实进入 `run()`,并断言 `yes` 不进入 command 自有 flags
88+
8289
## Journey 层(用户旅程全链路)
8390

8491
- **定位**:命令 E2E 验单命令契约;journey 验“用户带着目标跨命令走通回路”,结构性断言不在 journey 重复

docs/agents/command-add-remove.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,9 @@ packages/commands/src/index.ts
7171
- `usageArgs`(不含 bin/path 前缀)
7272
- `exampleArgs`(不含 bin/path 前缀)
7373
- `validate`(跨 flag 校验)
74+
- 高风险命令必须声明 `risk: { level: "high", message: <双语文案> }`;`--yes` 由 runtime 注入,命令不得自行声明
7475
- 普通业务命令的 `run(ctx)` 只读 `ctx.flags` / `ctx.settings` / `ctx.client`
76+
- 声明 `risk``run(ctx)` 必须在任何远端请求或本地写入之前处理 `ctx.settings.dryRun` 并返回预览;runtime 只负责确认闸门,不替命令实现 dry-run
7577
- `commands/auth/**` 可用 `ctx.authStore`,`commands/config/**` 可用 `ctx.configStore`;不要把这些持久化能力扩散到普通业务命令
7678
- `commands/plugin/**` 可用 `ctx.commandPacks`;产品 policy 由 runtime 绑定,命令不要自行 import 产品入口
7779
- [ ] 用户可见 Help 文案在命令文件中就近提供 `en-US` / `zh-CN`:命令 `description`、flag `description``notes` 和包含自然语言的 `exampleArgs`;纯命令语法示例可保留为字符串,服务端错误不翻译

docs/agents/telemetry-change.md

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,12 @@
1717
│ ├─ ~/.bailian/telemetry.jsonl
1818
│ └─ AEM(pid=bailian-cli-node, event name=命令路径)
1919
20-
└─ authStage
21-
├─ apiKey → DashScope / 模型域
22-
├─ console → Bailian Console Gateway
23-
├─ openapi → 阿里云 OpenAPI
24-
└─ none → 无凭证域;本地命令也仍有 AEM 命令事件
20+
└─ confirmationStage
21+
└─ versionCheckStage → authStage
22+
├─ apiKey → DashScope / 模型域
23+
├─ console → Bailian Console Gateway
24+
├─ openapi → 阿里云 OpenAPI
25+
└─ none → 无凭证域;本地命令也仍有 AEM 命令事件
2526
```
2627

2728
### 1. 三套鉴权与埋点标识
@@ -77,7 +78,7 @@ source-config 只用于百炼 / DashScope API 侧消费,不发送到通用网
7778

7879
### 3. 全命令 AEM 客户端埋点
7980

80-
`packages/runtime/src/middleware.ts``telemetryStage` 包裹 `authStage` 与命令执行,因此成功、业务失败、网络失败和鉴权失败都会形成一次命令事件。事件名是空格连接的命令路径,例如 `text chat`
81+
`packages/runtime/src/middleware.ts``telemetryStage` 包裹确认闸门、`authStage` 与命令执行,因此成功、确认未通过、业务失败、网络失败和鉴权失败都会形成一次命令事件。事件名是空格连接的命令路径,例如 `text chat`。确认闸门仍位于版本检查、鉴权和业务执行之前,不会因为埋点而放行高风险操作
8182

8283
以下情况不会形成命令事件,因为没有进入 middleware 的 `run`
8384

@@ -92,7 +93,7 @@ source-config 只用于百炼 / DashScope API 侧消费,不发送到通用网
9293
- `command``timestamp``durationMs``success`
9394
- `cliVersion``nodeVersion``os`
9495
- `authMethod`
95-
- 失败时的 `errorMessage``httpStatus``requestId`
96+
- 失败时的 `errorMessage``exitCode``httpStatus``requestId`
9697
- 安全 allowlist 过滤后的 `params`
9798

9899
参数默认不上传,只有 `packages/core/src/telemetry/tracker.ts``PARAM_ALLOWLIST` 中字段会进入事件。不得加入 prompt、凭证、文件路径、URL、账号/租户/工作空间 ID 或其他用户内容。
@@ -108,16 +109,16 @@ source-config 只用于百炼 / DashScope API 侧消费,不发送到通用网
108109

109110
AEM 映射:
110111

111-
| AEM 字段 | 内容 |
112-
| ---------- | ----------------------------------------- |
113-
| event name | 命令路径 |
114-
| `et` | `EXP` |
115-
| `ext` |`command``params` 外的结构化事件字段 |
116-
| `c1` | allowlist 参数 |
117-
| `c2` | `success` / `failure` |
118-
| `c3` | HTTP status |
119-
| `c4` | 错误文案,最多 500 字符 |
120-
| `c5` | request ID |
112+
| AEM 字段 | 内容 |
113+
| ---------- | ------------------------------------------------------------------ |
114+
| event name | 命令路径 |
115+
| `et` | `EXP` |
116+
| `ext` |`command``params` 外的结构化事件字段,包含失败时的 `exitCode` |
117+
| `c1` | allowlist 参数 |
118+
| `c2` | `success` / `failure` |
119+
| `c3` | HTTP status |
120+
| `c4` | 错误文案,最多 500 字符 |
121+
| `c5` | request ID |
121122

122123
远端发送是 best-effort,不得阻塞命令或改变退出码。正常退出最多等待 1 秒,SIGINT 最多等待 500 ms。
123124

@@ -144,6 +145,7 @@ AEM 映射:
144145
- [ ] 更新 `TrackingEvent``createTrackingEvent()``buildRemoteAemOptions()` 的字段映射
145146
- [ ] 本地 JSONL 与远端 AEM 必须基于同一结构化事件,不能维护两套字段口径
146147
- [ ] 成功与失败均覆盖;遥测异常必须静默且不改变业务退出码
148+
- [ ] runtime 本地语义错误应记录 `exitCode`;新增字段默认随 AEM `ext` 上报,无需占用新的 `c1``c5`
147149
- [ ] 检查 `DO_NOT_TRACK=1``telemetry: false` 两个关闭入口
148150
- [ ] 错误字段不得额外拼接 token、请求体、prompt 或本地路径
149151

docs/knowledge/doc.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -190,11 +190,11 @@ bl knowledge doc delete --index-id <id> --doc-id <id> [flags]
190190

191191
**参数**
192192

193-
| 参数 | 类型 | 必填 | 说明 |
194-
| ----------------- | ------ | ---- | ----------------- |
195-
| `--index-id <id>` | string || 知识库 ID |
196-
| `--doc-id <id>` | array || 文档 ID(可重复) |
197-
| `--yes` | switch || 跳过确认提示 |
193+
| 参数 | 类型 | 必填 | 说明 |
194+
| ----------------- | ------ | ---- | ------------------ |
195+
| `--index-id <id>` | string || 知识库 ID |
196+
| `--doc-id <id>` | array || 文档 ID(可重复) |
197+
| `--yes` | switch || 显式确认高风险操作 |
198198

199199
**输出**
200200

@@ -223,7 +223,7 @@ json 模式:返回 API 原始响应,`data.deleted[]` 为实际删除的 ID
223223
# 删除单个文档
224224
bl knowledge doc delete --index-id idx-xxx --doc-id doc-xxx --workspace-id ws-xxx
225225

226-
# 批量删除,跳过确认
226+
# 用户明确确认后批量删除
227227
bl knowledge doc delete --index-id idx-xxx --doc-id doc-a --doc-id doc-b --yes
228228
```
229229

docs/kscli/doc.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -190,11 +190,11 @@ kscli doc delete --index-id <id> --doc-id <id> [flags]
190190

191191
**参数**
192192

193-
| 参数 | 类型 | 必填 | 说明 |
194-
| ----------------- | ------ | ---- | ----------------- |
195-
| `--index-id <id>` | string || 知识库 ID |
196-
| `--doc-id <id>` | array || 文档 ID(可重复) |
197-
| `--yes` | switch || 跳过确认提示 |
193+
| 参数 | 类型 | 必填 | 说明 |
194+
| ----------------- | ------ | ---- | ------------------ |
195+
| `--index-id <id>` | string || 知识库 ID |
196+
| `--doc-id <id>` | array || 文档 ID(可重复) |
197+
| `--yes` | switch || 显式确认高风险操作 |
198198

199199
**输出**
200200

@@ -223,7 +223,7 @@ json 模式:返回 API 原始响应,`data.deleted[]` 为实际删除的 ID
223223
# 删除单个文档
224224
kscli doc delete --index-id idx-xxx --doc-id doc-xxx --workspace-id ws-xxx
225225

226-
# 批量删除,跳过确认
226+
# 用户明确确认后批量删除
227227
kscli doc delete --index-id idx-xxx --doc-id doc-a --doc-id doc-b --yes
228228
```
229229

packages/cli/tests/e2e/command-packs.e2e.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ describe("e2e: Command Pack", () => {
5353
expect(linkedJson.linked.commands).toEqual([
5454
"agent credential",
5555
"agent credential-denied",
56+
"agent dangerous",
5657
"agent fail",
5758
"agent output",
5859
"agent ping",
@@ -96,6 +97,34 @@ describe("e2e: Command Pack", () => {
9697
expect(failed.stderr).toContain("Use agent fail only in tests.");
9798
});
9899

100+
test("high-risk 命令由 runtime 统一确认并支持安全 dry-run", async () => {
101+
const dangerousHelp = await runCli(["agent", "dangerous", "--help"], env());
102+
expect(dangerousHelp.exitCode, dangerousHelp.stderr).toBe(0);
103+
expect(dangerousHelp.stderr).toContain("--yes");
104+
105+
const unconfirmed = await runCli(["agent", "dangerous", "--output", "json"], env());
106+
expect(unconfirmed.exitCode).toBe(7);
107+
expect(JSON.parse(unconfirmed.stderr)).toMatchObject({
108+
error: { code: 7, type: "requires_confirmation" },
109+
});
110+
111+
const confirmed = await runCli(["agent", "dangerous", "--yes", "--output", "json"], env());
112+
expect(confirmed.exitCode, confirmed.stderr).toBe(0);
113+
expect(parseStdoutJson(confirmed.stdout)).toEqual({
114+
executed: true,
115+
dry_run: false,
116+
command_flags: [],
117+
});
118+
119+
const preview = await runCli(["agent", "dangerous", "--dry-run", "--output", "json"], env());
120+
expect(preview.exitCode, preview.stderr).toBe(0);
121+
expect(parseStdoutJson(preview.stdout)).toEqual({
122+
executed: false,
123+
dry_run: true,
124+
command_flags: [],
125+
});
126+
});
127+
99128
test("plugin list 输出加载状态", async () => {
100129
const result = await runCli(["plugin", "list", "--output", "json"], env());
101130
expect(result.exitCode, result.stderr).toBe(0);
@@ -109,6 +138,7 @@ describe("e2e: Command Pack", () => {
109138
commands: [
110139
"agent credential",
111140
"agent credential-denied",
141+
"agent dangerous",
112142
"agent fail",
113143
"agent output",
114144
"agent ping",

packages/cli/tests/fixtures/command-pack/commands.mjs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,26 @@ const ping = {
1919
},
2020
};
2121

22+
const dangerous = {
23+
description: "Exercise runtime confirmation for a high-risk Command Pack command",
24+
auth: "none",
25+
risk: {
26+
level: "high",
27+
message: {
28+
"en-US": "This fixture represents a high-risk operation.",
29+
"zh-CN": "该测试命令代表高风险操作。",
30+
},
31+
},
32+
async run(ctx) {
33+
const dryRun = ctx.settings.dryRun;
34+
ctx.output.result({
35+
executed: !dryRun,
36+
dry_run: dryRun,
37+
command_flags: Object.keys(ctx.flags),
38+
});
39+
},
40+
};
41+
2242
const credential = {
2343
description: "Read an API key through the Command Pack host adapter",
2444
auth: "apiKey",
@@ -55,6 +75,7 @@ const fail = {
5575
export default {
5676
"agent credential": credential,
5777
"agent credential-denied": credentialDenied,
78+
"agent dangerous": dangerous,
5879
"agent fail": fail,
5980
"agent output": output,
6081
"agent ping": ping,

packages/commands/src/commands/knowledge/doc-delete.ts

Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
type FlagsDef,
77
type RagDeleteFileResponse,
88
} from "bailian-cli-core";
9-
import { emitResult, emitBare, confirmDangerousAction } from "bailian-cli-runtime";
9+
import { emitResult, emitBare } from "bailian-cli-runtime";
1010
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
1111

1212
const DOC_DELETE_FLAGS = {
@@ -25,28 +25,22 @@ const DOC_DELETE_FLAGS = {
2525
},
2626
required: true,
2727
},
28-
yes: {
29-
type: "switch",
30-
description: { "en-US": "Skip the confirmation prompt", "zh-CN": "跳过确认提示" },
31-
},
3228
...WORKSPACE_FLAG,
3329
} satisfies FlagsDef;
3430

35-
/** Confirmation summary: list all doc_ids up to 5, otherwise show the first 5 + total count */
36-
function buildDeleteSummary(indexId: string, docIds: string[]): string {
37-
const listed =
38-
docIds.length <= 5
39-
? docIds.join("\n ")
40-
: `${docIds.slice(0, 5).join("\n ")}\n ... (${docIds.length} documents total)`;
41-
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.`;
42-
}
43-
4431
export default defineCommand({
4532
description: {
4633
"en-US": "Delete documents and their chunks from a knowledge base",
4734
"zh-CN": "从知识库中删除文档及其 Chunk",
4835
},
4936
auth: "apiKey",
37+
risk: {
38+
level: "high",
39+
message: {
40+
"en-US": "This permanently deletes the selected documents and all of their chunks.",
41+
"zh-CN": "该操作会永久删除所选文档及其全部 Chunk,且无法撤销。",
42+
},
43+
},
5044
usageArgs: "--index-id <id> --doc-id <id> [flags]",
5145
flags: DOC_DELETE_FLAGS,
5246
notes: [
@@ -72,7 +66,7 @@ export default defineCommand({
7266
},
7367
],
7468
exampleArgs: [
75-
"--index-id idx-xxx --doc-id file-xxx --workspace-id ws-xxx",
69+
"--index-id idx-xxx --doc-id file-xxx --workspace-id ws-xxx --dry-run",
7670
"--index-id idx-xxx --doc-id file-a --doc-id file-b --yes",
7771
],
7872
async run(ctx) {
@@ -89,11 +83,6 @@ export default defineCommand({
8983
return;
9084
}
9185

92-
await confirmDangerousAction(
93-
buildDeleteSummary(flags.indexId, flags.docId),
94-
flags.yes ?? false,
95-
);
96-
9786
const response = await ctx.client.requestJson<RagDeleteFileResponse>({
9887
path: endpoint,
9988
method: "POST",

packages/commands/tests/e2e/knowledge/knowledge-doc-delete.e2e.test.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ describe("e2e: knowledge doc delete", () => {
6666
expect(data.request?.doc_ids).toEqual(["file_a", "file_b"]);
6767
});
6868

69-
test("非 TTY 无 --yes 报 USAGE (2)", async () => {
69+
test("无 --yes 返回确认请求 (7)", async () => {
7070
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_DOC_DELETE_ROUTES, [
7171
"knowledge",
7272
"doc",
@@ -79,9 +79,18 @@ describe("e2e: knowledge doc delete", () => {
7979
"sk-fake",
8080
"--workspace-id",
8181
"ws_test",
82+
"--output",
83+
"json",
8284
]);
83-
expect(exitCode).toBe(2);
84-
expect(stderr).toMatch(/--yes/);
85+
expect(exitCode).toBe(7);
86+
expect(JSON.parse(stderr)).toMatchObject({
87+
error: {
88+
code: 7,
89+
type: "requires_confirmation",
90+
hint: expect.stringContaining("--yes"),
91+
},
92+
});
93+
expect(stderr).not.toContain("sk-fake");
8594
});
8695
});
8796

packages/core/src/errors/codes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export const ExitCode = {
66
QUOTA: 4,
77
TIMEOUT: 5,
88
NETWORK: 6,
9+
CONFIRMATION_REQUIRED: 7,
910
CONTENT_FILTER: 10,
1011
} as const;
1112

0 commit comments

Comments
 (0)