@zhushanwen/pi-permission 1.6.3 → 1.6.5
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 +1 -1
- package/package.json +2 -2
- package/src/__tests__/approval.test.ts +1 -1
- package/src/__tests__/footer-provider.test.ts +1 -1
- package/src/__tests__/index-integration.test.ts +11 -11
- package/src/__tests__/legacy-tool-names.test.ts +37 -2
- package/src/__tests__/model-picker.test.ts +1 -1
- package/src/__tests__/production.test.ts +7 -7
- package/src/__tests__/rule-editor-component.test.ts +4 -4
- package/src/approval.ts +4 -4
- package/src/ast/__tests__/analyzer.test.ts +1 -1
- package/src/ast/analyzer.ts +1 -1
- package/src/classifier/__tests__/classifier.test.ts +1 -1
- package/src/classifier/__tests__/model-resolver.test.ts +1 -1
- package/src/classifier/classifier.ts +3 -3
- package/src/classifier/model-resolver.ts +3 -3
- package/src/commands.ts +2 -2
- package/src/config.ts +1 -1
- package/src/footer-provider.ts +2 -2
- package/src/index.ts +2 -2
- package/src/model-picker.ts +2 -2
- package/src/production.ts +3 -3
- package/src/rule-editor-component.ts +2 -2
- package/src/rules/__tests__/matcher.test.ts +2 -2
- package/src/rules/index.ts +1 -1
- package/src/rules/legacy-tool-names.ts +2 -0
- package/src/rules/matcher.ts +2 -2
- package/src/types.ts +1 -1
package/README.md
CHANGED
|
@@ -354,7 +354,7 @@ publish 规则在后,last-match-wins 时 deny 胜出。
|
|
|
354
354
|
- 查看决策来源:`PermissionDecision.source`(`mode` / `rule` / `ai` / `user`),区分是模式直接放行、规则命中、AI 分类还是人工审批(AST 分析不直接产生决策,只改变后续评估路径)
|
|
355
355
|
- 查看命中规则:`PermissionDecision.matchedRule`(命中时携带 Rule 对象),从 `id` 可判断是 `builtin-safe`(白名单虚拟规则)、`bd-<n>`(内置危险)还是 `user-<n>`(用户规则)
|
|
356
356
|
- 故意写一条 `deny` + 带 `description` 的用户规则触发拦截,从 tool_result 的 block reason 文案反查命中的是哪条规则
|
|
357
|
-
- 规则 pattern 编译失败不会静默跳过:`resolvePattern` 无容错,非法 pattern 抛出的异常由 tool_call handler 兜底 fail-closed block
|
|
357
|
+
- 规则 pattern 编译失败不会静默跳过:`resolvePattern` 无容错,非法 pattern 抛出的异常由 tool_call handler 兜底 fail-closed block(该次调用被拦)。实际触发范围极窄——`source` 非 `user`/`builtin-safe`/`builtin-danger` 时归一化为 `user`,走 wildcard 编译(特殊字符自动转义,几乎不会失败);只有手写配置把 `source` 写成 `builtin-danger` 且 pattern 恰为非法正则时才会抛异常。怀疑某条规则没生效时先检查 JSON 语法与 wildcard 写法
|
|
358
358
|
|
|
359
359
|
## AI Classifier
|
|
360
360
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-permission",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.5",
|
|
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",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"web-tree-sitter": "^0.26.11",
|
|
53
53
|
"@zhushanwen/pi-ext-guards": "0.5.0",
|
|
54
54
|
"@zhushanwen/pi-extension-logger": "0.6.2",
|
|
55
|
-
"@zhushanwen/pi-llm-shared": "0.
|
|
55
|
+
"@zhushanwen/pi-llm-shared": "0.12.0"
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
58
|
"@earendil-works/pi-ai": "^1.0.0",
|
|
@@ -286,7 +286,7 @@ describe("ApprovalComponent(G4 invalidate)", () => {
|
|
|
286
286
|
expect(result.approved).toBe(false);
|
|
287
287
|
});
|
|
288
288
|
|
|
289
|
-
it("handleInput 在 resolved 后 no-op
|
|
289
|
+
it("handleInput 在 resolved 后 no-op(检查)", () => {
|
|
290
290
|
const done = vi.fn();
|
|
291
291
|
const comp = new ApprovalComponent(req, { requestRender: vi.fn() }, done);
|
|
292
292
|
comp.handleInput("\r"); // 首次 approve(Enter)
|
|
@@ -23,7 +23,7 @@ import permissionExtension from "../index.js";
|
|
|
23
23
|
import { FOOTER_HANDSHAKE_KEY, REQUEST_RENDER_KEY } from "../footer-provider.js";
|
|
24
24
|
|
|
25
25
|
// migrateLegacyConfig 包 spy 计数(透传真实实现,行为零改变):迁移接线用例的
|
|
26
|
-
// 操作级去重断言(双派发 spy = 1
|
|
26
|
+
// 操作级去重断言(双派发 spy = 1)经此观测,文件侧断言(mtime/内容不变)同源有效。
|
|
27
27
|
const { spyMigrateLegacyConfig } = vi.hoisted(() => ({
|
|
28
28
|
spyMigrateLegacyConfig: vi.fn(),
|
|
29
29
|
}));
|
|
@@ -295,7 +295,7 @@ describe("W8 /permission rule 命令集成", () => {
|
|
|
295
295
|
const { pi, calls } = createMockPi();
|
|
296
296
|
permissionExtension(pi);
|
|
297
297
|
const handler = getPermissionHandler(calls);
|
|
298
|
-
// notify spy 注入 ctx(commands.test.ts 同款 vi.fn 形态):headless
|
|
298
|
+
// notify spy 注入 ctx(commands.test.ts 同款 vi.fn 形态):headless 降级提示的可观测范围
|
|
299
299
|
const notify = vi.fn();
|
|
300
300
|
const ctx = {
|
|
301
301
|
mode: "json",
|
|
@@ -406,13 +406,13 @@ describe("footer line 注册", () => {
|
|
|
406
406
|
});
|
|
407
407
|
|
|
408
408
|
// ──────────────────────── session_start 配置路径迁移接线(index.ts migrateLegacyConfig 调用)────────────────────────
|
|
409
|
-
// 迁移经 oncePerProcess
|
|
409
|
+
// 迁移经 oncePerProcess 检查(u-audit-fix 收编原内联 once flag),检查的模块级 Map
|
|
410
410
|
// 跨用例残留:用 vi.resetModules() + 动态 import 取新鲜模块实例,否则前面用例已触发过
|
|
411
411
|
// session_start,迁移分支永不执行(静默不测)。
|
|
412
|
-
//
|
|
412
|
+
// 末两条为检查双派发实测(u-audit-fix 探针):
|
|
413
413
|
// a) 操作级去重:双派发下 spy migrateLegacyConfig 调用 = 1(warn 在 migrateLegacyConfig
|
|
414
414
|
// 调用点内部,调用 = 1 构造性蕴含 warn ≤ 1,不另设断言);
|
|
415
|
-
// b)
|
|
415
|
+
// b) 文件侧:第二次派发后重建的旧文件原样保留、新路径 mtime/内容严格不变;
|
|
416
416
|
// c) 豁免项防误伤:footer 注册为纯内存初始化(registry.register 同 id 覆盖),不在
|
|
417
417
|
// 包装范围——双派发仍每次执行(pending push ×2)。
|
|
418
418
|
describe("session_start 配置路径迁移接线", () => {
|
|
@@ -453,7 +453,7 @@ describe("session_start 配置路径迁移接线", () => {
|
|
|
453
453
|
expect(existsSync(LEGACY_PATH)).toBe(false);
|
|
454
454
|
});
|
|
455
455
|
|
|
456
|
-
it("once
|
|
456
|
+
it("once 检查:同进程第二次 session_start 不重复迁移(重建旧文件不动)", async () => {
|
|
457
457
|
rmSync(CONFIG_PATH, { force: true });
|
|
458
458
|
writeLegacyConfig("auto");
|
|
459
459
|
|
|
@@ -464,7 +464,7 @@ describe("session_start 配置路径迁移接线", () => {
|
|
|
464
464
|
handler({}, makeCtx("json"));
|
|
465
465
|
expect(existsSync(LEGACY_PATH)).toBe(false); // 首次已迁移
|
|
466
466
|
|
|
467
|
-
// 重建旧文件(模拟外部残留),再次触发 session_start → oncePerProcess
|
|
467
|
+
// 重建旧文件(模拟外部残留),再次触发 session_start → oncePerProcess 检查跳过迁移
|
|
468
468
|
writeLegacyConfig("yolo");
|
|
469
469
|
handler({}, makeCtx("json"));
|
|
470
470
|
expect(existsSync(LEGACY_PATH)).toBe(true); // 未被再次消费
|
|
@@ -498,8 +498,8 @@ describe("session_start 配置路径迁移接线", () => {
|
|
|
498
498
|
expect(existsSync(CONFIG_PATH)).toBe(false); // 迁移不凭空造新配置
|
|
499
499
|
});
|
|
500
500
|
|
|
501
|
-
// ── oncePerProcess
|
|
502
|
-
it("双派发下 migrateLegacyConfig 仅执行一次:spy = 1
|
|
501
|
+
// ── oncePerProcess 检查双派发实测(u-audit-fix 探针)──
|
|
502
|
+
it("双派发下 migrateLegacyConfig 仅执行一次:spy = 1,文件侧首次迁移到位、第二次 mtime/内容不变", async () => {
|
|
503
503
|
// spy 是文件级单例(跨 resetModules 保留计数),前面用例已触发过迁移 → 先清零
|
|
504
504
|
spyMigrateLegacyConfig.mockClear();
|
|
505
505
|
// 预置旧路径文件(新路径不存在 → 走 renameSync 迁移分支)
|
|
@@ -520,11 +520,11 @@ describe("session_start 配置路径迁移接线", () => {
|
|
|
520
520
|
const contentAfterFirst = readFileSync(CONFIG_PATH, "utf-8");
|
|
521
521
|
|
|
522
522
|
// 第二次派发(factory 二调/handler 累积形态:同一 handler 引用再调):
|
|
523
|
-
//
|
|
523
|
+
// 重建旧路径文件模拟外部残留——检查已消费 key,不得再次触碰任何文件。
|
|
524
524
|
writeLegacyConfig("yolo");
|
|
525
525
|
handler({}, makeCtx("json"));
|
|
526
526
|
|
|
527
|
-
// 探针 a:操作级去重(spy
|
|
527
|
+
// 探针 a:操作级去重(spy 透传真实实现,文件侧与计数同源)
|
|
528
528
|
expect(spyMigrateLegacyConfig).toHaveBeenCalledTimes(1);
|
|
529
529
|
// 探针 b:第二次后重建的旧文件原样保留、新路径 mtime/内容严格不变
|
|
530
530
|
expect(existsSync(LEGACY_PATH)).toBe(true);
|
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
* 注册,D3「旧名不保留别名」),不对其迁移后结论作断言;
|
|
15
15
|
* 5b. 反向混合集(wildcard deny 在前、精确 allow 在后)——与分支5互为镜像,
|
|
16
16
|
* 区分度弱,承重位置锚 = 精确条目仍在 wildcard 之前。
|
|
17
|
+
* 6. subagents → subagent_batch(2026-10-23 批量工具改名入表)——单目标展开:
|
|
18
|
+
* 展开正确 + 字段保留 + 幂等 + 保序位置锚 + 真实 matcher 结论等价四维度合并
|
|
19
|
+
* 锁定(单元素展开无次序承重,四维度一用例足够)。
|
|
17
20
|
*
|
|
18
21
|
* 匹配器行为注释:matchNonBashTool 的 tool 字段经 wildcardToRegExp 全锚定匹配
|
|
19
22
|
* (精确名 = 精确匹配,'*' = 匹配全部);pattern 对 path 匹配(path 缺省时对
|
|
@@ -34,7 +37,7 @@ function userRule(overrides: Partial<Rule> & { id: string; tool: string }): Rule
|
|
|
34
37
|
};
|
|
35
38
|
}
|
|
36
39
|
|
|
37
|
-
describe("旧工具名精确规则迁移(D3)—
|
|
40
|
+
describe("旧工具名精确规则迁移(D3)— 表驱动七用例(六分支 + 分支5b 反向混合集)", () => {
|
|
38
41
|
it("分支1:精确旧名展开(subagent 三条 + workflow 两条,展开顺序承重,其余字段保留)", () => {
|
|
39
42
|
const rules: Rule[] = [
|
|
40
43
|
userRule({ id: "user-1", tool: "subagent", pattern: "*", action: "deny", description: "no subagent" }),
|
|
@@ -163,7 +166,7 @@ describe("旧工具名精确规则迁移(D3)— 表驱动六用例(五分
|
|
|
163
166
|
it("分支5b:反向混合集(wildcard deny 在前、精确 allow 在后)保序——新名结论 = 旧名结论", () => {
|
|
164
167
|
// 与分支5互为镜像:旧名调用结论 = deny(尾部精确 allow 被…不——allow 在
|
|
165
168
|
// wildcard deny 之后,last-match-wins 下 allow 胜。若错误追加,新名 allow
|
|
166
|
-
// 落在最后 → 仍 allow
|
|
169
|
+
// 落在最后 → 仍 allow,本场景区分度弱;关键部分是「精确条目仍在 wildcard
|
|
167
170
|
// 之前」的位置锚 + 与分支5合并覆盖两个方向)。
|
|
168
171
|
const mixed: Rule[] = [
|
|
169
172
|
userRule({ id: "user-1", tool: "*", pattern: "*", action: "deny" }),
|
|
@@ -196,4 +199,36 @@ describe("旧工具名精确规则迁移(D3)— 表驱动六用例(五分
|
|
|
196
199
|
expect(matchNonBashTool("bash", "/x", migrated).action).toBe(matchNonBashTool("bash", "/x", mixed).action);
|
|
197
200
|
expect(matchNonBashTool("bash", "/x", mixed).action).toBe("deny");
|
|
198
201
|
});
|
|
202
|
+
|
|
203
|
+
it("分支6:subagents → subagent_batch(2026-10-23 批量工具改名入表)— 展开 + 幂等 + 保序 + matcher 结论等价", () => {
|
|
204
|
+
// 展开正确 + 字段保留:单目标展开(一条旧名规则 → 一条 subagent_batch)
|
|
205
|
+
const rules: Rule[] = [
|
|
206
|
+
userRule({ id: "user-1", tool: "subagents", pattern: "*", action: "deny", description: "no batch" }),
|
|
207
|
+
userRule({ id: "user-2", tool: "*", pattern: "*", action: "allow" }),
|
|
208
|
+
];
|
|
209
|
+
const migrated = migrateLegacyToolRules(rules);
|
|
210
|
+
|
|
211
|
+
// 保序位置锚:展开条目原位替换,wildcard 仍在后(单目标无展开次序承重)
|
|
212
|
+
expect(migrated).toHaveLength(2);
|
|
213
|
+
expect(migrated.map((r) => r.tool)).toEqual(["subagent_batch", "*"]);
|
|
214
|
+
// pattern/action/source/description 保留(只换 tool 名与 id)
|
|
215
|
+
expect(migrated[0]).toMatchObject({ pattern: "*", action: "deny", source: "user", description: "no batch" });
|
|
216
|
+
// 新 id(D3 契约②):不克隆原 id,形态 user-N
|
|
217
|
+
expect(migrated[0]?.id).not.toBe("user-1");
|
|
218
|
+
expect(migrated[0]?.id).toMatch(/^user-\d+$/);
|
|
219
|
+
// 纯函数:原数组不改写
|
|
220
|
+
expect(rules).toHaveLength(2);
|
|
221
|
+
expect(rules[0]?.tool).toBe("subagents");
|
|
222
|
+
|
|
223
|
+
// 幂等:新名不在旧名集合,再迁移深度相等
|
|
224
|
+
expect(migrateLegacyToolRules(migrated)).toEqual(migrated);
|
|
225
|
+
|
|
226
|
+
// 真实 matcher 结论等价:场景同分支5(尾部 wildcard allow 覆盖精确 deny)——
|
|
227
|
+
// 若实装错误地把展开条目追加到数组尾部,subagent_batch deny 落到 wildcard
|
|
228
|
+
// allow 之后 → 结论翻成 deny ≠ 迁移前 allow,本断言即红灯。
|
|
229
|
+
const beforeSubagents = matchNonBashTool("subagents", "/x", rules).action;
|
|
230
|
+
const afterBatch = matchNonBashTool("subagent_batch", "/x", migrated).action;
|
|
231
|
+
expect(beforeSubagents).toBe("allow");
|
|
232
|
+
expect(afterBatch).toBe(beforeSubagents);
|
|
233
|
+
});
|
|
199
234
|
});
|
|
@@ -285,7 +285,7 @@ describe("MPT5: ProviderModelSelectorComponent model onSelect", () => {
|
|
|
285
285
|
expect(comp.render(80).join("\n")).toContain("Select Provider"); // 回到 provider stage
|
|
286
286
|
});
|
|
287
287
|
|
|
288
|
-
it("_resolved
|
|
288
|
+
it("_resolved 检查:done 后再 handleInput no-op", () => {
|
|
289
289
|
const done = vi.fn();
|
|
290
290
|
const models = makeModelsMap({ "co": [makeEntry("co", "m1")] });
|
|
291
291
|
const comp = new ProviderModelSelectorComponent("auto", ["co"], models, done);
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* production.test.ts — createProductionClassifier + createPipelineDeps 装配测试。
|
|
3
3
|
*
|
|
4
|
-
* C1a
|
|
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
7
|
* - TC1: toSelector 任意字符串 → ref 精确选择器('auto' 不走 selector,由 resolveModel 注入层本地处理)
|
|
8
8
|
* - TC3: 'auto' 且 getAvailable 空 → resolveModel 返回 null → fail-closed ask(不触达 callLLM)
|
|
9
|
-
* - TC4(静态断言): production.ts
|
|
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
12
|
* - TC7: 'auto' 直接取 getAvailable() 首个(permission 本地解析,不经过 llm-shared 非精确 selector)
|
|
@@ -129,7 +129,7 @@ describe("createProductionClassifier", () => {
|
|
|
129
129
|
});
|
|
130
130
|
});
|
|
131
131
|
|
|
132
|
-
// ──────────────────────── resolveModel 装配(C3 + C1a
|
|
132
|
+
// ──────────────────────── resolveModel 装配(C3 + C1a 收敛) ────────────────────────
|
|
133
133
|
|
|
134
134
|
describe("resolveModel 装配(resolveModel + callLLM)", () => {
|
|
135
135
|
const CTX = { toolName: "bash", command: "ls", cwd: "/tmp" };
|
|
@@ -147,19 +147,19 @@ describe("resolveModel 装配(resolveModel + callLLM)", () => {
|
|
|
147
147
|
expect(callLLMShared).not.toHaveBeenCalled();
|
|
148
148
|
});
|
|
149
149
|
|
|
150
|
-
it("TC4(静态断言): production.ts
|
|
150
|
+
it("TC4(静态断言): production.ts 收敛完成——无 getApiProvider/streamSimple/@ts-ignore,classifier 走 callLLM", () => {
|
|
151
151
|
const source = readFileSync(new URL("../production.ts", import.meta.url), "utf-8");
|
|
152
152
|
// 匹配代码形式(import 调用 / 字段定义),注释里的历史描述不算残留
|
|
153
153
|
expect(source).not.toMatch(/getApiProvider\s*\(/);
|
|
154
154
|
expect(source).not.toMatch(/streamSimple\s*[:=]/);
|
|
155
155
|
expect(source).not.toMatch(/\/\/\s*@ts-ignore/);
|
|
156
156
|
expect(source).not.toMatch(/import\s*\{[^}]*getApiProvider/);
|
|
157
|
-
// classifier LLM
|
|
157
|
+
// classifier LLM 调用收敛到 llm-shared callLLM(注入闭包捕获 ctx)
|
|
158
158
|
expect(source).toMatch(/callLLM\s*:/);
|
|
159
159
|
expect(source).toContain('from "@zhushanwen/pi-llm-shared"');
|
|
160
160
|
});
|
|
161
161
|
|
|
162
|
-
it("TC4(装配): ref resolveModel 命中 → callLLM 收到 model → ok:true
|
|
162
|
+
it("TC4(装配): ref resolveModel 命中 → callLLM 收到 model → ok:true 正常分类(收敛链路通)", async () => {
|
|
163
163
|
vi.mocked(resolveModelShared).mockReturnValue(MOCK_MODEL_A);
|
|
164
164
|
vi.mocked(callLLMShared).mockResolvedValue({
|
|
165
165
|
ok: true,
|
|
@@ -168,7 +168,7 @@ describe("resolveModel 装配(resolveModel + callLLM)", () => {
|
|
|
168
168
|
const classifier = createProductionClassifier(makeMockCtx());
|
|
169
169
|
const result = await classifier.classifyRisk(CTX, CFG_REF);
|
|
170
170
|
expect(result.outcome).toBe("allow");
|
|
171
|
-
// 关键:resolveModel 返回 model 后,callLLM 被调用且收到该 model
|
|
171
|
+
// 关键:resolveModel 返回 model 后,callLLM 被调用且收到该 model(收敛链路 resolve→callLLM 通)
|
|
172
172
|
expect(callLLMShared).toHaveBeenCalledOnce();
|
|
173
173
|
// callLLM(ctx, opts) 两个参数:mock.calls[0] = [ctx, opts]
|
|
174
174
|
const opts = vi.mocked(callLLMShared).mock.calls[0]![1];
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* - list stage [+ Add] → add stage
|
|
8
8
|
* - add stage 选模板 → fill stage
|
|
9
9
|
* - [Done] → settle ops
|
|
10
|
-
* - _resolved
|
|
10
|
+
* - _resolved 检查
|
|
11
11
|
* - currentListRules 实时反映 ops(G2)
|
|
12
12
|
* - WR1 handleInput 锁定(Container override 委托)
|
|
13
13
|
* - Tab 焦点路由(WR8)
|
|
@@ -134,9 +134,9 @@ describe("REC4: fill stage 命令选择 + commitFill", () => {
|
|
|
134
134
|
});
|
|
135
135
|
});
|
|
136
136
|
|
|
137
|
-
// ──────────────────────── _resolved
|
|
137
|
+
// ──────────────────────── _resolved 检查 ────────────────────────
|
|
138
138
|
|
|
139
|
-
describe("REC5: _resolved
|
|
139
|
+
describe("REC5: _resolved 检查", () => {
|
|
140
140
|
it("settle 后再 handleInput no-op(done 只调一次)", () => {
|
|
141
141
|
const { comp, done } = createComp();
|
|
142
142
|
// 选 [Done]
|
|
@@ -226,7 +226,7 @@ describe("REC9: cancel", () => {
|
|
|
226
226
|
expect(done).toHaveBeenCalledOnce();
|
|
227
227
|
});
|
|
228
228
|
|
|
229
|
-
it("cancel 后 _resolved
|
|
229
|
+
it("cancel 后 _resolved 检查生效", () => {
|
|
230
230
|
const { comp, done } = createComp();
|
|
231
231
|
comp.cancel();
|
|
232
232
|
comp.cancel();
|
package/src/approval.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* ApprovalComponent 是简化版 TUI 组件(参考 ask-user AskUserComponent):
|
|
13
13
|
* - 显示工具名 + 命令 + 触发原因 + 可选 AI 预分类。
|
|
14
14
|
* - Enter/approve、Esc/deny。
|
|
15
|
-
* - signal abort → comp.cancel()(复用 _resolved
|
|
15
|
+
* - signal abort → comp.cancel()(复用 _resolved 检查,避免二次 done)。
|
|
16
16
|
*
|
|
17
17
|
* W6 T9 G3 Reject-with-Reason:用户拒绝时,若 ctx.ui.input 存在则弹出文本输入框
|
|
18
18
|
* 采集真实拒绝理由(回传给 agent,辅助理解为何被拒);ctx.ui.input 不存在则 fallback
|
|
@@ -105,7 +105,7 @@ async function requestTui(
|
|
|
105
105
|
|
|
106
106
|
return approvalCtx.ui.custom<UserDecision>((tui, _theme, _kb, done) => {
|
|
107
107
|
const comp = new ApprovalComponent(req, tui as TuiLike, done);
|
|
108
|
-
// signal abort → comp.cancel()(复用 _resolved
|
|
108
|
+
// signal abort → comp.cancel()(复用 _resolved 检查,避免二次 done)
|
|
109
109
|
if (signal) {
|
|
110
110
|
signal.addEventListener("abort", () => comp.cancel(), { once: true });
|
|
111
111
|
}
|
|
@@ -275,7 +275,7 @@ interface TuiLike {
|
|
|
275
275
|
* 键位:Enter → approve;Esc → deny/cancel(y/n 快捷键已移除,测试钉住 no-op)。
|
|
276
276
|
*
|
|
277
277
|
* G4:implements Component,补 invalidate()(调 tui.requestRender())。
|
|
278
|
-
* signal abort → cancel()(复用 _resolved
|
|
278
|
+
* signal abort → cancel()(复用 _resolved 检查)。
|
|
279
279
|
*
|
|
280
280
|
* TODO W6:若需更丰富的 TUI(方向键选择 once/session/always scope、高亮风险等级、
|
|
281
281
|
* Reject-with-Reason 的内联文本输入),参考 ask-user AskUserComponent 的 SelectList
|
|
@@ -345,7 +345,7 @@ export class ApprovalComponent implements Component {
|
|
|
345
345
|
this.done({ approved: false, reason: "denied via tui" });
|
|
346
346
|
}
|
|
347
347
|
|
|
348
|
-
/** 取消(signal abort 调用)。复用 _resolved
|
|
348
|
+
/** 取消(signal abort 调用)。复用 _resolved 检查,避免二次 done。 */
|
|
349
349
|
cancel(): void {
|
|
350
350
|
if (this._resolved) return;
|
|
351
351
|
this._resolved = true;
|
|
@@ -153,7 +153,7 @@ describe("WT16-WT19: malformed 语法(parseError=true)", () => {
|
|
|
153
153
|
expect(r.clean).toBe(false);
|
|
154
154
|
});
|
|
155
155
|
|
|
156
|
-
it("WT18: unclosed \" ——
|
|
156
|
+
it("WT18: unclosed \" —— 未结束的双引号", async () => {
|
|
157
157
|
const r = await analyzeBashStructure('echo "unclosed');
|
|
158
158
|
expect(r.parseError).toBe(true);
|
|
159
159
|
expect(r.clean).toBe(false);
|
package/src/ast/analyzer.ts
CHANGED
|
@@ -181,7 +181,7 @@ function parseChildToken(child: Node): string | null {
|
|
|
181
181
|
/**
|
|
182
182
|
* 从一个 command 节点提取 argv(对应 Rust parse_plain_command_from_node)。
|
|
183
183
|
*
|
|
184
|
-
*
|
|
184
|
+
* 主函数编排:类型检查 → 逐 named child 分发(parseChildToken)→ 组装 words。
|
|
185
185
|
* 任一 child 解析失败 → 返回 null。
|
|
186
186
|
*/
|
|
187
187
|
function parsePlainCommand(cmd: Node): string[] | null {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* CT 系列:classifier.ts 单元测试(createClassifier + classifyRisk 主流程)。
|
|
3
3
|
*
|
|
4
|
-
* C1a
|
|
4
|
+
* C1a 收敛后 classifier 的 LLM 调用走 llm-shared callLLM(依赖注入 deps.callLLM,
|
|
5
5
|
* mock 返回 CallLLMResult 判别联合)。stopReason 归一化在 callLLM 层完成,
|
|
6
6
|
* classifier 消费 ok:false + stopReason 透传字段保留日志区分。
|
|
7
7
|
*
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MRT 系列:model-resolver.ts 单元测试(picker 相关 API)。
|
|
3
3
|
*
|
|
4
|
-
* E2(CL-picker-scope
|
|
4
|
+
* E2(CL-picker-scope 收敛)后 listAvailableModels 改走 ctx.modelRegistry.getAll() +
|
|
5
5
|
* hasConfiguredAuth() 过滤(不再读 models.json),loadModelsJson / flattenModels 已删除。
|
|
6
6
|
* E1:ResolvedModelEntry.apiKey / hasApiKey 已删。
|
|
7
7
|
*
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* 关键设计:
|
|
9
9
|
* - 依赖注入 ClassifierDeps(callLLM 注入,便于测试 mock,不直接 import)
|
|
10
|
-
* - C1a
|
|
10
|
+
* - C1a 收敛:LLM 调用走 llm-shared callLLM(内部完成凭证 getApiKeyAndHeaders +
|
|
11
11
|
* completeSimple + stopReason 归一化:error/aborted → {ok:false,
|
|
12
12
|
* stopReason 透传}),不再走 production.ts 的 streamSimple
|
|
13
13
|
* - G3 修正(由 callLLM 承接):completeSimple 的 EventStream.result() 只 resolve 不 reject,
|
|
@@ -51,9 +51,9 @@ const CLASSIFY_FALLBACK_RESULT: ClassifierResult = {
|
|
|
51
51
|
* Classifier 的外部依赖(DI 便于测试 mock)。
|
|
52
52
|
*
|
|
53
53
|
* - resolveModel(async):把 ClassifierConfig.model 解析为 Model<Api>(或 null)。
|
|
54
|
-
* P3
|
|
54
|
+
* P3 收敛后 model 解析走 llm-shared resolveModel(ctx.modelRegistry 三源合并)。
|
|
55
55
|
* - callLLM:llm-shared 的 LLM 调用封装(内部完成凭证 getApiKeyAndHeaders +
|
|
56
|
-
* completeSimple + stopReason 归一化)。C1a
|
|
56
|
+
* completeSimple + stopReason 归一化)。C1a 收敛替代原 streamSimple 注入。
|
|
57
57
|
* - onLog:可选日志回调(调试/审计)
|
|
58
58
|
*/
|
|
59
59
|
export interface ClassifierDeps {
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* model picker 的模型列表(picker 用,非 classifier)。
|
|
3
3
|
*
|
|
4
|
-
* P3
|
|
4
|
+
* P3 收敛后 classifier 不再自读 models.json(改走 llm-shared resolveModel +
|
|
5
5
|
* ctx.modelRegistry,见 classifier.ts / production.ts)。
|
|
6
6
|
*
|
|
7
|
-
* E2(CL-picker-scope
|
|
7
|
+
* E2(CL-picker-scope 收敛):listAvailableModels 改走 ctx.modelRegistry.getAll() +
|
|
8
8
|
* hasConfiguredAuth() 过滤——用户只经 `pi auth login` 配的内置/OAuth provider 同样可见,
|
|
9
9
|
* 不再自读 models.json(loadModelsJson / flattenModels 已删除)。
|
|
10
|
-
* E1:ResolvedModelEntry.apiKey 死字段已删(P3
|
|
10
|
+
* E1:ResolvedModelEntry.apiKey 死字段已删(P3 收敛后凭证走 modelRegistry,无消费者)。
|
|
11
11
|
* E5:name/baseUrl/cost 死字段已删(picker 只读 id/api)。
|
|
12
12
|
*/
|
|
13
13
|
|
package/src/commands.ts
CHANGED
|
@@ -126,7 +126,7 @@ function modeHighlight(mode: PermissionMode): string {
|
|
|
126
126
|
* 2. pickModelViaOverlay:TUI/RPC/headless 三模式分发。
|
|
127
127
|
* 3. 写回:save(newConfig)。失败 → notify error。
|
|
128
128
|
*
|
|
129
|
-
* save
|
|
129
|
+
* save 为普通函数参数(唯一真实变化原因:保存 + footer 重绘副作用)。
|
|
130
130
|
* ctx 用 ModelPickerContext 子集(mode + modelRegistry + ui.notify/custom/select)。
|
|
131
131
|
*/
|
|
132
132
|
export async function handlePermissionModelCommand(
|
|
@@ -181,7 +181,7 @@ export async function handlePermissionModelCommand(
|
|
|
181
181
|
* 2. ops 非空 → applyOps + saveConfig + notify。
|
|
182
182
|
* 3. 空 → notify no changes。
|
|
183
183
|
*
|
|
184
|
-
* save
|
|
184
|
+
* save 为普通函数参数(唯一真实变化原因:保存 + footer 重绘副作用,与同步路径 onSave 同款)。
|
|
185
185
|
*/
|
|
186
186
|
export async function handlePermissionRuleCommand(
|
|
187
187
|
ctx: RuleEditorContext,
|
package/src/config.ts
CHANGED
|
@@ -145,7 +145,7 @@ function warnLegacyConfigIfExists(): void {
|
|
|
145
145
|
* D3:subagent/workflow 拆分改名后,持久化的旧名精确规则原地展开为新名规则——
|
|
146
146
|
* 原地替换保序 + makeNextIdCounter 新 id + 幂等;只内存展开,不回写落盘,加载路径纯读)。
|
|
147
147
|
* 所有配置消费方(pipeline / rule-editor / statusline / commands)都经本入口取 config,
|
|
148
|
-
*
|
|
148
|
+
* 迁移单点在此即全部消费方生效。
|
|
149
149
|
*/
|
|
150
150
|
export function loadAndWatchConfig(onWarning?: (msg: string) => void): PermissionConfig {
|
|
151
151
|
const configPath = getConfigPath();
|
package/src/footer-provider.ts
CHANGED
|
@@ -68,7 +68,7 @@ interface FooterHandshakeSlot {
|
|
|
68
68
|
pending: PendingEntry[];
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
// ── Slot 读取(version +
|
|
71
|
+
// ── Slot 读取(version + 结构检查)──
|
|
72
72
|
|
|
73
73
|
/**
|
|
74
74
|
* 从 globalThis 读 slot;version !== 1 或结构不合规视为无 slot(返回 undefined)。
|
|
@@ -80,7 +80,7 @@ function readSlot(): FooterHandshakeSlot | undefined {
|
|
|
80
80
|
const slot = Reflect.get(globalThis, FOOTER_HANDSHAKE_KEY);
|
|
81
81
|
if (slot === undefined) return undefined;
|
|
82
82
|
if (typeof slot !== "object" || slot === null) return undefined;
|
|
83
|
-
// 双字段 in
|
|
83
|
+
// 双字段 in 检查:version + pending 都在才视为 slot 雏形(避免 as 到全可选类型的无校验断言)
|
|
84
84
|
if (!("version" in slot) || !("pending" in slot)) return undefined;
|
|
85
85
|
const loose = slot as { version: unknown; pending: unknown };
|
|
86
86
|
if (loose.version !== HANDSHAKE_VERSION) {
|
package/src/index.ts
CHANGED
|
@@ -43,7 +43,7 @@ import { paletteFromTheme, type PermissionPalette } from "./statusline-palette.j
|
|
|
43
43
|
// Added in v1.0.0. Remove after v2.0.0 (one major past).
|
|
44
44
|
// session_start 首次触发时把旧路径 permission-config.json 迁到 config/permission-ext-config.json。
|
|
45
45
|
// 幂等、best-effort(migrateLegacyConfig 见 @zhushanwen/pi-llm-shared)。
|
|
46
|
-
// 写 agentDir 全局配置文件属跨 session 副作用——oncePerProcess
|
|
46
|
+
// 写 agentDir 全局配置文件属跨 session 副作用——oncePerProcess 检查防 factory 二调/handler
|
|
47
47
|
// 累积双跑(u-audit-fix 收编原内联 once flag;key 带包名前缀避撞,见 pi-ext-guards JSDoc)。
|
|
48
48
|
// agentDir 由 getAgentDir() 推导(尊重 PI_CODING_AGENT_DIR)。
|
|
49
49
|
|
|
@@ -90,7 +90,7 @@ const _UI_OPTIONS_PARAM_INDEX = 2;
|
|
|
90
90
|
*
|
|
91
91
|
* select/input 的 opts 类型经 `Parameters<typeof ...>[typeof _UI_OPTIONS_PARAM_INDEX]`
|
|
92
92
|
* 索引提取(字面量索引会触发 no-magic-numbers,typeof 常量索引语义等价)。
|
|
93
|
-
* input
|
|
93
|
+
* input 可选检查展开:SDK 提供,mock/headless 可能缺失——rule-editor custom 模板
|
|
94
94
|
* 文本输入与 Reject-with-Reason(approval.ts collectRejectReason)都依赖此透传。
|
|
95
95
|
*/
|
|
96
96
|
function makeUiAdapter(ui: ExtensionUIContext) {
|
package/src/model-picker.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* - ProviderModelSelectorComponent extends Container:Container 无 handleInput,必须 override
|
|
17
17
|
* 委托给当前 stage 的 SelectList(WC9/WC15 critical)。
|
|
18
18
|
* - stage 切换用 clear() + addChild 重建(两态状态机)。
|
|
19
|
-
* - _resolved
|
|
19
|
+
* - _resolved 检查防二次 done(switchToModelStage 等异步路径可能重复触发)。
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { type Component, Container, type SelectItem, SelectList, type SelectListTheme, truncateToWidth } from "@earendil-works/pi-tui";
|
|
@@ -79,7 +79,7 @@ class TextLines implements Component {
|
|
|
79
79
|
* 测试 WR1:直接调 comp.handleInput('\r') 验证 SelectList.onSelect 被触发(锁定键盘委托通路)。
|
|
80
80
|
*
|
|
81
81
|
* done 桥接:comp 构造时传入 done(由 ctx.ui.custom 的 factory 提供),内部 onSelect/onCancel
|
|
82
|
-
* 最终调 done(参考 ApprovalComponent)。_resolved
|
|
82
|
+
* 最终调 done(参考 ApprovalComponent)。_resolved 检查防二次 done。
|
|
83
83
|
*/
|
|
84
84
|
export class ProviderModelSelectorComponent extends Container {
|
|
85
85
|
private stage: "provider" | "model" = "provider";
|
package/src/production.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 生产装配层(W5 集成层)。
|
|
3
3
|
*
|
|
4
|
-
* P3
|
|
4
|
+
* P3 收敛:classifier 的 model 解析改走 llm-shared resolveModel(三源合并含 OAuth/env/auth.json),
|
|
5
5
|
* 废弃自读 models.json。'auto' 由 permission 本地处理:直接取 ctx.modelRegistry.getAvailable()[0]
|
|
6
6
|
* (不经过 llm-shared 的非精确 selector),与下方 createProductionClassifier JSDoc 一致。
|
|
7
7
|
*
|
|
8
|
-
* C1a
|
|
8
|
+
* C1a 收敛:classifier 的 LLM 调用改走 llm-shared callLLM(凭证 getApiKeyAndHeaders +
|
|
9
9
|
* completeSimple + stopReason 归一化都在 callLLM 内部完成),删除 getApiProvider +
|
|
10
10
|
* streamSimple(@ts-ignore 随之消除)。
|
|
11
11
|
*
|
|
@@ -54,7 +54,7 @@ export function toSelector(modelSpec: string): ModelSelector {
|
|
|
54
54
|
* - model 仍 null → 返回 null(fail-closed)。
|
|
55
55
|
*
|
|
56
56
|
* callLLM 注入(C1a):闭包捕获 ctx,把 llm-shared callLLM 绑定为 classifier 的 LLM 调用。
|
|
57
|
-
* 凭证获取(getApiKeyAndHeaders)在 callLLM 内部完成(C1a
|
|
57
|
+
* 凭证获取(getApiKeyAndHeaders)在 callLLM 内部完成(C1a 收敛后不再在 resolveModel 层预检)。
|
|
58
58
|
*/
|
|
59
59
|
export function createProductionClassifier(ctx: ExtensionContext): {
|
|
60
60
|
classifyRisk: ReturnType<typeof createClassifier>["classifyRisk"];
|
|
@@ -75,7 +75,7 @@ type FillKind =
|
|
|
75
75
|
* TUI overlay 组件:规则 CRUD 编辑器。
|
|
76
76
|
*
|
|
77
77
|
* Container override handleInput(W7 模式):按 stage 委托给当前子组件。
|
|
78
|
-
* settle 用 _resolved
|
|
78
|
+
* settle 用 _resolved 检查防二次 done。
|
|
79
79
|
*/
|
|
80
80
|
export class RuleEditorComponent extends Container {
|
|
81
81
|
private stage: Stage = "list";
|
|
@@ -244,7 +244,7 @@ export class RuleEditorComponent extends Container {
|
|
|
244
244
|
this.settle();
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
-
/** settle:_resolved
|
|
247
|
+
/** settle:_resolved 检查 + done(this.ops)。 */
|
|
248
248
|
private settle(): void {
|
|
249
249
|
if (this._resolved) return;
|
|
250
250
|
this._resolved = true;
|
|
@@ -173,8 +173,8 @@ describe("MT-last-match-wins: 后匹配的规则胜出", () => {
|
|
|
173
173
|
// ──────────────────────── matchRules 退化路径 ────────────────────────
|
|
174
174
|
|
|
175
175
|
describe("MT-matchRules: 退化路径(command 字符串)", () => {
|
|
176
|
-
// E11:toolName 参数与 '!== bash'
|
|
177
|
-
// 非 bash 由 pipeline.matchNonBashTool
|
|
176
|
+
// E11:toolName 参数与 '!== bash' 检查已删(生产调用点恒在 bash 分支内,
|
|
177
|
+
// 非 bash 由 pipeline.matchNonBashTool 承接),对应检查用例随之移除。
|
|
178
178
|
|
|
179
179
|
it("command === undefined → ask", () => {
|
|
180
180
|
const r = matchRules(undefined, getDefaultRules());
|
package/src/rules/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* rules 模块 barrel —— 只 re-export 有真实消费的公开 API(E10 收敛)。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 生产消费方 = production.ts:getDefaultRules(内置规则拼接)+ matchRulesForArgv
|
|
5
5
|
* (bash argv 匹配)。其余符号(matchRules / resolvePattern / wildcardToRegExp /
|
|
6
6
|
* builtins 数据)无 barrel 消费——pipeline.ts 与各测试均走深路径 import;
|
|
7
7
|
* 类型一律直接从 ../types.js import,barrel 不做类型 re-export。
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* subagent_start/subagent_manage/subagent_message 与 workflow_run/workflow_control,
|
|
6
6
|
* 旧名不保留别名(探针 PB-fork)。用户权限规则按工具名匹配(`Rule.tool`)并持久化
|
|
7
7
|
* 落盘(config.ts saveConfig),旧名精确规则随改名失配——deny 静默退化成 ask/AI 分类。
|
|
8
|
+
* 批量工具 `subagents` → `subagent_batch` 改名(2026-10-23)同机制入表。
|
|
8
9
|
*
|
|
9
10
|
* 迁移语义(D3 两条契约):
|
|
10
11
|
* ① 展开落位与保序——精确匹配旧名的用户规则**原地替换**为其新名展开(展开顺序
|
|
@@ -38,6 +39,7 @@ import { makeNextIdCounter } from "../rule-templates.js";
|
|
|
38
39
|
const LEGACY_TOOL_NAME_EXPANSION: Readonly<Record<string, readonly string[]>> = {
|
|
39
40
|
subagent: ["subagent_start", "subagent_manage", "subagent_message"],
|
|
40
41
|
workflow: ["workflow_run", "workflow_control"],
|
|
42
|
+
subagents: ["subagent_batch"],
|
|
41
43
|
};
|
|
42
44
|
|
|
43
45
|
/**
|
package/src/rules/matcher.ts
CHANGED
|
@@ -149,8 +149,8 @@ export function matchRulesForArgv(argv: string[], rules: readonly Rule[]): RuleM
|
|
|
149
149
|
* - 遍历 rules(last-match-wins),对每条用 resolvePattern(rule).test(command)
|
|
150
150
|
* - 无匹配 → ask(G1)
|
|
151
151
|
*
|
|
152
|
-
* E11:toolName 参数与 `toolName !== 'bash'`
|
|
153
|
-
* 恒在 bash 分支内调用,非 bash 由 matchNonBashTool
|
|
152
|
+
* E11:toolName 参数与 `toolName !== 'bash'` 检查已删——生产调用点(pipeline.runLayer2)
|
|
153
|
+
* 恒在 bash 分支内调用,非 bash 由 matchNonBashTool 承接,检查是不可达的投机分支。
|
|
154
154
|
*
|
|
155
155
|
* 不调 isKnownSafeCommand(不查白名单):command 字符串无法可靠提取 argv
|
|
156
156
|
* (含引号/转义/管道),argv 级白名单判断由 matchRulesForArgv 在 happy path 负责。
|
package/src/types.ts
CHANGED
|
@@ -30,7 +30,7 @@ export const MODE_LABELS: Record<PermissionMode, string> = {
|
|
|
30
30
|
strict: "Strict",
|
|
31
31
|
};
|
|
32
32
|
|
|
33
|
-
/** PermissionMode
|
|
33
|
+
/** PermissionMode 类型检查 */
|
|
34
34
|
export function isValidPermissionMode(value: unknown): value is PermissionMode {
|
|
35
35
|
return typeof value === "string" && PERMISSION_MODES.includes(value as PermissionMode);
|
|
36
36
|
}
|