@zhushanwen/pi-permission 1.0.0 → 1.2.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 +7 -42
- package/package.json +11 -6
- package/skills/permission-ext-config/SKILL.md +151 -0
- package/src/__tests__/commands.test.ts +10 -3
- package/src/__tests__/config.test.ts +101 -33
- package/src/__tests__/extension.test.ts +22 -2
- package/src/__tests__/index-integration.test.ts +143 -66
- package/src/__tests__/model-picker.test.ts +56 -83
- package/src/__tests__/production.test.ts +188 -23
- package/src/classifier/__tests__/classifier.test.ts +109 -118
- package/src/classifier/__tests__/model-resolver.test.ts +118 -142
- package/src/classifier/classifier.ts +76 -117
- package/src/classifier/index.ts +2 -8
- package/src/classifier/model-resolver.ts +50 -239
- package/src/commands.ts +5 -5
- package/src/config.ts +79 -120
- package/src/footer-provider.ts +20 -18
- package/src/index.ts +107 -100
- package/src/model-picker.ts +12 -6
- package/src/production.ts +49 -57
- package/src/rule-editor.ts +1 -1
- package/src/statusline-palette.ts +4 -5
- package/src/types.ts +11 -2
package/README.md
CHANGED
|
@@ -12,7 +12,6 @@ Pi permission 扩展 — 四档权限模式(yolo / auto / approve / strict)+
|
|
|
12
12
|
- **AI Classifier**:auto 模式下用 LLM 评估未知命令风险(low/medium/high)
|
|
13
13
|
- **用户审批 UI**:TUI(自定义 Component)/ RPC(select 对话框)/ headless(fail-closed deny)
|
|
14
14
|
- **Reject-with-Reason**:用户拒绝时可输入真实理由(回传 agent 辅助理解)
|
|
15
|
-
- **statusline 集成**:TUI 底部 footer 显示当前权限模式标签(通过 globalThis Symbol 握手协议向 statusline 注册一行 footer line renderer)
|
|
16
15
|
- **fail-closed**:任何异常路径 → block(绝不静默放行)
|
|
17
16
|
|
|
18
17
|
## 安装
|
|
@@ -28,7 +27,7 @@ ln -s /path/to/xyz-pi-extensions-workspace/feat-permission-and-auto-mode/extensi
|
|
|
28
27
|
|
|
29
28
|
## 配置
|
|
30
29
|
|
|
31
|
-
|
|
30
|
+
配置文件位置:`<agentDir>/config/permission-ext-config.json`(`<agentDir>` = pi agent 目录,`PI_CODING_AGENT_DIR` 覆盖,默认 `~/.pi/agent`;首次运行自动创建默认配置)。
|
|
32
31
|
|
|
33
32
|
可通过 `PI_CODING_AGENT_DIR` 环境变量覆盖基础路径。
|
|
34
33
|
|
|
@@ -54,7 +53,7 @@ ln -s /path/to/xyz-pi-extensions-workspace/feat-permission-and-auto-mode/extensi
|
|
|
54
53
|
| `mode` | `"yolo"` | 当前权限模式(yolo/auto/approve/strict) |
|
|
55
54
|
| `enabled` | `true` | 扩展是否启用(false=完全放行,等同 yolo 但保留配置) |
|
|
56
55
|
| `classifier.enabled` | `true` | 是否启用 AI 层(auto 模式自动 true) |
|
|
57
|
-
| `classifier.model` | `"auto"` | AI 模型(`auto`
|
|
56
|
+
| `classifier.model` | `"auto"` | AI 模型(`auto` = scoped:取 settings.json enabledModels 首个可用,或 `provider/model-id`) |
|
|
58
57
|
| `classifier.timeout` | `90` | AI 分类超时秒数 |
|
|
59
58
|
| `classifier.autoApproveLowRisk` | `true` | 低风险是否自动放行(false=转人工) |
|
|
60
59
|
| `classifier.autoDenyHighRisk` | `true` | 高风险是否自动拦截(true=强制 deny) |
|
|
@@ -118,7 +117,7 @@ ln -s /path/to/xyz-pi-extensions-workspace/feat-permission-and-auto-mode/extensi
|
|
|
118
117
|
|------|-------------|------|------|------|
|
|
119
118
|
| 内置安全白名单 | `builtin-safe` | 50 无条件 + 9 条件 | 函数实现(`isKnownSafeCommand`),不进 `Rule[]` 数组 | 不可改 |
|
|
120
119
|
| 内置危险规则 | `builtin-danger` | 12 条正则 | `BUILTIN_DANGER_RULES` 常量,代码硬编码 | 不可改 |
|
|
121
|
-
| 用户自定义规则 | `user` | 任意 | `permission-config.json` 的 `userRules` 数组 | 可改 |
|
|
120
|
+
| 用户自定义规则 | `user` | 任意 | `config/permission-ext-config.json` 的 `userRules` 数组 | 可改 |
|
|
122
121
|
|
|
123
122
|
规则层在整个权限管道中的位置(auto / approve 模式):
|
|
124
123
|
|
|
@@ -197,7 +196,7 @@ wc / whereis / who / whoami / which
|
|
|
197
196
|
|
|
198
197
|
### 4. 自定义规则(用户编辑指南)
|
|
199
198
|
|
|
200
|
-
在
|
|
199
|
+
在 `<agentDir>/config/permission-ext-config.json` 的 `userRules` 数组中添加。完整示例:
|
|
201
200
|
|
|
202
201
|
```json
|
|
203
202
|
{
|
|
@@ -342,7 +341,7 @@ publish 规则在后,last-match-wins 时 deny 胜出。
|
|
|
342
341
|
|
|
343
342
|
### 10. 配置文件管理
|
|
344
343
|
|
|
345
|
-
-
|
|
344
|
+
- **路径**:`<agentDir>/config/permission-ext-config.json`(`<agentDir>` 可用 `PI_CODING_AGENT_DIR` 环境变量覆盖,默认 `~/.pi/agent`)
|
|
346
345
|
- **首次创建**:扩展启动时若文件不存在,自动写入默认配置(`mode: "yolo"`、空 `userRules`)
|
|
347
346
|
- **权限**:`0o600`(原子写:先写 `.tmp` 再 rename,避免半写状态)
|
|
348
347
|
- **编辑方式**:目前需手动编辑 JSON 文件(未来计划提供 `/permission add-rule` 命令辅助编辑)
|
|
@@ -360,7 +359,7 @@ publish 规则在后,last-match-wins 时 deny 胜出。
|
|
|
360
359
|
|
|
361
360
|
auto 模式下层 3 用 LLM 评估未知命令风险:
|
|
362
361
|
|
|
363
|
-
- **模型**:`classifier.model`(`auto`
|
|
362
|
+
- **模型**:`classifier.model`(`auto` = scoped:取 enabledModels 首个可用(空则 fallback available),或指定 `provider/model-id`)
|
|
364
363
|
- **输出**:`risk_level`(low/medium/high)+ `outcome`(allow/deny/ask)+ `reasoning` + `confidence`
|
|
365
364
|
- **override**(WT7 偏差补丁):
|
|
366
365
|
- `low + allow + autoApproveLowRisk=false` → 强制 `ask`(转人工)
|
|
@@ -371,7 +370,7 @@ auto 模式下层 3 用 LLM 评估未知命令风险:
|
|
|
371
370
|
|
|
372
371
|
`/permission model` 弹出 overlay 选择 AI classifier 使用的模型,写回 `classifier.model`:
|
|
373
372
|
|
|
374
|
-
- **第一级 provider 选择**:列出 `Auto
|
|
373
|
+
- **第一级 provider 选择**:列出 `Auto`(自动:scoped 取 enabledModels 首个可用)+ 所有可用 provider(来自 `~/.pi/agent/models.json`,按字母序)。当前 `classifier.model` 预选高亮。
|
|
375
374
|
- **第二级 model 选择**:选中具体 provider 后,列出该 provider 下所有可用 model(按 `cost.input` 升序,并列按 id 字母序)。`Esc` 回退到 provider 列表。
|
|
376
375
|
- **键位**:`↑/↓` 导航、`Enter` 确认、`Esc` 取消(provider stage)或回退(model stage)。
|
|
377
376
|
- **三模式分发**:
|
|
@@ -381,42 +380,8 @@ auto 模式下层 3 用 LLM 评估未知命令风险:
|
|
|
381
380
|
- **无可用模型**:`models.json` 不存在 / 无 provider 配 `apiKey` / 解析失败时,`listAvailableModels` 返回空 Map,命令降级为提示 `No available models. Configure ~/.pi/agent/models.json first.`(不阻塞,不修改配置)。
|
|
382
381
|
- **结果写回**:选中后 `classifier.model` 更新为 `auto` 或 `provider/model-id`,其余字段(mode/enabled/timeout/userRules)保留。
|
|
383
382
|
|
|
384
|
-
## statusline 集成
|
|
385
|
-
|
|
386
|
-
permission 不再自管 footer,而是通过 **globalThis Symbol 握手协议** 向 `@zhushanwen/pi-statusline` 注册一行 footer line renderer(`session_start` 时调用 `registerPermissionFooterLine`):
|
|
387
|
-
|
|
388
|
-
- **statusline 是 footer canonical owner**:唯一创建 footer registry(`getOrCreateFooterRegistry`)。
|
|
389
|
-
- **permission 是 consumer**:永不创建 registry 实例,永不写 `slot.registry` 字段;只通过 `FOOTER_HANDSHAKE_KEY = Symbol.for('@zhushanwen/pi-statusline.footerHandshake')` 读写 slot。
|
|
390
|
-
- **插入位置**:renderer `order=2`,聚合排序后落在 line2(model)和 line3(ctx)之间。
|
|
391
|
-
- **加载顺序无关**:slot 形状 `{version, registry?, pending:[]}`。registry 未就绪时 consumer 只 push pending,等 statusline 后到时 flush(沿用 ask-user 的 pending-flush 修复模式)。
|
|
392
|
-
- **无代码层 import**:permission 仅对 statusline 做运行时 `globalThis` 反射,statusline 是可选 `peerDep`,未安装时静态 import 不会破坏 permission。
|
|
393
|
-
- **重绘触发**:mode/enabled 切换后调用 `requestFooterRender()`(`REQUEST_RENDER_KEY`),statusline 立即重绘 footer。
|
|
394
|
-
|
|
395
|
-
footer line 内容(精简版,避免 footer 拥挤):
|
|
396
|
-
|
|
397
|
-
```
|
|
398
|
-
[permission] Auto · enabled
|
|
399
|
-
[permission] disabled
|
|
400
|
-
```
|
|
401
|
-
|
|
402
|
-
模式标签:YOLO / Auto / Approve / Strict(对应 yolo/auto/approve/strict)。
|
|
403
|
-
|
|
404
|
-
**未安装 statusline 时 silent 降级**:permission 功能完整,仅 footer 不显示 mode 标签。可用 `/permission status` 查看 mode。
|
|
405
|
-
|
|
406
|
-
## 升级须知
|
|
407
|
-
|
|
408
|
-
**v0.0.1 → v0.1.0 breaking change**:本扩展不再自管 footer,权限模式标签现在由 `@zhushanwen/pi-statusline` 聚合提供(通过 globalThis Symbol 握手协议)。此前 footer 由各扩展各自调用 `ctx.ui.setFooter` 单例渲染、互相覆盖。
|
|
409
|
-
|
|
410
|
-
- **同时安装 statusline 的用户**:无需任何操作。mode 标签自动作为 statusline footer 的一行出现(line2 和 line3 之间)。
|
|
411
|
-
- **未安装 statusline 的用户**:footer 不再显示 mode 标签。两种恢复方式:
|
|
412
|
-
- 安装 statusline:`pi install npm:@zhushanwen/pi-statusline`,mode 标签回到 footer。
|
|
413
|
-
- 或随时用 `/permission status` 查看/切换 mode(不依赖 footer)。
|
|
414
|
-
|
|
415
|
-
详见下方「statusline 集成」章节。
|
|
416
|
-
|
|
417
383
|
## 已知限制
|
|
418
384
|
|
|
419
|
-
- **Footer 由 statusline 聚合**:本扩展不再自管 footer,mode 标签通过 globalThis Symbol 握手协议向 `@zhushanwen/pi-statusline` 注册(见「statusline 集成」)。未安装 statusline 时 footer 不显示 mode 标签(silent 降级,不影响功能)。
|
|
420
385
|
- **TUI Reject-with-Reason**:当前 RPC 分支已完整接入 `ctx.ui.input` 采集拒绝理由;TUI 分支因 pi-tui Input 组件集成成本较高,暂保留简化 deny(固定文案),后续迭代补齐内联文本输入。
|
|
421
386
|
- **headless 模式**:json/print 模式无交互 UI,所有审批请求 fail-closed deny(不阻塞自动化流程,但 strict/approve 模式下无法放行)。
|
|
422
387
|
- **wasm 加载**:AST 分析依赖 tree-sitter-bash wasm,加载失败时 fail-closed(clean=false, parseError=true)。
|
package/package.json
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-permission",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.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",
|
|
7
7
|
"pi": {
|
|
8
8
|
"extensions": [
|
|
9
9
|
"./index.ts"
|
|
10
|
+
],
|
|
11
|
+
"skills": [
|
|
12
|
+
"./skills"
|
|
10
13
|
]
|
|
11
14
|
},
|
|
12
15
|
"keywords": [
|
|
@@ -20,31 +23,33 @@
|
|
|
20
23
|
"license": "MIT",
|
|
21
24
|
"files": [
|
|
22
25
|
"src/",
|
|
26
|
+
"skills/",
|
|
23
27
|
"index.ts"
|
|
24
28
|
],
|
|
25
29
|
"dependencies": {
|
|
26
30
|
"tree-sitter-bash": "^0.25.1",
|
|
27
|
-
"web-tree-sitter": "^0.26.11"
|
|
31
|
+
"web-tree-sitter": "^0.26.11",
|
|
32
|
+
"@zhushanwen/pi-llm-shared": "0.3.0"
|
|
28
33
|
},
|
|
29
34
|
"peerDependencies": {
|
|
30
35
|
"@earendil-works/pi-ai": "*",
|
|
31
36
|
"@earendil-works/pi-coding-agent": "*",
|
|
32
|
-
"@earendil-works/pi-tui": "*"
|
|
33
|
-
"@zhushanwen/pi-statusline": "*"
|
|
37
|
+
"@earendil-works/pi-tui": "*"
|
|
34
38
|
},
|
|
35
39
|
"peerDependenciesMeta": {
|
|
36
40
|
"@earendil-works/pi-ai": {
|
|
37
41
|
"optional": true
|
|
38
42
|
},
|
|
39
|
-
"@earendil-works/pi-
|
|
43
|
+
"@earendil-works/pi-coding-agent": {
|
|
40
44
|
"optional": true
|
|
41
45
|
},
|
|
42
|
-
"@
|
|
46
|
+
"@earendil-works/pi-tui": {
|
|
43
47
|
"optional": true
|
|
44
48
|
}
|
|
45
49
|
},
|
|
46
50
|
"devDependencies": {
|
|
47
51
|
"@earendil-works/pi-ai": "*",
|
|
52
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
48
53
|
"@earendil-works/pi-tui": "*",
|
|
49
54
|
"vitest": "^4.1.8"
|
|
50
55
|
},
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: permission-ext-config
|
|
3
|
+
description: "配置 @zhushanwen/pi-permission(四档权限模式 + AI 风险分类)时加载。含配置文件路径、PermissionConfig/ClassifierConfig schema、权限模式、classifier model、默认值、示例、userRules、enabled 反直觉行为、热重载。触发词:配置权限、permission 配置、权限模式、classifier model、permission.json、permission 配置文件、auto/approve/strict/yolo 模式、热重载、enabled。"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# permission 配置指南
|
|
7
|
+
|
|
8
|
+
> @zhushanwen/pi-permission:四档权限模式(yolo/auto/approve/strict)+ 三层管道(AST 分析 + 规则匹配 + AI 风险分类)。
|
|
9
|
+
|
|
10
|
+
## ⚠ 先看:`enabled` 字段的反直觉行为
|
|
11
|
+
|
|
12
|
+
**`enabled: false` 不是「锁定/安全模式」,而是「扩展完全不介入」= 全部放行(等同 yolo,仅保留配置文件)。**
|
|
13
|
+
|
|
14
|
+
想提高安全性,改 `mode`(`auto`/`approve`/`strict`),**不要**把 `enabled` 设 false。把 `enabled` 设 false 是最危险的误配置——用户以为关掉了扩展等于安全锁死,实际等于完全无防护。(源码:`pipeline.ts`「config.enabled=false 等同 yolo」)
|
|
15
|
+
|
|
16
|
+
## 配置文件位置
|
|
17
|
+
|
|
18
|
+
`<agentDir>/config/permission-ext-config.json`
|
|
19
|
+
|
|
20
|
+
- `<agentDir>` = pi agent 目录(`PI_CODING_AGENT_DIR` 覆盖,默认 `~/.pi/agent`;xyz-agent 隔离环境为 `~/.xyz-agent/pi/agent`)
|
|
21
|
+
- 走 llm-shared 泛型 config(config/ 子目录 + getAgentDir 派生 + mtime+size 缓存 + 原子写)
|
|
22
|
+
- 文件缺失/坏 JSON 返回默认值,不抛错;首次加载自动创建默认配置文件
|
|
23
|
+
- [HISTORICAL] 旧路径 `<agentDir>/permission-config.json`(agentDir 根):**session_start hook 运行时迁移**到新路径(幂等,过渡性,Added in v1.0.0, remove after v2.0.0)
|
|
24
|
+
|
|
25
|
+
## 热重载:改文件即生效
|
|
26
|
+
|
|
27
|
+
配置走 **mtime+size 读时刷新(pull-based)**:每次权限检查(每个 tool_call)都 stat 文件,变了就读新配置,**无需重启 session**。
|
|
28
|
+
|
|
29
|
+
- 在编辑器改 JSON 保存 → 下一次工具调用立即按新配置拦截
|
|
30
|
+
- `/permission` 命令执行时也会读最新
|
|
31
|
+
- 不要担心性能:文件没变时只做一次 `statSync`(metadata 读,不读文件内容),开销可忽略
|
|
32
|
+
|
|
33
|
+
> 这是 llm-shared config 框架统一提供的热重载能力。早期版本曾用闭包缓存架空了它(同一 session 改文件不生效),已修复——现在每次 tool_call 直接读最新。
|
|
34
|
+
|
|
35
|
+
## Schema
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
interface PermissionConfig {
|
|
39
|
+
mode: "yolo" | "auto" | "approve" | "strict"; // 权限模式,默认 "yolo"
|
|
40
|
+
enabled: boolean; // 扩展总开关(false=全部放行,见上方警告),默认 true
|
|
41
|
+
classifier: ClassifierConfig; // AI 分类器配置(仅 auto 模式生效,见下)
|
|
42
|
+
userRules: Rule[]; // 用户自定义规则
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface ClassifierConfig {
|
|
46
|
+
enabled: boolean; // AI 层开关,默认 true
|
|
47
|
+
model: string; // 'auto' 或 'provider/model-id',默认 "auto"
|
|
48
|
+
timeout: number; // 超时秒,默认 90
|
|
49
|
+
autoApproveLowRisk: boolean; // 低风险自动放行,默认 true
|
|
50
|
+
autoDenyHighRisk: boolean; // 高风险转人工审批,默认 true
|
|
51
|
+
thinkingLevel: ModelThinkingLevel; // thinking 级别,默认 "off"(直接透传,llm-shared 映射为不传);"minimal"~"max" 透传
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### 权限模式
|
|
56
|
+
|
|
57
|
+
| 模式 | 行为 | classifier 是否介入 |
|
|
58
|
+
|---|---|---|
|
|
59
|
+
| yolo | 完全无防护,全部放行 | 否 |
|
|
60
|
+
| auto | 安全命令规则直通 + 非安全过 AI 审查 | **是(仅此模式)** |
|
|
61
|
+
| approve | 规则直通安全 + 非安全直接人工审批(无 AI) | 否 |
|
|
62
|
+
| strict | 全部审批 | 否 |
|
|
63
|
+
|
|
64
|
+
> `classifier.*` 配置**仅在 `mode: "auto"` 下生效**。在 approve/strict/yolo 模式下调 classifier 字段(timeout/model 等)无任何效果。
|
|
65
|
+
|
|
66
|
+
### classifier.model(只接受 string)
|
|
67
|
+
|
|
68
|
+
- `"auto"`(默认)→ permission 本地取 `ctx.modelRegistry.getAvailable()` 首个(不经过 llm-shared 的非精确 selector)
|
|
69
|
+
- `"provider/model-id"`(如 `"zhipu/glm-4-flash"`)→ 精确匹配(llm-shared `ModelSelector` 仅支持 ref)
|
|
70
|
+
- 传对象形式会被 console.warn 忽略,回落 auto
|
|
71
|
+
|
|
72
|
+
### auto 模式的风险判定(autoApproveLowRisk / autoDenyHighRisk)
|
|
73
|
+
|
|
74
|
+
AI classifier 输出三档风险,对应处理:
|
|
75
|
+
|
|
76
|
+
| 风险 | autoApproveLowRisk=true | autoDenyHighRisk=true | 实际动作 |
|
|
77
|
+
|---|---|---|---|
|
|
78
|
+
| low | ✓ 自动放行 | — | allow |
|
|
79
|
+
| **medium** | — | — | **ask(转人工审批)** |
|
|
80
|
+
| high | — | ✓ 转人工审批 | ask |
|
|
81
|
+
|
|
82
|
+
> medium 风险**总是转人工审批**,不受这两个开关影响。两个开关只拨动 low/high 的边界。
|
|
83
|
+
|
|
84
|
+
## 默认值
|
|
85
|
+
|
|
86
|
+
```json
|
|
87
|
+
{
|
|
88
|
+
"mode": "yolo",
|
|
89
|
+
"enabled": true,
|
|
90
|
+
"classifier": {
|
|
91
|
+
"enabled": true,
|
|
92
|
+
"model": "auto",
|
|
93
|
+
"timeout": 90,
|
|
94
|
+
"autoApproveLowRisk": true,
|
|
95
|
+
"autoDenyHighRisk": true,
|
|
96
|
+
"thinkingLevel": "off"
|
|
97
|
+
},
|
|
98
|
+
"userRules": []
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
> ⚠ 默认 `mode: "yolo"` = 安装后默认**无任何防护**。生产/敏感环境务必改为 `auto` 或 `strict`。
|
|
103
|
+
|
|
104
|
+
## 验证配置已生效
|
|
105
|
+
|
|
106
|
+
改完配置文件后,用 `/permission` 命令确认:
|
|
107
|
+
|
|
108
|
+
- `/permission` — 显示当前模式 + 可用模式
|
|
109
|
+
- `/permission status` — 显示详细配置(mode/classifier/userRules 全貌)
|
|
110
|
+
- `/permission <mode>` — 切换模式(yolo/auto/approve/strict)
|
|
111
|
+
|
|
112
|
+
`/permission status` 的输出应与你改后的 JSON 一致;不一致说明文件没保存成功或 JSON 语法错误(此时会回落默认值并 console.warn)。
|
|
113
|
+
|
|
114
|
+
## 配置示例
|
|
115
|
+
|
|
116
|
+
auto 模式 + 指定 classifier 用 glm-4-flash + 自定义危险规则:
|
|
117
|
+
```json
|
|
118
|
+
{
|
|
119
|
+
"mode": "auto",
|
|
120
|
+
"enabled": true,
|
|
121
|
+
"classifier": {
|
|
122
|
+
"enabled": true,
|
|
123
|
+
"model": "zhipu/glm-4-flash",
|
|
124
|
+
"timeout": 60
|
|
125
|
+
},
|
|
126
|
+
"userRules": [
|
|
127
|
+
{
|
|
128
|
+
"tool": "bash",
|
|
129
|
+
"pattern": "rm -rf *",
|
|
130
|
+
"action": "deny",
|
|
131
|
+
"source": "user",
|
|
132
|
+
"description": "禁止递归删除当前目录"
|
|
133
|
+
}
|
|
134
|
+
]
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## userRules 字段
|
|
139
|
+
|
|
140
|
+
每条规则:`{ id?, tool, pattern, action, source, description? }`
|
|
141
|
+
- tool:工具名(`*` 匹配全部,`bash` 精确)
|
|
142
|
+
- pattern:命令模式(wildcard,仅 bash 用)
|
|
143
|
+
- action:`allow` | `deny` | `ask`
|
|
144
|
+
- source:`user`(用户规则;内置规则 builtin-safe/builtin-danger 在代码里不进配置)
|
|
145
|
+
- id 省略时自动分配 `user-<n>`
|
|
146
|
+
|
|
147
|
+
## classifier LLM 调用
|
|
148
|
+
|
|
149
|
+
- 走 llm-shared callLLM(凭证 getApiKeyAndHeaders,含 OAuth/env/auth.json 三源合并)
|
|
150
|
+
- model 不可用 / 调用失败 / 超时 → fail-closed 降级为 `ask`(转人工审批)
|
|
151
|
+
- classifier 的 stopReason=error/aborted 同样降级 ask
|
|
@@ -217,21 +217,28 @@ function makeModelEntry(provider: string, id: string, inputCost: number): Resolv
|
|
|
217
217
|
name: id,
|
|
218
218
|
api: "openai-completions",
|
|
219
219
|
cost: { input: inputCost, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
220
|
-
hasApiKey: true,
|
|
221
220
|
};
|
|
222
221
|
}
|
|
223
222
|
|
|
224
|
-
/** 构造 mock ctx(mode + ui.notify/custom/select)。 */
|
|
223
|
+
/** 构造 mock ctx(mode + modelRegistry + ui.notify/custom/select)。 */
|
|
225
224
|
function makeModelPickerCtx(overrides: Partial<ModelPickerContext> = {}): ModelPickerContext {
|
|
226
225
|
const base: ModelPickerContext = {
|
|
227
226
|
mode: "rpc",
|
|
227
|
+
modelRegistry: {
|
|
228
|
+
getAll: vi.fn(() => []),
|
|
229
|
+
hasConfiguredAuth: vi.fn(() => false),
|
|
230
|
+
},
|
|
228
231
|
ui: {
|
|
229
232
|
notify: vi.fn(),
|
|
230
233
|
select: vi.fn(() => Promise.resolve("Auto")),
|
|
231
234
|
custom: vi.fn(() => Promise.resolve(undefined)),
|
|
232
235
|
},
|
|
233
236
|
};
|
|
234
|
-
return {
|
|
237
|
+
return {
|
|
238
|
+
mode: overrides.mode ?? base.mode,
|
|
239
|
+
modelRegistry: overrides.modelRegistry ?? base.modelRegistry,
|
|
240
|
+
ui: { ...base.ui, ...overrides.ui },
|
|
241
|
+
};
|
|
235
242
|
}
|
|
236
243
|
|
|
237
244
|
/** 构造 mock deps(listModels + save)。 */
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* WT1-WT5/WT7: 配置加载/保存/mtime
|
|
2
|
+
* WT1-WT5/WT7: 配置加载/保存/mtime 缓存测试(委托 llm-shared 泛型 config)
|
|
3
3
|
*
|
|
4
|
-
* 用真实 fs + 临时目录(os.tmpdir +
|
|
4
|
+
* 用真实 fs + 临时目录(os.tmpdir + 随机子目录),PI_CODING_AGENT_DIR 指向临时目录隔离,
|
|
5
|
+
* 配置文件路径 = <temp>/config/permission-ext-config.json(llm-shared getConfigPath 推导)。
|
|
5
6
|
*/
|
|
6
7
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
|
|
7
8
|
import { tmpdir } from "node:os";
|
|
8
9
|
import { join } from "node:path";
|
|
9
|
-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
10
11
|
|
|
11
12
|
import { DEFAULT_CONFIG } from "../types.js";
|
|
12
13
|
import {
|
|
@@ -17,23 +18,27 @@ import {
|
|
|
17
18
|
} from "../config.js";
|
|
18
19
|
|
|
19
20
|
let tempDir: string;
|
|
21
|
+
let configPath: string;
|
|
20
22
|
|
|
21
23
|
beforeEach(() => {
|
|
22
24
|
tempDir = mkdtempSync(join(tmpdir(), "pi-perm-test-"));
|
|
25
|
+
process.env.PI_CODING_AGENT_DIR = tempDir;
|
|
26
|
+
mkdirSync(join(tempDir, "config"), { recursive: true });
|
|
27
|
+
configPath = join(tempDir, "config", "permission-ext-config.json");
|
|
23
28
|
clearConfigCache();
|
|
24
29
|
});
|
|
25
30
|
|
|
26
31
|
afterEach(() => {
|
|
32
|
+
delete process.env.PI_CODING_AGENT_DIR;
|
|
27
33
|
rmSync(tempDir, { recursive: true, force: true });
|
|
28
34
|
clearConfigCache();
|
|
29
35
|
});
|
|
30
36
|
|
|
31
37
|
describe("WT1: 默认配置生成(首次无配置文件)", () => {
|
|
32
38
|
it("文件不存在时创建默认配置并返回", () => {
|
|
33
|
-
const configPath = join(tempDir, "permission-config.json");
|
|
34
39
|
expect(existsSync(configPath)).toBe(false);
|
|
35
40
|
|
|
36
|
-
const config = loadAndWatchConfig(
|
|
41
|
+
const config = loadAndWatchConfig();
|
|
37
42
|
|
|
38
43
|
expect(config.mode).toBe("yolo");
|
|
39
44
|
expect(config.enabled).toBe(true);
|
|
@@ -49,8 +54,7 @@ describe("WT1: 默认配置生成(首次无配置文件)", () => {
|
|
|
49
54
|
});
|
|
50
55
|
|
|
51
56
|
it("创建的文件是合法 JSON", () => {
|
|
52
|
-
|
|
53
|
-
loadAndWatchConfig(configPath);
|
|
57
|
+
loadAndWatchConfig();
|
|
54
58
|
|
|
55
59
|
const content = readFileSync(configPath, "utf-8");
|
|
56
60
|
expect(() => JSON.parse(content)).not.toThrow();
|
|
@@ -59,7 +63,6 @@ describe("WT1: 默认配置生成(首次无配置文件)", () => {
|
|
|
59
63
|
|
|
60
64
|
describe("WT2: 配置解析(合法配置)", () => {
|
|
61
65
|
it("正确解析完整配置", () => {
|
|
62
|
-
const configPath = join(tempDir, "permission-config.json");
|
|
63
66
|
const rawConfig = {
|
|
64
67
|
mode: "auto",
|
|
65
68
|
enabled: true,
|
|
@@ -77,7 +80,7 @@ describe("WT2: 配置解析(合法配置)", () => {
|
|
|
77
80
|
};
|
|
78
81
|
writeFileSync(configPath, JSON.stringify(rawConfig), "utf-8");
|
|
79
82
|
|
|
80
|
-
const config = loadAndWatchConfig(
|
|
83
|
+
const config = loadAndWatchConfig();
|
|
81
84
|
|
|
82
85
|
expect(config.mode).toBe("auto");
|
|
83
86
|
expect(config.classifier.enabled).toBe(false);
|
|
@@ -93,11 +96,10 @@ describe("WT2: 配置解析(合法配置)", () => {
|
|
|
93
96
|
|
|
94
97
|
describe("WT3: 配置解析容错(malformed JSON)", () => {
|
|
95
98
|
it("malformed JSON fallback 到默认配置,不 throw", () => {
|
|
96
|
-
const configPath = join(tempDir, "permission-config.json");
|
|
97
99
|
writeFileSync(configPath, "{ invalid json missing quotes:", "utf-8");
|
|
98
100
|
|
|
99
101
|
const warnings: string[] = [];
|
|
100
|
-
const config = loadAndWatchConfig(
|
|
102
|
+
const config = loadAndWatchConfig((msg) => warnings.push(msg));
|
|
101
103
|
|
|
102
104
|
expect(config.mode).toBe("yolo"); // 默认值
|
|
103
105
|
expect(config.enabled).toBe(true);
|
|
@@ -106,25 +108,64 @@ describe("WT3: 配置解析容错(malformed JSON)", () => {
|
|
|
106
108
|
});
|
|
107
109
|
|
|
108
110
|
it("mode 字段非法时 fallback 到 yolo", () => {
|
|
109
|
-
const configPath = join(tempDir, "permission-config.json");
|
|
110
111
|
writeFileSync(configPath, JSON.stringify({ mode: "unknown-mode" }), "utf-8");
|
|
111
112
|
|
|
112
|
-
const config = loadAndWatchConfig(
|
|
113
|
+
const config = loadAndWatchConfig();
|
|
113
114
|
expect(config.mode).toBe("yolo");
|
|
114
115
|
});
|
|
115
116
|
|
|
116
117
|
it("classifier 字段缺失时用默认值", () => {
|
|
117
|
-
const configPath = join(tempDir, "permission-config.json");
|
|
118
118
|
writeFileSync(configPath, JSON.stringify({ mode: "strict" }), "utf-8");
|
|
119
119
|
|
|
120
|
-
const config = loadAndWatchConfig(
|
|
120
|
+
const config = loadAndWatchConfig();
|
|
121
121
|
expect(config.mode).toBe("strict");
|
|
122
122
|
expect(config.classifier.model).toBe("auto"); // 默认
|
|
123
123
|
expect(config.classifier.timeout).toBe(90); // 默认
|
|
124
124
|
});
|
|
125
125
|
|
|
126
|
+
it("TC6 (C3b): classifier.model 传对象形式 → console.warn + 回落默认 auto", () => {
|
|
127
|
+
// 用户照旧设计文档配对象形式 selector(不受支持)
|
|
128
|
+
writeFileSync(configPath, JSON.stringify({ classifier: { model: { type: "available" } } }), "utf-8");
|
|
129
|
+
|
|
130
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
131
|
+
try {
|
|
132
|
+
const config = loadAndWatchConfig();
|
|
133
|
+
// 回落默认 auto(不再静默)
|
|
134
|
+
expect(config.classifier.model).toBe("auto");
|
|
135
|
+
// warn 提示忽略无效 classifier.model
|
|
136
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Ignoring invalid classifier.model"));
|
|
137
|
+
} finally {
|
|
138
|
+
warnSpy.mockRestore();
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("classifier.model 传数字 → console.warn + 回落默认 auto", () => {
|
|
143
|
+
writeFileSync(configPath, JSON.stringify({ classifier: { model: 42 } }), "utf-8");
|
|
144
|
+
|
|
145
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
146
|
+
try {
|
|
147
|
+
const config = loadAndWatchConfig();
|
|
148
|
+
expect(config.classifier.model).toBe("auto");
|
|
149
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Ignoring invalid classifier.model"));
|
|
150
|
+
} finally {
|
|
151
|
+
warnSpy.mockRestore();
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("classifier.model 合法 string('auto' / 'provider/model-id')→ 不 warn", () => {
|
|
156
|
+
writeFileSync(configPath, JSON.stringify({ classifier: { model: "zhipu/glm-4-flash" } }), "utf-8");
|
|
157
|
+
|
|
158
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
159
|
+
try {
|
|
160
|
+
const config = loadAndWatchConfig();
|
|
161
|
+
expect(config.classifier.model).toBe("zhipu/glm-4-flash");
|
|
162
|
+
expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining("Ignoring invalid classifier.model"));
|
|
163
|
+
} finally {
|
|
164
|
+
warnSpy.mockRestore();
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
126
168
|
it("userRules 含非法条目时过滤掉", () => {
|
|
127
|
-
const configPath = join(tempDir, "permission-config.json");
|
|
128
169
|
writeFileSync(configPath, JSON.stringify({
|
|
129
170
|
mode: "yolo",
|
|
130
171
|
userRules: [
|
|
@@ -135,7 +176,7 @@ describe("WT3: 配置解析容错(malformed JSON)", () => {
|
|
|
135
176
|
],
|
|
136
177
|
}), "utf-8");
|
|
137
178
|
|
|
138
|
-
const config = loadAndWatchConfig(
|
|
179
|
+
const config = loadAndWatchConfig();
|
|
139
180
|
expect(config.userRules).toHaveLength(1);
|
|
140
181
|
expect(config.userRules[0].pattern).toBe("ls");
|
|
141
182
|
});
|
|
@@ -143,12 +184,11 @@ describe("WT3: 配置解析容错(malformed JSON)", () => {
|
|
|
143
184
|
|
|
144
185
|
describe("WT4: mtime 缓存(文件未变化时不重读)", () => {
|
|
145
186
|
it("连续两次调用,第二次返回缓存(同 mtime)", () => {
|
|
146
|
-
const
|
|
147
|
-
const original = loadAndWatchConfig(configPath);
|
|
187
|
+
const original = loadAndWatchConfig();
|
|
148
188
|
const originalMode = original.mode;
|
|
149
189
|
|
|
150
190
|
// 不修改文件,第二次调用
|
|
151
|
-
const cached = loadAndWatchConfig(
|
|
191
|
+
const cached = loadAndWatchConfig();
|
|
152
192
|
|
|
153
193
|
// 返回的 config 应该与第一次一致(深相等)
|
|
154
194
|
expect(cached).toEqual(original);
|
|
@@ -158,8 +198,7 @@ describe("WT4: mtime 缓存(文件未变化时不重读)", () => {
|
|
|
158
198
|
|
|
159
199
|
describe("WT5: mtime 缓存(文件变化时重读)", () => {
|
|
160
200
|
it("修改文件后第二次调用返回新内容", () => {
|
|
161
|
-
const
|
|
162
|
-
const first = loadAndWatchConfig(configPath);
|
|
201
|
+
const first = loadAndWatchConfig();
|
|
163
202
|
expect(first.mode).toBe("yolo");
|
|
164
203
|
|
|
165
204
|
// 修改文件(写入新 mode)
|
|
@@ -170,17 +209,16 @@ describe("WT5: mtime 缓存(文件变化时重读)", () => {
|
|
|
170
209
|
const future = new Date(Date.now() + 2000);
|
|
171
210
|
utimesSync(configPath, future, future);
|
|
172
211
|
|
|
173
|
-
const second = loadAndWatchConfig(
|
|
212
|
+
const second = loadAndWatchConfig();
|
|
174
213
|
expect(second.mode).toBe("strict");
|
|
175
214
|
});
|
|
176
215
|
});
|
|
177
216
|
|
|
178
217
|
describe("WT7: 保存配置", () => {
|
|
179
218
|
it("saveConfig 写入文件并更新缓存", () => {
|
|
180
|
-
const configPath = join(tempDir, "permission-config.json");
|
|
181
219
|
const newConfig = { ...DEFAULT_CONFIG, mode: "strict" as const };
|
|
182
220
|
|
|
183
|
-
const result = saveConfig(newConfig
|
|
221
|
+
const result = saveConfig(newConfig);
|
|
184
222
|
expect(result.success).toBe(true);
|
|
185
223
|
|
|
186
224
|
// 文件被写入
|
|
@@ -190,16 +228,18 @@ describe("WT7: 保存配置", () => {
|
|
|
190
228
|
expect(parsed.mode).toBe("strict");
|
|
191
229
|
|
|
192
230
|
// 后续 loadAndWatchConfig 返回新配置(缓存已更新)
|
|
193
|
-
const loaded = loadAndWatchConfig(
|
|
231
|
+
const loaded = loadAndWatchConfig();
|
|
194
232
|
expect(loaded.mode).toBe("strict");
|
|
195
233
|
});
|
|
196
234
|
|
|
197
235
|
it("saveConfig 失败时返回 error(只读目录)", () => {
|
|
198
236
|
const readOnlyDir = join(tempDir, "readonly");
|
|
199
237
|
mkdirSync(readOnlyDir, { mode: 0o500 });
|
|
200
|
-
|
|
238
|
+
// 直接把 agentDir 指向只读目录(config/ 建不出来 / tmp 写不进)
|
|
239
|
+
process.env.PI_CODING_AGENT_DIR = readOnlyDir;
|
|
240
|
+
clearConfigCache();
|
|
201
241
|
|
|
202
|
-
const result = saveConfig(DEFAULT_CONFIG
|
|
242
|
+
const result = saveConfig(DEFAULT_CONFIG);
|
|
203
243
|
// 可能成功也可能失败取决于系统权限实现,主要验证不 throw
|
|
204
244
|
expect(typeof result.success).toBe("boolean");
|
|
205
245
|
if (!result.success) {
|
|
@@ -208,8 +248,7 @@ describe("WT7: 保存配置", () => {
|
|
|
208
248
|
});
|
|
209
249
|
|
|
210
250
|
it("saveConfig 后文件权限是 0o600(仅用户可读写)", () => {
|
|
211
|
-
|
|
212
|
-
saveConfig(DEFAULT_CONFIG, configPath);
|
|
251
|
+
saveConfig(DEFAULT_CONFIG);
|
|
213
252
|
|
|
214
253
|
const stat = statSync(configPath);
|
|
215
254
|
// macOS/Linux 下 mode & 0o777 应该是 0o600
|
|
@@ -221,9 +260,38 @@ describe("WT7: 保存配置", () => {
|
|
|
221
260
|
});
|
|
222
261
|
|
|
223
262
|
describe("getConfigPath", () => {
|
|
224
|
-
it("返回
|
|
263
|
+
it("返回 <agentDir>/config/permission-ext-config.json 路径", () => {
|
|
225
264
|
const path = getConfigPath();
|
|
226
|
-
expect(path).toContain("
|
|
227
|
-
expect(path).toContain(".
|
|
265
|
+
expect(path).toContain("config");
|
|
266
|
+
expect(path).toContain("permission-ext-config.json");
|
|
267
|
+
// 受 PI_CODING_AGENT_DIR 隔离(本测试 beforeEach 已指向临时目录)
|
|
268
|
+
expect(path.startsWith(tempDir)).toBe(true);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe("[MIGRATION] legacy config 降级告警(ensureConfigFile 兜底)", () => {
|
|
273
|
+
it("旧路径残留 + 新路径缺失 → console.warn 提醒 strict→yolo 降级", () => {
|
|
274
|
+
// 写 legacy 文件到 agentDir 根(PI_CODING_AGENT_DIR=tempDir)
|
|
275
|
+
const legacyPath = join(tempDir, "permission-config.json");
|
|
276
|
+
writeFileSync(legacyPath, JSON.stringify({ mode: "strict" }), "utf-8");
|
|
277
|
+
|
|
278
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
279
|
+
try {
|
|
280
|
+
loadAndWatchConfig();
|
|
281
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Legacy config detected"));
|
|
282
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("downgrade"));
|
|
283
|
+
} finally {
|
|
284
|
+
warnSpy.mockRestore();
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("旧路径不存在 → 不触发 legacy 告警", () => {
|
|
289
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
290
|
+
try {
|
|
291
|
+
loadAndWatchConfig();
|
|
292
|
+
expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining("Legacy config detected"));
|
|
293
|
+
} finally {
|
|
294
|
+
warnSpy.mockRestore();
|
|
295
|
+
}
|
|
228
296
|
});
|
|
229
297
|
});
|