@zhushanwen/pi-permission 1.3.4 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -20
- package/package.json +4 -3
- package/skills/permission-ext-config/SKILL.md +10 -9
- package/src/__tests__/approval.test.ts +1 -2
- package/src/__tests__/index-integration.test.ts +5 -4
- package/src/__tests__/pipeline.test.ts +76 -0
- package/src/__tests__/production.test.ts +4 -4
- package/src/__tests__/rule-templates.test.ts +0 -34
- package/src/__tests__/session-start-once-guard.test.ts +147 -0
- package/src/approval.ts +11 -9
- package/src/classifier/__tests__/classifier.test.ts +1 -1
- package/src/classifier/classifier.ts +2 -2
- package/src/classifier/model-resolver.ts +0 -3
- package/src/index.ts +9 -10
- package/src/model-picker.ts +5 -18
- package/src/pipeline.ts +177 -121
- package/src/production.ts +2 -2
- package/src/rule-editor-component.ts +4 -13
- package/src/rule-editor.ts +1 -1
- package/src/rule-templates.ts +1 -52
- package/src/rules/matcher.ts +1 -1
- package/src/select-theme.ts +15 -0
- package/src/statusline-palette.ts +2 -1
- package/src/types.ts +16 -27
package/README.md
CHANGED
|
@@ -21,7 +21,7 @@ Pi permission 扩展 — 四档权限模式(yolo / auto / approve / strict)+
|
|
|
21
21
|
pi install npm:@zhushanwen/pi-permission
|
|
22
22
|
|
|
23
23
|
# 本地开发(symlink;globalExtDir 平铺布局,目标不带分组层)
|
|
24
|
-
ln -s /path/to/xyz-
|
|
24
|
+
ln -s /path/to/xyz-agent/extensions/universal/permission \
|
|
25
25
|
~/.pi/agent/extensions/permission
|
|
26
26
|
```
|
|
27
27
|
|
|
@@ -42,7 +42,8 @@ ln -s /path/to/xyz-pi-extensions-workspace/feat-permission-and-auto-mode/extensi
|
|
|
42
42
|
"model": "auto",
|
|
43
43
|
"timeout": 90,
|
|
44
44
|
"autoApproveLowRisk": true,
|
|
45
|
-
"autoDenyHighRisk": true
|
|
45
|
+
"autoDenyHighRisk": true,
|
|
46
|
+
"thinkingLevel": "off"
|
|
46
47
|
},
|
|
47
48
|
"userRules": []
|
|
48
49
|
}
|
|
@@ -52,11 +53,12 @@ ln -s /path/to/xyz-pi-extensions-workspace/feat-permission-and-auto-mode/extensi
|
|
|
52
53
|
|------|--------|------|
|
|
53
54
|
| `mode` | `"yolo"` | 当前权限模式(yolo/auto/approve/strict) |
|
|
54
55
|
| `enabled` | `true` | 扩展是否启用(false=完全放行,等同 yolo 但保留配置) |
|
|
55
|
-
| `classifier.enabled` | `true` | 是否启用 AI 层(auto
|
|
56
|
-
| `classifier.model` | `"auto"` | AI 模型(`auto` =
|
|
56
|
+
| `classifier.enabled` | `true` | 是否启用 AI 层(false 时 auto 模式跳过 AI,ask 直接转人工审批) |
|
|
57
|
+
| `classifier.model` | `"auto"` | AI 模型(`auto` = 取 modelRegistry.getAvailable() 首个可用,或精确 `provider/model-id`) |
|
|
57
58
|
| `classifier.timeout` | `90` | AI 分类超时秒数 |
|
|
58
59
|
| `classifier.autoApproveLowRisk` | `true` | 低风险是否自动放行(false=转人工) |
|
|
59
60
|
| `classifier.autoDenyHighRisk` | `true` | 高风险是否自动拦截(true=强制 deny) |
|
|
61
|
+
| `classifier.thinkingLevel` | `"off"` | classifier LLM 的 thinking 级别(pi `ModelThinkingLevel`;`"off"`=不传 reasoning,`"minimal"`~`"max"` 透传) |
|
|
60
62
|
| `userRules` | `[]` | 用户自定义规则数组 |
|
|
61
63
|
|
|
62
64
|
## 四档模式
|
|
@@ -93,6 +95,7 @@ ln -s /path/to/xyz-pi-extensions-workspace/feat-permission-and-auto-mode/extensi
|
|
|
93
95
|
/permission approve 切换到 approve 模式
|
|
94
96
|
/permission strict 切换到 strict 模式
|
|
95
97
|
/permission status 显示详细配置
|
|
98
|
+
/permission rule overlay 编辑用户规则(增/删)
|
|
96
99
|
/permission model overlay 选择 AI classifier 模型(W7)
|
|
97
100
|
```
|
|
98
101
|
|
|
@@ -160,8 +163,8 @@ wc / whereis / who / whoami / which
|
|
|
160
163
|
| `git` | 子命令属于 `status`/`log`/`diff`/`show`/`branch` 且只读 | 见下文 git 子表 |
|
|
161
164
|
| `sed` | 仅 `sed -n {N\|M,N}p [file]`(argv 长度 ≤ 4) | 其余形式不放行 |
|
|
162
165
|
| `sort` | 不含写文件 flag | 禁 `-o` / `--output` / `--output=*` / `-o*`(合并 flag) |
|
|
163
|
-
| `iconv` | 不含写文件 flag | 禁 `-o` / `--output` / `--output=*` |
|
|
164
|
-
| `shuf` | 不含写文件 flag | 禁 `-o` / `--output` / `--output=*` |
|
|
166
|
+
| `iconv` | 不含写文件 flag | 禁 `-o` / `--output` / `--output=*` / `-o*`(合并 flag 如 `-fo`) |
|
|
167
|
+
| `shuf` | 不含写文件 flag | 禁 `-o` / `--output` / `--output=*` / `-o*`(合并 flag 如 `-fo`) |
|
|
165
168
|
| `date` | 不含设置时间 flag | 禁 `-s` / `--set` / `--set=*`(`-s` 设置系统时间需 root) |
|
|
166
169
|
|
|
167
170
|
**git 子命令安全判定细则**:
|
|
@@ -181,14 +184,14 @@ wc / whereis / who / whoami / which
|
|
|
181
184
|
|
|
182
185
|
| id | pattern(正则字面量)| description | 匹配示例 |
|
|
183
186
|
|----|----------------------|-------------|----------|
|
|
184
|
-
| bd-001 | `\brm\s
|
|
187
|
+
| bd-001 | `\brm\b.*(\s-(?:[a-zA-Z]*r)\|--recursive)` | recursive delete | `rm -rf /`、`rm -f -r /`(分离 flag)、`rm --recursive y` |
|
|
185
188
|
| bd-002 | `\bsudo\b` | sudo | `sudo`、`sudo -E apt update` |
|
|
186
|
-
| bd-003 | `\bchmod\b.*777` | world-writable permissions | `chmod 777 /tmp`、`chmod
|
|
187
|
-
| bd-004 |
|
|
189
|
+
| bd-003 | `\bchmod\b.*(777\|a\+rwx\|ugo\+rwx\|ugo=rwx)` | world-writable permissions | `chmod 777 /tmp`、`chmod a+rwx file`、`chmod ugo=rwx file` |
|
|
190
|
+
| bd-004 | `(>\s*/dev/(sd\|hd\|nvme\|mmcblk\|vd\|xvd)[a-z0-9]+\|of=/dev/(sd\|hd\|nvme\|mmcblk\|vd\|xvd)[a-z0-9]+)` | raw device write | `dd ... > /dev/sda`、`dd of=/dev/nvme0n1`、`> /dev/mmcblk0` |
|
|
188
191
|
| bd-005 | `\bgit\s+push\s+.*(-f\b\|--force\b)` | force push | `git push --force`、`git push -f` |
|
|
189
192
|
| bd-006 | `\bgit\s+reset\s+--hard\b` | hard reset | `git reset --hard HEAD~1` |
|
|
190
|
-
| bd-007 | `\bgit\s+clean\s
|
|
191
|
-
| bd-008 | `\bgit\s+checkout\s
|
|
193
|
+
| bd-007 | `\bgit\s+clean\b.*(\s-(?:[a-zA-Z]*f)\|--force)` | git clean --force | `git clean -fd`、`git clean -d -f`(分离 flag)、`git clean -fx` |
|
|
194
|
+
| bd-008 | `\bgit\s+checkout\s+(--\s+)?\.\s*($\|[;&\|])` | git checkout . (discard all) | `git checkout .`、`git checkout -- .` |
|
|
192
195
|
| bd-009 | `\bgit\s+restore\b` | git restore | `git restore file.txt` |
|
|
193
196
|
| bd-010 | `\b(curl\|wget)\b.*\|\s*(ba)?sh\b` | pipe to shell | `curl http://x \| sh`、`wget x \| bash` |
|
|
194
197
|
| bd-011 | `\bgh\s+repo\s+(create\|delete\|rename\|archive)\b` | modify GitHub repo | `gh repo delete foo` |
|
|
@@ -223,7 +226,7 @@ wc / whereis / who / whoami / which
|
|
|
223
226
|
|------|------|------|------|
|
|
224
227
|
| `id` | string | 是 | 唯一 id,用户规则建议 `user-<n>`(缺失会自动分配) |
|
|
225
228
|
| `tool` | string | 是 | 工具名匹配,wildcard 语法。`bash` 精确匹配 bash 工具;`*` 匹配所有工具(含 Read/Write/Edit);也可写具体工具名如 `read`、`write` |
|
|
226
|
-
| `pattern` | string | 是 | 命令/目标匹配,wildcard 语法。bash 工具对 `argv.join(' ')` 匹配;非 bash
|
|
229
|
+
| `pattern` | string | 是 | 命令/目标匹配,wildcard 语法。bash 工具对 `argv.join(' ')` 匹配;非 bash 工具对文件路径匹配(path 缺省时对工具名) |
|
|
227
230
|
| `action` | `"allow"` \| `"deny"` \| `"ask"` | 是 | 决策动作,详见第 8 节 |
|
|
228
231
|
| `source` | `"user"` | 是 | 规则来源,用户规则固定 `user`(写成其他值也会被归一化回 `user`) |
|
|
229
232
|
| `description` | string | 否 | 人类可读描述,在 matchedRule 与拒绝理由中展示 |
|
|
@@ -344,22 +347,22 @@ publish 规则在后,last-match-wins 时 deny 胜出。
|
|
|
344
347
|
- **路径**:`<agentDir>/config/permission-ext-config.json`(`<agentDir>` 可用 `PI_CODING_AGENT_DIR` 环境变量覆盖,默认 `~/.pi/agent`)
|
|
345
348
|
- **首次创建**:扩展启动时若文件不存在,自动写入默认配置(`mode: "yolo"`、空 `userRules`)
|
|
346
349
|
- **权限**:`0o600`(原子写:先写 `.tmp` 再 rename,避免半写状态)
|
|
347
|
-
-
|
|
350
|
+
- **编辑方式**:`/permission rule` 打开 overlay 编辑器(TUI 支持列表查看/模板新增/自定义表单;RPC 模式仅支持删除已有规则);也可直接手动编辑 JSON 文件
|
|
348
351
|
- **热重载**:每次 tool_call 都重读配置,用 `mtimeMs + size` 双 key 缓存检测变化(防 APFS 等 mtime 精度截断)。编辑保存后下一次命令即生效,无需重启
|
|
349
352
|
|
|
350
353
|
### 11. 调试技巧
|
|
351
354
|
|
|
352
355
|
- `/permission status`:查看当前配置摘要(含 `userRules` 数量)
|
|
353
|
-
- 查看决策来源:`PermissionDecision.source`(`mode` / `
|
|
356
|
+
- 查看决策来源:`PermissionDecision.source`(`mode` / `rule` / `ai` / `user`),区分是模式直接放行、规则命中、AI 分类还是人工审批(AST 分析不直接产生决策,只改变后续评估路径)
|
|
354
357
|
- 查看命中规则:`PermissionDecision.matchedRule`(命中时携带 Rule 对象),从 `id` 可判断是 `builtin-safe`(白名单虚拟规则)、`bd-<n>`(内置危险)还是 `user-<n>`(用户规则)
|
|
355
358
|
- 故意写一条 `deny` + 带 `description` 的用户规则触发拦截,从 tool_result 的 block reason 文案反查命中的是哪条规则
|
|
356
|
-
- 规则 pattern
|
|
359
|
+
- 规则 pattern 编译失败不会静默跳过:`resolvePattern` 无容错,非法 pattern 抛出的异常由 tool_call handler 兜底 fail-closed block(该次调用被拦)。实际触发面极窄——用户规则加载时 source 归一化为 `user`,走 wildcard 编译(特殊字符自动转义,几乎不会失败);只有手写配置写出非法正则且恰好命中 `builtin-danger` 分支才会抛异常。怀疑某条规则没生效时先检查 JSON 语法与 wildcard 写法
|
|
357
360
|
|
|
358
361
|
## AI Classifier
|
|
359
362
|
|
|
360
363
|
auto 模式下层 3 用 LLM 评估未知命令风险:
|
|
361
364
|
|
|
362
|
-
- **模型**:`classifier.model`(`auto` =
|
|
365
|
+
- **模型**:`classifier.model`(`auto` = 取 `modelRegistry.getAvailable()` 首个可用,或指定精确 `provider/model-id`)
|
|
363
366
|
- **输出**:`risk_level`(low/medium/high)+ `outcome`(allow/deny/ask)+ `reasoning` + `confidence`
|
|
364
367
|
- **override**(WT7 偏差补丁):
|
|
365
368
|
- `low + allow + autoApproveLowRisk=false` → 强制 `ask`(转人工)
|
|
@@ -370,14 +373,15 @@ auto 模式下层 3 用 LLM 评估未知命令风险:
|
|
|
370
373
|
|
|
371
374
|
`/permission model` 弹出 overlay 选择 AI classifier 使用的模型,写回 `classifier.model`:
|
|
372
375
|
|
|
373
|
-
-
|
|
374
|
-
-
|
|
376
|
+
- **数据源**:`ctx.modelRegistry.getAll()` + `hasConfiguredAuth()` 过滤(`pi auth login` 配置的内置/OAuth provider 同样可见,不读 `models.json`),整体按 provider + id 字典序排序。
|
|
377
|
+
- **第一级 provider 选择**:列出 `Auto`(自动:取 `modelRegistry.getAvailable()` 首个可用)+ 所有可用 provider。当前 `classifier.model` 预选高亮。
|
|
378
|
+
- **第二级 model 选择**:选中具体 provider 后,列出该 provider 下所有可用 model(provider + id 字典序)。`Esc` 回退到 provider 列表。
|
|
375
379
|
- **键位**:`↑/↓` 导航、`Enter` 确认、`Esc` 取消(provider stage)或回退(model stage)。
|
|
376
380
|
- **三模式分发**:
|
|
377
381
|
- **TUI**:`ctx.ui.custom` 渲染 overlay(`ProviderModelSelectorComponent`,两级 `SelectList` 状态机)。
|
|
378
382
|
- **RPC**:两次 `ctx.ui.select`(先 provider 含 `Auto`,再 model)。
|
|
379
383
|
- **headless**(json/print):无交互 UI,返回降级提示。
|
|
380
|
-
- **无可用模型**:`
|
|
384
|
+
- **无可用模型**:`modelRegistry` 无模型或全部未配置 auth 时,`listAvailableModels` 返回空 Map,命令降级为提示 `No available models. Run `pi auth login` or configure provider API keys, then retry.`(不阻塞,不修改配置)。
|
|
381
385
|
- **结果写回**:选中后 `classifier.model` 更新为 `auto` 或 `provider/model-id`,其余字段(mode/enabled/timeout/userRules)保留。
|
|
382
386
|
|
|
383
387
|
## 已知限制
|
|
@@ -422,7 +426,7 @@ bash? ──yes──→ AST 结构分析
|
|
|
422
426
|
- **fail-closed**:任何异常 → block(绝不静默放行)
|
|
423
427
|
- **checkPermission 永不 throw**:caller(tool_call handler)依赖此契约
|
|
424
428
|
- **deps 注入**:所有外部依赖(AST/规则/AI/UI)通过 CheckPermissionDeps 注入,便于测试 mock
|
|
425
|
-
-
|
|
429
|
+
- **配置读时刷新**:config 不持有跨调用缓存,每次需要时直接 `loadAndWatchConfig()` 读最新(llm-shared `mtimeMs + size` 去重,文件未变只 statSync),改文件即生效
|
|
426
430
|
- **纯函数核心**:checkPermission / runLayer2 / applyAutoApproveOverrides 都是纯函数
|
|
427
431
|
|
|
428
432
|
## 开发
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-permission",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Pi permission extension — four permission modes (yolo/auto/approve/strict) with three-layer pipeline (AST + rules + AI classifier).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -50,8 +50,9 @@
|
|
|
50
50
|
"dependencies": {
|
|
51
51
|
"tree-sitter-bash": "^0.25.1",
|
|
52
52
|
"web-tree-sitter": "^0.26.11",
|
|
53
|
-
"@zhushanwen/pi-
|
|
54
|
-
"@zhushanwen/pi-
|
|
53
|
+
"@zhushanwen/pi-ext-guards": "0.2.0",
|
|
54
|
+
"@zhushanwen/pi-extension-logger": "0.4.0",
|
|
55
|
+
"@zhushanwen/pi-llm-shared": "0.5.0"
|
|
55
56
|
},
|
|
56
57
|
"peerDependencies": {
|
|
57
58
|
"@earendil-works/pi-ai": "^0.84.4",
|
|
@@ -43,7 +43,7 @@ interface PermissionConfig {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
interface ClassifierConfig {
|
|
46
|
-
enabled: boolean; // AI 层开关,默认 true
|
|
46
|
+
enabled: boolean; // AI 层开关,默认 true;false 时 auto 模式跳过 AI 直接人工审批
|
|
47
47
|
model: string; // 'auto' 或 'provider/model-id',默认 "auto"
|
|
48
48
|
timeout: number; // 超时秒,默认 90
|
|
49
49
|
autoApproveLowRisk: boolean; // 低风险自动放行,默认 true
|
|
@@ -71,15 +71,16 @@ interface ClassifierConfig {
|
|
|
71
71
|
|
|
72
72
|
### auto 模式的风险判定(autoApproveLowRisk / autoDenyHighRisk)
|
|
73
73
|
|
|
74
|
-
AI classifier
|
|
74
|
+
AI classifier 输出 `outcome`(allow/deny/ask)+ `risk_level`(low/medium/high),**outcome 直接生效**:allow 自动放行、deny 直接拒绝、ask 转人工。两个开关只拨动两条边界 override:
|
|
75
75
|
|
|
76
|
-
|
|
|
77
|
-
|
|
78
|
-
| low |
|
|
79
|
-
|
|
|
80
|
-
|
|
|
76
|
+
| AI 输出 | override 条件 | 实际动作 |
|
|
77
|
+
|---|---|---|
|
|
78
|
+
| allow + low | `autoApproveLowRisk=false` | 改 **ask**(转人工);默认 true 时自动放行 |
|
|
79
|
+
| allow + high | `autoDenyHighRisk=true` | 改 **deny**(不问人,直接拒绝);false 时自动放行 |
|
|
80
|
+
| allow + medium | — | **自动放行**(不经人工,不受这两个开关影响) |
|
|
81
|
+
| deny / ask(任意风险) | — | 原样生效(deny 拒绝;ask 转人工) |
|
|
81
82
|
|
|
82
|
-
> medium
|
|
83
|
+
> 也就是说:medium + allow 会自动放行;只有 low+allow 与 high+allow 这两个边界会被开关改写。想让 medium 也转人工做不到(AI 说 allow 就放行),只能换 `approve` 模式去掉 AI 层。
|
|
83
84
|
|
|
84
85
|
## 默认值
|
|
85
86
|
|
|
@@ -139,7 +140,7 @@ auto 模式 + 指定 classifier 用 glm-4-flash + 自定义危险规则:
|
|
|
139
140
|
|
|
140
141
|
每条规则:`{ id?, tool, pattern, action, source, description? }`
|
|
141
142
|
- tool:工具名(`*` 匹配全部,`bash` 精确)
|
|
142
|
-
- pattern
|
|
143
|
+
- pattern:bash 工具对命令行匹配;非 bash 工具对文件路径匹配(path 缺省时对工具名)(wildcard)
|
|
143
144
|
- action:`allow` | `deny` | `ask`
|
|
144
145
|
- source:`user`(用户规则;内置规则 builtin-safe/builtin-danger 在代码里不进配置)
|
|
145
146
|
- id 省略时自动分配 `user-<n>`
|
|
@@ -83,7 +83,6 @@ describe("requestUserApproval: rpc 模式", () => {
|
|
|
83
83
|
});
|
|
84
84
|
const decision = await requestUserApproval(req, ctx, undefined, approvalCtx);
|
|
85
85
|
expect(decision.approved).toBe(true);
|
|
86
|
-
expect(decision.scope).toBe("once");
|
|
87
86
|
});
|
|
88
87
|
|
|
89
88
|
it("用户选 Deny → approved=false", async () => {
|
|
@@ -180,7 +179,7 @@ describe("requestUserApproval: tui 模式", () => {
|
|
|
180
179
|
ui: {
|
|
181
180
|
notify: vi.fn(),
|
|
182
181
|
select: vi.fn(),
|
|
183
|
-
custom: vi.fn((_factory) => Promise.resolve({ approved: true, reason: "tui-ok"
|
|
182
|
+
custom: vi.fn((_factory) => Promise.resolve({ approved: true, reason: "tui-ok" } as UserDecision)),
|
|
184
183
|
},
|
|
185
184
|
});
|
|
186
185
|
const decision = await requestUserApproval(req, ctx, undefined, approvalCtx);
|
|
@@ -374,8 +374,9 @@ describe("footer line 注册", () => {
|
|
|
374
374
|
});
|
|
375
375
|
|
|
376
376
|
// ──────────────────────── session_start 配置路径迁移接线(index.ts migrateLegacyConfig 调用)────────────────────────
|
|
377
|
-
//
|
|
378
|
-
// 取新鲜模块实例,否则前面用例已触发过
|
|
377
|
+
// 迁移经 oncePerProcess 守卫(u-audit-fix 收编原内联 once flag),守卫的模块级 Map
|
|
378
|
+
// 跨用例残留:用 vi.resetModules() + 动态 import 取新鲜模块实例,否则前面用例已触发过
|
|
379
|
+
// session_start,迁移分支永不执行(静默不测)。
|
|
379
380
|
describe("session_start 配置路径迁移接线", () => {
|
|
380
381
|
const LEGACY_PATH = join(AGENT_DIR, "permission-config.json");
|
|
381
382
|
|
|
@@ -414,7 +415,7 @@ describe("session_start 配置路径迁移接线", () => {
|
|
|
414
415
|
expect(existsSync(LEGACY_PATH)).toBe(false);
|
|
415
416
|
});
|
|
416
417
|
|
|
417
|
-
it("once
|
|
418
|
+
it("once 守卫:同进程第二次 session_start 不重复迁移(重建旧文件不动)", async () => {
|
|
418
419
|
rmSync(CONFIG_PATH, { force: true });
|
|
419
420
|
writeLegacyConfig("auto");
|
|
420
421
|
|
|
@@ -425,7 +426,7 @@ describe("session_start 配置路径迁移接线", () => {
|
|
|
425
426
|
handler({}, makeCtx("json"));
|
|
426
427
|
expect(existsSync(LEGACY_PATH)).toBe(false); // 首次已迁移
|
|
427
428
|
|
|
428
|
-
// 重建旧文件(模拟外部残留),再次触发 session_start →
|
|
429
|
+
// 重建旧文件(模拟外部残留),再次触发 session_start → oncePerProcess 守卫跳过迁移
|
|
429
430
|
writeLegacyConfig("yolo");
|
|
430
431
|
handler({}, makeCtx("json"));
|
|
431
432
|
expect(existsSync(LEGACY_PATH)).toBe(true); // 未被再次消费
|
|
@@ -329,6 +329,82 @@ describe("checkPermission: auto 模式(AST + 规则 + AI Racing)", () => {
|
|
|
329
329
|
});
|
|
330
330
|
});
|
|
331
331
|
|
|
332
|
+
// ──────────────────────── A2: classifier.enabled=false(AI 层关闭,跳过 racing) ────────────────────────
|
|
333
|
+
|
|
334
|
+
describe("A2: classifier.enabled=false(AI 层关闭,直接人工审批)", () => {
|
|
335
|
+
it("auto + 规则 ask + enabled=false → 直接人工审批(不调 classifyRisk)", async () => {
|
|
336
|
+
const classify = vi.fn(() => Promise.resolve(allowClassifier()));
|
|
337
|
+
const approve = vi.fn(() => Promise.resolve<UserDecision>({ approved: true, reason: "user-ok" }));
|
|
338
|
+
const deps = makeDeps({
|
|
339
|
+
analyze: () => Promise.resolve(cleanAnalysis([["curl"]])),
|
|
340
|
+
matchArgv: () => ({ action: "ask", matchedRule: undefined }),
|
|
341
|
+
classify,
|
|
342
|
+
approve,
|
|
343
|
+
});
|
|
344
|
+
const decision = await checkPermission(
|
|
345
|
+
"bash",
|
|
346
|
+
{ command: "curl example.com" },
|
|
347
|
+
"auto",
|
|
348
|
+
{ ...DEFAULT_CFG, enabled: false },
|
|
349
|
+
[],
|
|
350
|
+
deps,
|
|
351
|
+
ctxBase,
|
|
352
|
+
);
|
|
353
|
+
expect(decision.action).toBe("allow");
|
|
354
|
+
expect(decision.source).toBe("user");
|
|
355
|
+
expect(classify).not.toHaveBeenCalled(); // AI 层被关闭,classifier 不启动
|
|
356
|
+
expect(approve).toHaveBeenCalledOnce(); // 直接走人工审批
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("enabled=false + 用户拒绝 → deny(source=user)", async () => {
|
|
360
|
+
const classify = vi.fn(() => Promise.resolve(denyClassifier("high")));
|
|
361
|
+
const deps = makeDeps({
|
|
362
|
+
analyze: () => Promise.resolve(cleanAnalysis([["curl"]])),
|
|
363
|
+
matchArgv: () => ({ action: "ask", matchedRule: undefined }),
|
|
364
|
+
classify,
|
|
365
|
+
approve: () => Promise.resolve<UserDecision>({ approved: false, reason: "no" }),
|
|
366
|
+
});
|
|
367
|
+
const decision = await checkPermission(
|
|
368
|
+
"bash",
|
|
369
|
+
{ command: "curl example.com" },
|
|
370
|
+
"auto",
|
|
371
|
+
{ ...DEFAULT_CFG, enabled: false },
|
|
372
|
+
[],
|
|
373
|
+
deps,
|
|
374
|
+
ctxBase,
|
|
375
|
+
);
|
|
376
|
+
expect(decision.action).toBe("deny");
|
|
377
|
+
expect(decision.source).toBe("user");
|
|
378
|
+
expect(decision.reason).toBe("no");
|
|
379
|
+
expect(classify).not.toHaveBeenCalled();
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it("enabled=false + headless → 人工审批路径立即 fail-closed deny(无 AI 可兜底)", async () => {
|
|
383
|
+
const classify = vi.fn(() => Promise.resolve(allowClassifier()));
|
|
384
|
+
const approve = vi.fn(() => Promise.resolve<UserDecision>({ approved: false, reason: "headless auto-deny" }));
|
|
385
|
+
const deps = makeDeps({
|
|
386
|
+
analyze: () => Promise.resolve(cleanAnalysis([["curl"]])),
|
|
387
|
+
matchArgv: () => ({ action: "ask", matchedRule: undefined }),
|
|
388
|
+
classify,
|
|
389
|
+
approve,
|
|
390
|
+
isHeadless: () => true,
|
|
391
|
+
});
|
|
392
|
+
const decision = await checkPermission(
|
|
393
|
+
"bash",
|
|
394
|
+
{ command: "curl example.com" },
|
|
395
|
+
"auto",
|
|
396
|
+
{ ...DEFAULT_CFG, enabled: false },
|
|
397
|
+
[],
|
|
398
|
+
deps,
|
|
399
|
+
ctxBase,
|
|
400
|
+
);
|
|
401
|
+
expect(decision.action).toBe("deny");
|
|
402
|
+
expect(decision.source).toBe("user");
|
|
403
|
+
expect(classify).not.toHaveBeenCalled(); // 不等 AI(enabled=false 时无 AI 层可兜底)
|
|
404
|
+
expect(approve).toHaveBeenCalledOnce();
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
332
408
|
// ──────────────────────── G1: matchNonBashTool ────────────────────────
|
|
333
409
|
|
|
334
410
|
describe("G1: matchNonBashTool(非 bash 工具规则匹配,M5 pattern 对 path 匹配)", () => {
|
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
* C1a 收口后 createProductionClassifier(ctx) 的 resolveModel 注入走 llm-shared resolveModel
|
|
5
5
|
* (不再预检凭证),callLLM 注入闭包捕获 ctx(凭证在 callLLM 内部 getApiKeyAndHeaders)。
|
|
6
6
|
* 测试用 mock ctx.modelRegistry + vi.mock llm-shared(resolveModel + callLLM)精确控制,覆盖:
|
|
7
|
-
* - TC1
|
|
8
|
-
* - TC3:
|
|
7
|
+
* - TC1: toSelector 任意字符串 → ref 精确选择器('auto' 不走 selector,由 resolveModel 注入层本地处理)
|
|
8
|
+
* - TC3: 'auto' 且 getAvailable 空 → resolveModel 返回 null → fail-closed ask(不触达 callLLM)
|
|
9
9
|
* - TC4(静态断言): production.ts 收口完成——无 getApiProvider/streamSimple/@ts-ignore,走 callLLM
|
|
10
10
|
* - TC4(装配): resolveModel 命中 → callLLM 收到 model → ok:true 正常分类(链路通)
|
|
11
11
|
* - TC5: callLLM 返回 ok:false(LLM 调用失败)→ classifier fail-closed fallback
|
|
12
|
-
* - TC7:
|
|
12
|
+
* - TC7: 'auto' 直接取 getAvailable() 首个(permission 本地解析,不经过 llm-shared 非精确 selector)
|
|
13
13
|
* - m1: 每次 createPipelineDeps 创建独立 classifier(CL-classifier-singleton)
|
|
14
14
|
*/
|
|
15
15
|
import { readFileSync } from "node:fs";
|
|
@@ -192,7 +192,7 @@ describe("resolveModel 装配(resolveModel + callLLM)", () => {
|
|
|
192
192
|
|
|
193
193
|
it("TC5: callLLM 返回 ok:false(LLM 调用失败)→ classifier fail-closed fallback(不 throw)", async () => {
|
|
194
194
|
vi.mocked(resolveModelShared).mockReturnValue(MOCK_MODEL_A);
|
|
195
|
-
vi.mocked(callLLMShared).mockResolvedValue({ ok: false, error: "token expired"
|
|
195
|
+
vi.mocked(callLLMShared).mockResolvedValue({ ok: false, error: "token expired" });
|
|
196
196
|
const classifier = createProductionClassifier(makeMockCtx());
|
|
197
197
|
const result = await classifier.classifyRisk(CTX, CFG_REF);
|
|
198
198
|
expect(result.outcome).toBe("ask");
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
* 覆盖:
|
|
5
5
|
* - PRESET_COMMANDS 结构(31 命令,7 类 + Other)
|
|
6
6
|
* - 5 个模板 build(G4 wildcard pattern)
|
|
7
|
-
* - classifyRuleTemplate(FAMILY_RE / SUBCMD_RE + action 映射)
|
|
8
7
|
* - applyOps(add / edit / delete,纯函数)
|
|
9
8
|
* - makeNextIdCounter(从已有规则提取最大 user-N)
|
|
10
9
|
*/
|
|
@@ -15,7 +14,6 @@ import {
|
|
|
15
14
|
allowSubcmdTemplate,
|
|
16
15
|
applyOps,
|
|
17
16
|
askBeforeTemplate,
|
|
18
|
-
classifyRuleTemplate,
|
|
19
17
|
customTemplate,
|
|
20
18
|
denyFamilyTemplate,
|
|
21
19
|
makeNextIdCounter,
|
|
@@ -145,38 +143,6 @@ describe("RT2: 模板 build(G4 wildcard pattern)", () => {
|
|
|
145
143
|
});
|
|
146
144
|
});
|
|
147
145
|
|
|
148
|
-
// ──────────────────────── classifyRuleTemplate ────────────────────────
|
|
149
|
-
|
|
150
|
-
describe("RT3: classifyRuleTemplate", () => {
|
|
151
|
-
it("npm * + allow → allow-family", () => {
|
|
152
|
-
expect(classifyRuleTemplate(makeRule({ pattern: "npm *", action: "allow" }))).toBe("allow-family");
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it("git * + deny → deny-family(any)", () => {
|
|
156
|
-
expect(classifyRuleTemplate(makeRule({ pattern: "git *", action: "deny" }))).toBe("deny-family");
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
it("git push * + deny → deny-family(specific)", () => {
|
|
160
|
-
expect(classifyRuleTemplate(makeRule({ pattern: "git push *", action: "deny" }))).toBe("deny-family");
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
it("docker * + ask → ask-before", () => {
|
|
164
|
-
expect(classifyRuleTemplate(makeRule({ pattern: "docker *", action: "ask" }))).toBe("ask-before");
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
it("git status * + allow → allow-subcmd", () => {
|
|
168
|
-
expect(classifyRuleTemplate(makeRule({ pattern: "git status *", action: "allow" }))).toBe("allow-subcmd");
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
it("不匹配已知格式 → custom", () => {
|
|
172
|
-
expect(classifyRuleTemplate(makeRule({ pattern: "rm -rf /tmp/*", action: "deny" }))).toBe("custom");
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
it("* alone + allow → custom(不匹配 FAMILY_RE,无 cmd 前缀)", () => {
|
|
176
|
-
expect(classifyRuleTemplate(makeRule({ pattern: "*", action: "allow" }))).toBe("custom");
|
|
177
|
-
});
|
|
178
|
-
});
|
|
179
|
-
|
|
180
146
|
// ──────────────────────── applyOps ────────────────────────
|
|
181
147
|
|
|
182
148
|
describe("RT4: applyOps", () => {
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-start-once-guard.test.ts — u-audit-fix 探针实测(permission 侧)。
|
|
3
|
+
*
|
|
4
|
+
* 排查清单(docs/design/pi-session-start-handler-idempotency-audit.md §2 permission 行)
|
|
5
|
+
* 判定「必须接入」:migrateLegacyConfig 写 agentDir 全局配置文件,属跨 session 副作用,
|
|
6
|
+
* 已由 oncePerProcess 守卫收编原内联 once flag。本文件按清单探针验证点双派发实测:
|
|
7
|
+
* a) 双派发(同一 factory 闭包直接调 handler 两次)下 spy migrateLegacyConfig 调用 = 1;
|
|
8
|
+
* b) 文件面:tmp agentDir 预置旧路径文件 → 第一次 handler 后旧文件消失 + 新路径出现,
|
|
9
|
+
* 第二次 handler 后新路径 mtime/内容不变(守卫拦截 = 不触碰);
|
|
10
|
+
* c) migrated warn 仅一条:构造性蕴含于 a——warn 在 migrateLegacyConfig 调用点内部
|
|
11
|
+
* (llm-shared migrate.ts "migrated" 分支),调用 = 1 ⟹ warn ≤ 1,不另设断言。
|
|
12
|
+
* 反向断言(豁免项防误伤):footer 注册为纯内存初始化(registry.register 同 id 覆盖),
|
|
13
|
+
* 清单明令不包装——双派发仍每次执行(pending push ×2)。
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
|
|
19
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
21
|
+
|
|
22
|
+
import { FOOTER_HANDSHAKE_KEY } from "../footer-provider.js";
|
|
23
|
+
|
|
24
|
+
// ── mock:migrateLegacyConfig 包 spy 计数(透传真实实现,文件面断言保留) ──
|
|
25
|
+
|
|
26
|
+
const { spyMigrateLegacyConfig } = vi.hoisted(() => ({
|
|
27
|
+
spyMigrateLegacyConfig: vi.fn(),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
vi.mock("@zhushanwen/pi-llm-shared", async (importOriginal) => {
|
|
31
|
+
const actual = await importOriginal<typeof import("@zhushanwen/pi-llm-shared")>();
|
|
32
|
+
spyMigrateLegacyConfig.mockImplementation((agentDir: string, oldRel: string, newRel: string) =>
|
|
33
|
+
actual.migrateLegacyConfig(agentDir, oldRel, newRel));
|
|
34
|
+
return { ...actual, migrateLegacyConfig: spyMigrateLegacyConfig };
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// ── mock pi(对齐 index-integration.test.ts 自包含 helper 形态) ──
|
|
38
|
+
|
|
39
|
+
function createMockPi(): { pi: Pick<ExtensionAPI, "on" | "registerCommand">; calls: { eventHandlers: Map<string, Array<(event?: unknown, ctx?: unknown) => unknown>> } } {
|
|
40
|
+
const eventHandlers = new Map<string, Array<(event?: unknown, ctx?: unknown) => unknown>>();
|
|
41
|
+
const pi = {
|
|
42
|
+
registerCommand(): void {
|
|
43
|
+
/* noop */
|
|
44
|
+
},
|
|
45
|
+
on(event: string, handler: (event?: unknown, ctx?: unknown) => unknown): void {
|
|
46
|
+
if (!eventHandlers.has(event)) eventHandlers.set(event, []);
|
|
47
|
+
eventHandlers.get(event)!.push(handler);
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
return { pi: pi as Pick<ExtensionAPI, "on" | "registerCommand">, calls: { eventHandlers } };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getSessionStartHandler(calls: { eventHandlers: Map<string, Array<(event?: unknown, ctx?: unknown) => unknown>> }): (event: unknown, ctx: unknown) => unknown {
|
|
54
|
+
const handlers = calls.eventHandlers.get("session_start");
|
|
55
|
+
if (!handlers || handlers.length === 0) {
|
|
56
|
+
throw new Error("session_start handler not registered");
|
|
57
|
+
}
|
|
58
|
+
return handlers[0]!;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── tmp agentDir(getAgentDir 经 PI_CODING_AGENT_DIR 推导,对齐 index-integration.test.ts) ──
|
|
62
|
+
|
|
63
|
+
const TMP_ROOT = join(tmpdir(), "pi-perm-once-guard-" + process.pid);
|
|
64
|
+
const AGENT_DIR = join(TMP_ROOT, "agent");
|
|
65
|
+
const LEGACY_PATH = join(AGENT_DIR, "permission-config.json");
|
|
66
|
+
const CONFIG_PATH = join(AGENT_DIR, "config", "permission-ext-config.json");
|
|
67
|
+
|
|
68
|
+
function writeLegacyConfig(mode: string): void {
|
|
69
|
+
mkdirSync(AGENT_DIR, { recursive: true });
|
|
70
|
+
writeFileSync(
|
|
71
|
+
LEGACY_PATH,
|
|
72
|
+
JSON.stringify({ mode, enabled: true, classifier: { enabled: true }, userRules: [] }, null, 2) + "\n",
|
|
73
|
+
{ mode: 0o600 },
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** oncePerProcess 的 Map 是模块级状态:每个用例 resetModules 取新鲜模块实例(对齐迁移接线测试先例)。 */
|
|
78
|
+
async function freshExtension(): Promise<(pi: Pick<ExtensionAPI, "on" | "registerCommand">) => void> {
|
|
79
|
+
vi.resetModules();
|
|
80
|
+
const mod = await import("../index.js");
|
|
81
|
+
return mod.default as (pi: Pick<ExtensionAPI, "on" | "registerCommand">) => void;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
beforeEach(() => {
|
|
85
|
+
Reflect.deleteProperty(globalThis, FOOTER_HANDSHAKE_KEY);
|
|
86
|
+
process.env.PI_CODING_AGENT_DIR = AGENT_DIR;
|
|
87
|
+
mkdirSync(AGENT_DIR, { recursive: true });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
afterEach(() => {
|
|
91
|
+
delete process.env.PI_CODING_AGENT_DIR;
|
|
92
|
+
Reflect.deleteProperty(globalThis, FOOTER_HANDSHAKE_KEY);
|
|
93
|
+
if (existsSync(TMP_ROOT)) rmSync(TMP_ROOT, { recursive: true, force: true });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("session_start 双派发幂等守卫(oncePerProcess,u-audit-fix)", () => {
|
|
97
|
+
it("双派发下 migrateLegacyConfig 仅执行一次:spy = 1,文件面首次迁移到位、第二次 mtime/内容不变", async () => {
|
|
98
|
+
// 预置旧路径文件(新路径不存在 → 走 renameSync 迁移分支)
|
|
99
|
+
writeLegacyConfig("strict");
|
|
100
|
+
|
|
101
|
+
const extension = await freshExtension();
|
|
102
|
+
const { pi, calls } = createMockPi();
|
|
103
|
+
extension(pi);
|
|
104
|
+
const handler = getSessionStartHandler(calls);
|
|
105
|
+
|
|
106
|
+
// 第一次派发:迁移发生(旧文件消失 + 新路径出现,内容来自旧文件)
|
|
107
|
+
handler({}, { mode: "json", cwd: "/tmp", ui: {}, signal: undefined });
|
|
108
|
+
expect(existsSync(LEGACY_PATH)).toBe(false);
|
|
109
|
+
expect(existsSync(CONFIG_PATH)).toBe(true);
|
|
110
|
+
expect(JSON.parse(readFileSync(CONFIG_PATH, "utf-8")).mode).toBe("strict");
|
|
111
|
+
const mtimeAfterFirst = statSync(CONFIG_PATH).mtimeMs;
|
|
112
|
+
const contentAfterFirst = readFileSync(CONFIG_PATH, "utf-8");
|
|
113
|
+
|
|
114
|
+
// 第二次派发(factory 二调/handler 累积形态:同一 handler 引用再调):
|
|
115
|
+
// 重建旧路径文件模拟外部残留——守卫已消费 key,不得再次触碰任何文件。
|
|
116
|
+
writeLegacyConfig("yolo");
|
|
117
|
+
handler({}, { mode: "json", cwd: "/tmp", ui: {}, signal: undefined });
|
|
118
|
+
|
|
119
|
+
// 探针 a:操作级去重(spy 透传真实实现,文件面与计数同源)
|
|
120
|
+
expect(spyMigrateLegacyConfig).toHaveBeenCalledTimes(1);
|
|
121
|
+
// 探针 b:第二次后重建的旧文件原样保留、新路径 mtime/内容严格不变
|
|
122
|
+
expect(existsSync(LEGACY_PATH)).toBe(true);
|
|
123
|
+
expect(statSync(CONFIG_PATH).mtimeMs).toBe(mtimeAfterFirst);
|
|
124
|
+
expect(readFileSync(CONFIG_PATH, "utf-8")).toBe(contentAfterFirst);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("豁免项防误伤:footer 注册不包装,双派发仍每次执行(pending push ×2)", async () => {
|
|
128
|
+
writeLegacyConfig("strict");
|
|
129
|
+
|
|
130
|
+
const extension = await freshExtension();
|
|
131
|
+
const { pi, calls } = createMockPi();
|
|
132
|
+
extension(pi);
|
|
133
|
+
const handler = getSessionStartHandler(calls);
|
|
134
|
+
|
|
135
|
+
handler({}, { mode: "json", cwd: "/tmp", ui: {}, signal: undefined });
|
|
136
|
+
handler({}, { mode: "json", cwd: "/tmp", ui: {}, signal: undefined });
|
|
137
|
+
|
|
138
|
+
// 纯内存初始化(registry.register 同 id 覆盖 / pending push)保持每 session_start
|
|
139
|
+
// 执行——清单「粒度边界」明令不包装,挂进程级 flag 会杀 session 级语义。
|
|
140
|
+
const slot = Reflect.get(globalThis, FOOTER_HANDSHAKE_KEY) as {
|
|
141
|
+
pending: Array<{ id: string; renderer: unknown }>;
|
|
142
|
+
} | undefined;
|
|
143
|
+
expect(slot).toBeDefined();
|
|
144
|
+
expect(slot!.pending.length).toBe(2);
|
|
145
|
+
expect(slot!.pending[0]!.id).toBe("pi-permission");
|
|
146
|
+
});
|
|
147
|
+
});
|
package/src/approval.ts
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
* G3:调 ctx.ui.custom 前先检查 signal.aborted 短路(AI 先于 UI factory 执行时
|
|
7
7
|
* controller.abort() 触发但 comp 尚未创建,cancel 落空)。
|
|
8
8
|
* - rpc:ctx.ui.select(GUI 对话框)。M2:接收 signal,abort 短路 + 透传给 ui.select。
|
|
9
|
-
* - headless(json/print):无交互 UI
|
|
10
|
-
*
|
|
9
|
+
* - headless(json/print):无交互 UI,立即 fail-closed deny(requestHeadless)。auto 模式
|
|
10
|
+
* Racing 不走此路径(pipeline runLayer3WithRacing 的 isHeadless 分支纯等 AI 判定)。
|
|
11
11
|
*
|
|
12
12
|
* ApprovalComponent 是简化版 TUI 组件(参考 ask-user AskUserComponent):
|
|
13
13
|
* - 显示工具名 + 命令 + 触发原因 + 可选 AI 预分类。
|
|
14
|
-
* -
|
|
14
|
+
* - Enter/approve、Esc/deny。
|
|
15
15
|
* - signal abort → comp.cancel()(复用 _resolved 守卫,避免二次 done)。
|
|
16
16
|
*
|
|
17
17
|
* W6 T9 G3 Reject-with-Reason:用户拒绝时,若 ctx.ui.input 存在则弹出文本输入框
|
|
@@ -123,7 +123,8 @@ async function requestRpc(
|
|
|
123
123
|
): Promise<UserDecision> {
|
|
124
124
|
const title = formatTitle(req);
|
|
125
125
|
// TODO: spec 未要求 session/always scope,当前仅支持 once。
|
|
126
|
-
// 未来如需扩展,options 加 "Approve (session)" / "Approve (always)"
|
|
126
|
+
// 未来如需扩展,options 加 "Approve (session)" / "Approve (always)",并在 UserDecision
|
|
127
|
+
// 上新增 scope 字段承载(当前 UserDecision 只有 approved/reason)。
|
|
127
128
|
const options = ["Approve (once)", "Deny"];
|
|
128
129
|
// M2:与 requestTui 一致——调 select 前检查 signal.aborted 短路(AI 先于对话框弹出赢 race 时,
|
|
129
130
|
// controller.abort() 已触发但 select 尚未发起,这里 fail-closed deny 避免弹出无意义对话框)。
|
|
@@ -137,7 +138,7 @@ async function requestRpc(
|
|
|
137
138
|
return { approved: false, reason: "user dismissed the prompt" };
|
|
138
139
|
}
|
|
139
140
|
if (choice.startsWith("Approve")) {
|
|
140
|
-
return { approved: true, reason: "approved via rpc"
|
|
141
|
+
return { approved: true, reason: "approved via rpc" };
|
|
141
142
|
}
|
|
142
143
|
// W6 T9 G3:Reject-with-Reason。用户选 Deny 后,若 ctx.ui.input 存在则采集真实理由。
|
|
143
144
|
// 「受阻」可观测条件:typeof ctx.ui.input === 'function'。
|
|
@@ -226,7 +227,7 @@ interface TuiLike {
|
|
|
226
227
|
* TUI 审批组件(简化版,参考 ask-user AskUserComponent)。
|
|
227
228
|
*
|
|
228
229
|
* 显示:标题 + 工具名 + 命令 + 原因 + 可选 AI 预分类 + 操作提示。
|
|
229
|
-
* 键位:
|
|
230
|
+
* 键位:Enter → approve;Esc → deny/cancel(y/n 快捷键已移除,测试钉住 no-op)。
|
|
230
231
|
*
|
|
231
232
|
* G4:implements Component,补 invalidate()(调 tui.requestRender())。
|
|
232
233
|
* signal abort → cancel()(复用 _resolved 守卫)。
|
|
@@ -292,9 +293,10 @@ export class ApprovalComponent implements Component {
|
|
|
292
293
|
private approve(): void {
|
|
293
294
|
if (this._resolved) return;
|
|
294
295
|
this._resolved = true;
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
296
|
+
// TODO: spec 未要求 session/always scope,当前硬编码 "once"。未来如需扩展,
|
|
297
|
+
// 增加 handleInput 对应键位(如 's' → session、'a' → always),并在 UserDecision
|
|
298
|
+
// 上新增 scope 字段承载(当前 UserDecision 只有 approved/reason)。
|
|
299
|
+
this.done({ approved: true, reason: "approved via tui" });
|
|
298
300
|
}
|
|
299
301
|
|
|
300
302
|
private deny(): void {
|
|
@@ -60,7 +60,7 @@ function okResult(content: string): CallLLMResult {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
function failResult(error: string, stopReason?: "error" | "aborted"): CallLLMResult {
|
|
63
|
-
return { ok: false, error,
|
|
63
|
+
return { ok: false, error, ...(stopReason !== undefined ? { stopReason } : {}) };
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
/** 构造「永不 resolve」的 mock callLLM(用于 timeout/abort 测试) */
|