@zhushanwen/pi-permission 0.0.1

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.
Files changed (51) hide show
  1. package/README.md +455 -0
  2. package/index.ts +2 -0
  3. package/package.json +52 -0
  4. package/src/__tests__/approval.test.ts +456 -0
  5. package/src/__tests__/commands.test.ts +487 -0
  6. package/src/__tests__/config.test.ts +229 -0
  7. package/src/__tests__/e2e-modes.test.ts +253 -0
  8. package/src/__tests__/extension.test.ts +170 -0
  9. package/src/__tests__/index-integration.test.ts +282 -0
  10. package/src/__tests__/model-picker.test.ts +371 -0
  11. package/src/__tests__/pipeline.test.ts +976 -0
  12. package/src/__tests__/production.test.ts +102 -0
  13. package/src/__tests__/rule-editor-component.test.ts +475 -0
  14. package/src/__tests__/rule-editor.test.ts +252 -0
  15. package/src/__tests__/rule-templates.test.ts +264 -0
  16. package/src/__tests__/statusline.test.ts +221 -0
  17. package/src/__tests__/types.test.ts +59 -0
  18. package/src/approval.ts +352 -0
  19. package/src/ast/__tests__/analyzer.test.ts +262 -0
  20. package/src/ast/__tests__/loader.test.ts +136 -0
  21. package/src/ast/analyzer.ts +326 -0
  22. package/src/ast/index.ts +7 -0
  23. package/src/ast/loader.ts +113 -0
  24. package/src/classifier/__tests__/classifier.test.ts +285 -0
  25. package/src/classifier/__tests__/json-parser.test.ts +118 -0
  26. package/src/classifier/__tests__/model-resolver.test.ts +173 -0
  27. package/src/classifier/__tests__/prompt.test.ts +59 -0
  28. package/src/classifier/classifier.ts +242 -0
  29. package/src/classifier/index.ts +28 -0
  30. package/src/classifier/json-parser.ts +127 -0
  31. package/src/classifier/model-resolver.ts +295 -0
  32. package/src/classifier/prompt.ts +52 -0
  33. package/src/commands.ts +234 -0
  34. package/src/config.ts +216 -0
  35. package/src/index.ts +291 -0
  36. package/src/model-picker.ts +398 -0
  37. package/src/pipeline.ts +541 -0
  38. package/src/production.ts +102 -0
  39. package/src/rule-editor-component.ts +883 -0
  40. package/src/rule-editor.ts +262 -0
  41. package/src/rule-templates.ts +332 -0
  42. package/src/rules/__tests__/builtins.test.ts +614 -0
  43. package/src/rules/__tests__/index.test.ts +26 -0
  44. package/src/rules/__tests__/matcher.test.ts +378 -0
  45. package/src/rules/__tests__/wildcard.test.ts +84 -0
  46. package/src/rules/builtins.ts +680 -0
  47. package/src/rules/index.ts +27 -0
  48. package/src/rules/matcher.ts +160 -0
  49. package/src/rules/wildcard.ts +43 -0
  50. package/src/statusline.ts +226 -0
  51. package/src/types.ts +249 -0
@@ -0,0 +1,541 @@
1
+ /**
2
+ * checkPermission 纯函数主入口 + 四档模式分支 + 层 2/3 编排(W5 集成层)。
3
+ *
4
+ * 架构(自上而下):
5
+ * - checkPermission:纯函数,按 mode 分发。deps 全注入(测试可 mock,不触碰 fs/network)。
6
+ * - runLayer2:bash 走 matchRulesForArgv(遍历 AST 拆出的 argv[]);非 bash 走
7
+ * matchNonBashTool(G1:W3 matcher 对 toolName!=='bash' 直接 ask,但 W5 需要让
8
+ * Read/Write/Edit 也评估用户规则,故本模块自带 helper,复用 wildcardToRegExp)。
9
+ * - runLayer3WithRacing:AI Classifier + 用户审批竞速(移植 classifier-racing.ts)。
10
+ * G3:用户审批的 ctx.ui.custom 工厂在调之前先检查 signal.aborted,避免 AI 先于
11
+ * UI 触发 abort 时 comp 尚未创建导致 cancel 落空。
12
+ * - applyAutoApproveOverrides:W4 WT7 偏差补丁(low+allow+autoApproveLowRisk=false → ask;
13
+ * high+allow+autoDenyHighRisk=true → deny),独立纯函数便于单测。
14
+ *
15
+ * fail-closed 原则:任何异常路径 → ask(交人工,不静默放行)。
16
+ * checkPermission 永不 throw(caller index.ts 的 tool_call handler 依赖此契约)。
17
+ */
18
+
19
+ import { matchRules } from "./rules/matcher.js";
20
+ import { wildcardToRegExp } from "./rules/wildcard.js";
21
+ import type {
22
+ ApprovalRequest,
23
+ CheckPermissionDeps,
24
+ ClassifierConfig,
25
+ ClassifierResult,
26
+ PermissionAction,
27
+ PermissionDecision,
28
+ PermissionMode,
29
+ Rule,
30
+ RuleMatchResult,
31
+ ToolInvocationContext,
32
+ UserDecision,
33
+ } from "./types.js";
34
+
35
+ // ──────────────────────── 依赖注入接口(W6 T8 迁移到 types.ts) ────────────────────────
36
+
37
+ // CheckPermissionDeps 与 ApprovalRequest 已迁移到 types.ts(W6 T8 统一类型声明)。
38
+ // 此处 re-export 保持 public API 不变(下游 approval.ts / production.ts / 测试仍从
39
+ // pipeline.js import 这些类型,不破坏现有 import 路径)。
40
+ export type { ApprovalRequest, CheckPermissionDeps } from "./types.js";
41
+
42
+ // ──────────────────────── buildApprovalRequest ────────────────────────
43
+
44
+ /**
45
+ * 构造审批 UI 需要的数据(纯函数)。
46
+ *
47
+ * @param toolName 工具名(bash/read/write/...)
48
+ * @param command 命令字符串(bash 用,其他工具 undefined)
49
+ * @param trigger 触发审批的原因(如 "AST detected dangerous structure: subshell")
50
+ * @param preClassification AI 先返回的分类(仅 auto 模式 racing AI 赢时携带)
51
+ */
52
+ export function buildApprovalRequest(
53
+ toolName: string,
54
+ command: string | undefined,
55
+ trigger: string,
56
+ preClassification?: ClassifierResult,
57
+ ): ApprovalRequest {
58
+ const cmdPart = command !== undefined && command.length > 0 ? `: ${command}` : "";
59
+ const reason = `[${toolName}${cmdPart}] ${trigger}`;
60
+ return { toolName, command, reason, ...(preClassification !== undefined ? { preClassification } : {}) };
61
+ }
62
+
63
+ // ──────────────────────── runLayer2(含 G1 非 bash helper) ────────────────────────
64
+
65
+ /**
66
+ * G1 修正:非 bash 工具规则匹配 helper。
67
+ *
68
+ * W3 matcher.ts 的 matchRules 对 toolName!=='bash' 直接返回 ask(W3 只管 bash),
69
+ * 但 W5 需要让 Read/Write/Edit 也评估用户规则(用户可能写 `read ~/.ssh/*` 的 deny 规则)。
70
+ * 故本模块自带 helper,不依赖 matchRules 的非 bash 路径。
71
+ *
72
+ * M5 修正:pattern 对 path 匹配(而非 toolName)。用户规则如 `read ~/.ssh/*` 的
73
+ * pattern `~/.ssh/*` 应对文件路径生效,对 toolName='read' 匹配是失效的。
74
+ * - tool 字段仍对 toolName 匹配(wildcard,支持 'read'/'write'/'*')。
75
+ * - pattern 字段对 path 匹配;path 为 undefined 时退化为对 toolName 匹配
76
+ * (兼容 rule.tool='read' pattern='*' 这种不关心路径的规则)。
77
+ * - pattern 字段用 wildcardToRegExp 编译(用户规则是 OpenCode wildcard)。
78
+ * - builtin-danger 规则的 pattern 是 RegExp 源串(含 \b),这里用 new RegExp(pattern,'i')。
79
+ * - 无匹配 → ask(与 G1 一致,deny 到下游)。
80
+ *
81
+ * ~20 行,复用 wildcardToRegExp(不重新实现 wildcard 语义)。
82
+ */
83
+ export function matchNonBashTool(
84
+ toolName: string,
85
+ path: string | undefined,
86
+ rules: readonly Rule[],
87
+ ): RuleMatchResult {
88
+ if (toolName.length === 0) {
89
+ return { action: "ask", matchedRule: undefined };
90
+ }
91
+ const matchTarget = path ?? toolName;
92
+ let winner: RuleMatchResult = { action: "ask", matchedRule: undefined };
93
+ for (const rule of rules) {
94
+ // tool 字段匹配(wildcard,支持 '*' / 精确 'read')
95
+ const toolRe = wildcardToRegExp(rule.tool);
96
+ if (!toolRe.test(toolName)) continue;
97
+ // pattern 字段:builtin-danger 是 RegExp 源串,其余是 wildcard。
98
+ const patternRe =
99
+ rule.source === "builtin-danger" ? new RegExp(rule.pattern, "i") : wildcardToRegExp(rule.pattern);
100
+ // M5:pattern 对 path 匹配(path 缺省时对 toolName,'*' 总是命中)。
101
+ if (patternRe.test(matchTarget)) {
102
+ winner = { action: rule.action, matchedRule: rule };
103
+ }
104
+ }
105
+ return winner;
106
+ }
107
+
108
+ /**
109
+ * 层 2 规则匹配编排(纯逻辑版,用注入的 matchArgv 回调;测试可直接调)。
110
+ *
111
+ * bash:argvList 多条命令,聚合:
112
+ * - 任一 argv deny → deny(最严格,一条危险即全拒)
113
+ * - 全部 allow → allow
114
+ * - 否则(含 ask / 混合)→ ask
115
+ * 此外 C1 修正:对 bash 工具的完整 command 字符串再做一次 deny 补充检查。
116
+ * 原因:AST 把 `curl x | sh` 按管道拆成 `[["curl","x"],["sh"]]`,对每个 argv 单独
117
+ * 匹配,单 argv 不含 `|`,bd-010(`curl|sh`)永不命中。此处用完整 command 串
118
+ * 额外评估 deny 规则(matchRules),命中 deny 则覆盖 argv 级结果(allow/ask → deny)。
119
+ * 完整字符串检查只用于 deny 补充,不影响 argv 级白名单(白名单只在 argv 级查)。
120
+ * 非 bash:matchNonBashTool(G1 + M5 对 path 匹配)。
121
+ *
122
+ * @param matchArgv 单 argv 匹配函数(生产用 matchRulesForArgv,测试可 mock)
123
+ * @param command bash 完整命令字符串(C1 补充检查用;非 bash 传 undefined)
124
+ * @param path 非 bash 工具的文件路径(M5 用;bash 传 undefined)
125
+ * @returns 层 2 决策(action + matchedRule)
126
+ */
127
+ export function runLayer2(
128
+ toolName: string,
129
+ argvList: string[][],
130
+ rules: readonly Rule[],
131
+ matchArgv: (argv: string[], rules: readonly Rule[]) => RuleMatchResult,
132
+ command: string | undefined = undefined,
133
+ path: string | undefined = undefined,
134
+ ): RuleMatchResult {
135
+ if (toolName !== "bash") {
136
+ return matchNonBashTool(toolName, path, rules);
137
+ }
138
+ if (argvList.length === 0) {
139
+ // bash 但 AST 没拆出命令(parseError 或空)→ 仍做 C1 完整字符串 deny 检查
140
+ // (命令本身可能含管道,AST 拆不出但 deny 规则能命中)
141
+ const fullCmdDeny = matchRules("bash", command, rules);
142
+ if (fullCmdDeny.action === "deny") return fullCmdDeny;
143
+ return { action: "ask", matchedRule: undefined };
144
+ }
145
+ let sawAsk = false;
146
+ let lastAllow: RuleMatchResult | undefined;
147
+ for (const argv of argvList) {
148
+ if (argv.length === 0) {
149
+ sawAsk = true;
150
+ continue;
151
+ }
152
+ const r = matchArgv(argv, rules);
153
+ if (r.action === "deny") return r;
154
+ if (r.action === "ask") sawAsk = true;
155
+ if (r.action === "allow") lastAllow = r;
156
+ }
157
+ // C1:对完整 command 字符串做 deny 补充检查(覆盖跨 argv 管道如 curl|sh)。
158
+ // 仅 deny 能覆盖 argv 级结果;完整字符串检查不查白名单(matchRules 语义),
159
+ // 故不会把 argv 级 deny/ask 提升为 allow。
160
+ const fullCmdDeny = matchRules("bash", command, rules);
161
+ if (fullCmdDeny.action === "deny") return fullCmdDeny;
162
+ if (sawAsk) return { action: "ask", matchedRule: undefined };
163
+ return lastAllow ?? { action: "ask", matchedRule: undefined };
164
+ }
165
+
166
+ // ──────────────────────── applyAutoApproveOverrides(W4 WT7 偏差补丁) ────────────────────────
167
+
168
+ /**
169
+ * W4 WT7 偏差补丁:根据 ClassifierConfig 调整 AI 分类结果。
170
+ *
171
+ * 规则(独立纯函数,便于单测):
172
+ * - risk='low' + outcome='allow' + autoApproveLowRisk=false → ask(不让 AI 自动放行低风险)
173
+ * - risk='high' + outcome='allow' + autoDenyHighRisk=true → deny(高风险即使 AI 说放行也拒)
174
+ * - 其余透传(含 outcome='deny'/'ask' 不改,避免与 autoApprove/autoDeny 语义冲突)
175
+ *
176
+ * 不修改原对象(返回新对象),保持 ClassifierResult 不可变语义。
177
+ */
178
+ export function applyAutoApproveOverrides(
179
+ result: ClassifierResult,
180
+ config: ClassifierConfig,
181
+ ): ClassifierResult {
182
+ // 高风险 + allow + autoDenyHighRisk → 强制 deny
183
+ if (result.risk_level === "high" && result.outcome === "allow" && config.autoDenyHighRisk) {
184
+ return {
185
+ ...result,
186
+ outcome: "deny",
187
+ reasoning: `auto-denied (high risk, autoDenyHighRisk=true); original: ${result.reasoning}`,
188
+ };
189
+ }
190
+ // 低风险 + allow + autoApproveLowRisk=false → 强制 ask(交人工)
191
+ if (result.risk_level === "low" && result.outcome === "allow" && !config.autoApproveLowRisk) {
192
+ return {
193
+ ...result,
194
+ outcome: "ask",
195
+ reasoning: `auto-ask (low risk but autoApproveLowRisk=false); original: ${result.reasoning}`,
196
+ };
197
+ }
198
+ return result;
199
+ }
200
+
201
+ // ──────────────────────── runLayer3WithRacing(G3 abort 时序) ────────────────────────
202
+
203
+ /**
204
+ * 层 3:AI Classifier + 用户审批竞速(移植 pi-permission-system classifier-racing.ts)。
205
+ *
206
+ * 竞速语义:
207
+ * - 启动 AI 分类(aiPromise)+ 用户审批(userPromise)。
208
+ * - 用户先返回 → controller.abort() 取消 AI(已无用的 AI 计算尽早终止)。
209
+ * - AI 先返回:
210
+ * - AI outcome='allow' → resolveUser({approved:false}) 关闭用户对话框,返回 allow。
211
+ * - AI outcome='deny' → resolveUser({approved:false}) 关闭用户对话框,返回 deny。
212
+ * - AI outcome='ask' → 不 resolveUser,等用户最终决策(AI 不确定时转人工)。
213
+ *
214
+ * G3 修正:requestUserApproval(TUI 分支)在调 ctx.ui.custom 前先检查 signal.aborted,
215
+ * 避免 AI 先于 UI factory 执行时 controller.abort() 触发但 comp 尚未创建导致 cancel 落空。
216
+ * 此处再叠一层:AI 赢且 outcome 是 allow/deny 时,先 abort 再 resolveUser,
217
+ * 保证 UI factory 看到 aborted 状态短路。
218
+ *
219
+ * @param deps 注入依赖(classifier + requestUserApproval)
220
+ * @param ctx 工具调用上下文
221
+ * @param config classifier 配置
222
+ * @param outerSignal 外层 signal(session abort 时传播)
223
+ * @param trigger 审批触发原因(m2:AST 检测到的具体危险结构等;默认通用文案)
224
+ * @returns PermissionDecision(source='ai' 或 'user')
225
+ */
226
+ export async function runLayer3WithRacing(
227
+ deps: CheckPermissionDeps,
228
+ ctx: ToolInvocationContext,
229
+ config: ClassifierConfig,
230
+ outerSignal: AbortSignal | undefined,
231
+ trigger: string = "awaiting approval (auto mode: AI classifier racing with user prompt)",
232
+ ): Promise<PermissionDecision> {
233
+ const controller = new AbortController();
234
+ // 外层 abort 传播到内层
235
+ if (outerSignal) {
236
+ if (outerSignal.aborted) controller.abort();
237
+ else outerSignal.addEventListener("abort", () => controller.abort(), { once: true });
238
+ }
239
+
240
+ // M4:aiPromise 挂 .catch 转 fallback(确保永不 reject)。
241
+ // classifier 内部有 try/catch,但 resolveModel/buildModel/buildContext 在 try 外,
242
+ // 用户先返回 abort 后 classifier 晚 reject → race 已 settle → unhandledRejection。
243
+ const aiPromise = deps.classifier
244
+ .classifyRisk(ctx, config, controller.signal)
245
+ .catch((err: unknown) => {
246
+ const msg = err instanceof Error ? err.message : String(err);
247
+ return {
248
+ outcome: "ask" as const,
249
+ risk_level: "medium" as const,
250
+ reasoning: `classifier error: ${msg}`,
251
+ confidence: 0,
252
+ };
253
+ });
254
+
255
+ // M1:headless 模式(json/print)下纯等 AI,不启动 user promise。
256
+ // 否则 requestHeadless 立即 deny 会抢占 race → AI 永远没机会赢 → auto 退化为 strict。
257
+ // AI 返回 allow/deny → 按结果返回;AI 返回 ask/超时/fail → fail-closed deny。
258
+ if (deps.isHeadless()) {
259
+ const aiResult = await aiPromise;
260
+ const overridden = applyAutoApproveOverrides(aiResult, config);
261
+ if (overridden.outcome === "allow") {
262
+ controller.abort();
263
+ return {
264
+ action: "allow",
265
+ reason: overridden.reasoning,
266
+ source: "ai",
267
+ riskLevel: overridden.risk_level,
268
+ confidence: overridden.confidence,
269
+ };
270
+ }
271
+ if (overridden.outcome === "deny") {
272
+ controller.abort();
273
+ return {
274
+ action: "deny",
275
+ reason: overridden.reasoning,
276
+ source: "ai",
277
+ riskLevel: overridden.risk_level,
278
+ confidence: overridden.confidence,
279
+ };
280
+ }
281
+ // AI ask → headless 无 UI 可问 → fail-closed deny
282
+ controller.abort();
283
+ return {
284
+ action: "deny",
285
+ reason: "headless mode: AI inconclusive (ask), fail-closed deny",
286
+ source: "ai",
287
+ riskLevel: overridden.risk_level,
288
+ confidence: overridden.confidence,
289
+ };
290
+ }
291
+
292
+ // 用户审批 promise:可被 resolveUser 外部 resolve(AI 赢时关闭对话框)
293
+ let resolveUser: (d: UserDecision) => void = () => {
294
+ /* 占位,下方立即覆写 */
295
+ };
296
+ const userPromise = new Promise<UserDecision>((resolve) => {
297
+ resolveUser = resolve;
298
+ });
299
+
300
+ const req = buildApprovalRequest(ctx.toolName, ctx.command, trigger);
301
+
302
+ // 启动用户审批(真实对话框)。用户先返回 → abort AI。
303
+ //
304
+ // W6 G6(F9 结论固化):userPromise 与 realUserPromise 双 promise 都是必要的:
305
+ // - realUserPromise:承载 requestUserApproval 的真实返回值 + abort 副作用
306
+ // (用户决策后 controller.abort() 取消 AI)。它驱动 AI-ask 分支的最终 await。
307
+ // - userPromise:可被 resolveUser 外部 resolve(AI 赢时关闭对话框),用于 race
308
+ // 透传(AI allow/deny 时 resolveUser 关闭 UI,userPromise 立即 settle)。
309
+ // 删除任一都会破坏一种竞态:仅 realUserPromise → AI 赢时无法关闭对话框;
310
+ // 仅 userPromise → AI-ask 分支无法拿到用户的最终决策(userPromise 只能被
311
+ // resolveUser resolve,AI-ask 时不调 resolveUser)。F9 测试已验证两者缺一不可。
312
+ const realUserPromise = deps.requestUserApproval(req, ctx, controller.signal).then((d) => {
313
+ controller.abort(); // 用户已决策,取消 AI
314
+ resolveUser(d); // 透传给 userPromise(race 用)
315
+ return d;
316
+ });
317
+
318
+ // race:AI vs 用户。AI outcome='ask' 时仍可能先 settle,但语义是「转人工」,
319
+ // 此时不能 resolveUser 关闭对话框——需等用户。故 race 后据 AI outcome 分支。
320
+ const aiSettled = await Promise.race([
321
+ aiPromise.then((r) => ({ kind: "ai" as const, result: r })),
322
+ userPromise.then((d) => ({ kind: "user" as const, decision: d })),
323
+ realUserPromise.then((d) => ({ kind: "user" as const, decision: d })),
324
+ ]);
325
+
326
+ if (aiSettled.kind === "user") {
327
+ // 用户赢了(或 AI ask 后用户最终决策)
328
+ const d = aiSettled.decision;
329
+ const action: PermissionAction = d.approved ? "allow" : "deny";
330
+ return {
331
+ action,
332
+ reason: d.reason ?? (d.approved ? "approved by user" : "denied by user"),
333
+ source: "user",
334
+ };
335
+ }
336
+
337
+ // AI 赢
338
+ const overridden = applyAutoApproveOverrides(aiSettled.result, config);
339
+ if (overridden.outcome === "allow") {
340
+ // AI 放行 → 关闭用户对话框(若已弹出),返回 allow
341
+ controller.abort();
342
+ resolveUser({ approved: false, reason: "AI classifier allowed; dialog dismissed" });
343
+ return {
344
+ action: "allow",
345
+ reason: overridden.reasoning,
346
+ source: "ai",
347
+ riskLevel: overridden.risk_level,
348
+ confidence: overridden.confidence,
349
+ };
350
+ }
351
+ if (overridden.outcome === "deny") {
352
+ controller.abort();
353
+ resolveUser({ approved: false, reason: "AI classifier denied; dialog dismissed" });
354
+ return {
355
+ action: "deny",
356
+ reason: overridden.reasoning,
357
+ source: "ai",
358
+ riskLevel: overridden.risk_level,
359
+ confidence: overridden.confidence,
360
+ };
361
+ }
362
+ // AI ask → 转 human(等用户最终决策,不关闭对话框)
363
+ // M3:超时兜底防止永久挂起。若 AI 返回 ask 后用户恰好与 AI 在同一 tick 操作,
364
+ // comp.cancel() 因 _resolved 守卫不调 done → requestUserApproval promise 永不
365
+ // resolve → realUserPromise pending → 永久挂起(G5 串行化放大为全链卡死)。
366
+ // 5 分钟超时后 fail-closed 拒绝(不静默放行)。
367
+ const APPROVAL_TIMEOUT_MS = 300_000;
368
+ const userFinal = await Promise.race<UserDecision>([
369
+ realUserPromise,
370
+ new Promise<UserDecision>((resolve) =>
371
+ setTimeout(
372
+ () => resolve({ approved: false, reason: "approval dialog timeout (fail-closed)" }),
373
+ APPROVAL_TIMEOUT_MS,
374
+ ),
375
+ ),
376
+ ]);
377
+ const action: PermissionAction = userFinal.approved ? "allow" : "deny";
378
+ return {
379
+ action,
380
+ reason: userFinal.reason ?? (userFinal.approved ? "approved by user (after AI ask)" : "denied by user (after AI ask)"),
381
+ source: "user",
382
+ };
383
+ }
384
+
385
+ // ──────────────────────── checkPermission(纯函数主入口) ────────────────────────
386
+
387
+ /**
388
+ * 权限检查主入口(纯函数,deps 注入)。
389
+ *
390
+ * 四档模式分支:
391
+ * - yolo:完全放行(return allow,source='mode')。不跑任何层。
392
+ * - auto:AST → 规则(allow 通过 / deny 拒绝 / ask → 层 3 Racing AI+用户)。
393
+ * - approve:AST → 规则(allow 通过 / deny+ask → 人工审批,无 AI)。
394
+ * - strict:全部人工审批(不跑 AST/规则/AI)。
395
+ *
396
+ * config.enabled=false 等同 yolo(保留配置但不拦截)。
397
+ * 永不 throw(fail-closed:异常 → ask)。
398
+ *
399
+ * @param toolName 工具名
400
+ * @param input 工具输入(bash={command}, 其他={path,...})。command 用 AST/规则,其余字段透传给 UI。
401
+ * @param mode 当前权限模式
402
+ * @param config classifier 配置(auto 模式用)
403
+ * @param userRules 用户规则(与 getDefaultRules 拼接)
404
+ * @param deps 注入依赖
405
+ * @param ctxBase 工具调用上下文基线(cwd/agentName)
406
+ * @param signal 外层 abort signal
407
+ */
408
+ export async function checkPermission(
409
+ toolName: string,
410
+ input: Record<string, unknown>,
411
+ mode: PermissionMode,
412
+ config: ClassifierConfig,
413
+ userRules: Rule[],
414
+ deps: CheckPermissionDeps,
415
+ ctxBase: { cwd: string; agentName?: string; signal?: AbortSignal },
416
+ ): Promise<PermissionDecision> {
417
+ // yolo / disabled → 完全放行
418
+ if (mode === "yolo") {
419
+ return { action: "allow", reason: "yolo mode: all tools allowed", source: "mode" };
420
+ }
421
+
422
+ const command = extractCommand(toolName, input);
423
+ const ctx: ToolInvocationContext = {
424
+ toolName,
425
+ ...(command !== undefined ? { command } : {}),
426
+ path: typeof input.path === "string" ? input.path : undefined,
427
+ cwd: ctxBase.cwd,
428
+ ...(ctxBase.agentName !== undefined ? { agentName: ctxBase.agentName } : {}),
429
+ };
430
+
431
+ // strict → 全部人工审批(不跑 AST/规则/AI)
432
+ if (mode === "strict") {
433
+ return await askUser(deps, ctx, "strict mode: all tools require approval", ctxBase.signal);
434
+ }
435
+
436
+ // auto / approve:层 1 AST + 层 2 规则
437
+ let argvList: string[][] = [];
438
+ if (toolName === "bash" && command !== undefined) {
439
+ try {
440
+ const analysis = await deps.analyzeBashStructure(command);
441
+ argvList = analysis.commands;
442
+ // AST 检测到危险结构(非 clean)→ 直接 ask(auto→AI,approve→人工)
443
+ if (!analysis.clean) {
444
+ const trigger = `AST detected dangerous structure: ${analysis.dangerousStructures.join(", ") || "unknown"}`;
445
+ if (mode === "approve") {
446
+ return await askUser(deps, ctx, trigger, ctxBase.signal);
447
+ }
448
+ // auto:进层 3(AI 评估危险命令)。m2:透传 AST 检测的具体危险原因。
449
+ return await runLayer3WithRacing(deps, ctx, config, ctxBase.signal, trigger);
450
+ }
451
+ } catch {
452
+ // AST 异常 → fail-closed ask(auto→AI,approve→人工)
453
+ if (mode === "approve") {
454
+ return await askUser(deps, ctx, "AST analysis failed (fail-closed)", ctxBase.signal);
455
+ }
456
+ return await runLayer3WithRacing(deps, ctx, config, ctxBase.signal);
457
+ }
458
+ }
459
+
460
+ // 层 2 规则(auto + approve 共用)
461
+ const rules = [...deps.getDefaultRules(), ...userRules];
462
+ // C1:传入完整 command(bash 跨 argv 管道 deny 补充检查);
463
+ // M5:传入 path(非 bash 工具规则对 path 匹配)。
464
+ const layer2 = runLayer2ForArgvList(toolName, argvList, rules, deps, command, ctx.path);
465
+
466
+ if (layer2.action === "allow") {
467
+ return {
468
+ action: "allow",
469
+ reason: layer2.matchedRule
470
+ ? `allowed by rule ${layer2.matchedRule.id}`
471
+ : "allowed by rules (no deny matched)",
472
+ source: "rule",
473
+ ...(layer2.matchedRule ? { matchedRule: layer2.matchedRule } : {}),
474
+ };
475
+ }
476
+ if (layer2.action === "deny") {
477
+ return {
478
+ action: "deny",
479
+ reason: layer2.matchedRule
480
+ ? `denied by rule ${layer2.matchedRule.id}`
481
+ : "denied by rules",
482
+ source: "rule",
483
+ ...(layer2.matchedRule ? { matchedRule: layer2.matchedRule } : {}),
484
+ };
485
+ }
486
+
487
+ // layer2.action === 'ask' → 下游
488
+ if (mode === "approve") {
489
+ // approve:ask → 人工审批(无 AI)
490
+ return await askUser(deps, ctx, "no matching allow rule (approve mode)", ctxBase.signal);
491
+ }
492
+
493
+ // auto:ask → 层 3 Racing(AI + 用户)
494
+ return await runLayer3WithRacing(deps, ctx, config, ctxBase.signal);
495
+ }
496
+
497
+ /**
498
+ * 层 2 规则匹配(用注入的 deps.matchRulesForArgv)。
499
+ * 薄封装 runLayer2,把注入的 matcher 传入纯逻辑版。
500
+ */
501
+ function runLayer2ForArgvList(
502
+ toolName: string,
503
+ argvList: string[][],
504
+ rules: readonly Rule[],
505
+ deps: CheckPermissionDeps,
506
+ command: string | undefined,
507
+ path: string | undefined,
508
+ ): RuleMatchResult {
509
+ return runLayer2(toolName, argvList, rules, deps.matchRulesForArgv, command, path);
510
+ }
511
+
512
+ /** approve/strict 模式的人工审批封装(无 AI)。 */
513
+ async function askUser(
514
+ deps: CheckPermissionDeps,
515
+ ctx: ToolInvocationContext,
516
+ trigger: string,
517
+ signal: AbortSignal | undefined,
518
+ ): Promise<PermissionDecision> {
519
+ const req = buildApprovalRequest(ctx.toolName, ctx.command, trigger);
520
+ const decision = await deps.requestUserApproval(req, ctx, signal);
521
+ return {
522
+ action: decision.approved ? "allow" : "deny",
523
+ reason: decision.reason ?? (decision.approved ? "approved by user" : "denied by user"),
524
+ source: "user",
525
+ };
526
+ }
527
+
528
+ /** 从 input 提取 bash command(仅 bash 工具)。 */
529
+ function extractCommand(toolName: string, input: Record<string, unknown>): string | undefined {
530
+ if (toolName !== "bash") return undefined;
531
+ const cmd = input.command;
532
+ if (cmd === undefined) return undefined;
533
+ if (typeof cmd === "string") return cmd;
534
+ // m3:bash + command 非字符串(异常输入)→ 记录警告,fail-closed 送下游
535
+ // (不静默跳过 AST,避免危险命令因类型异常绕过检查)。
536
+ console.warn(
537
+ `[pi-permission] bash tool received non-string command (type=${typeof cmd}); ` +
538
+ `skipping AST analysis (fail-closed: forwarded to downstream layers)`,
539
+ );
540
+ return undefined;
541
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * 生产装配层(W5 集成层)。
3
+ *
4
+ * 把 W2/W3/W4 的真实实现 + W5 的 pipeline/approval 装配成 CheckPermissionDeps,
5
+ * 供 index.ts 的 tool_call handler 使用。
6
+ *
7
+ * - createProductionClassifier:移植 pi-permission-system ai-classifier.ts:315-331。
8
+ * 用 getApiProvider 解析 provider → streamSimple,注入 createClassifier。
9
+ * - createPipelineDeps:装配完整 CheckPermissionDeps(AST + rules + classifier + approval)。
10
+ *
11
+ * 设计:装配函数接受 ApprovalContext(从 ExtensionContext 提取),不直接依赖完整
12
+ * ExtensionContext 类型,便于测试 + 解耦。
13
+ */
14
+
15
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
16
+ // @ts-ignore - getApiProvider resolves via root tsconfig stub but per-package tsc paths differ
17
+ import { getApiProvider } from "@earendil-works/pi-ai";
18
+
19
+ import { type ApprovalContext, requestUserApproval } from "./approval.js";
20
+ import { analyzeBashStructure } from "./ast/index.js";
21
+ import { createClassifier } from "./classifier/index.js";
22
+ import { resolveClassifierModel } from "./classifier/index.js";
23
+ import type { CheckPermissionDeps } from "./pipeline.js";
24
+ import { getDefaultRules, matchRulesForArgv } from "./rules/index.js";
25
+ import type { ClassifierConfig } from "./types.js";
26
+
27
+ // ──────────────────────── createProductionClassifier ────────────────────────
28
+
29
+ /**
30
+ * 创建生产环境 AI Classifier(移植 pi-permission-system ai-classifier.ts:315-331)。
31
+ *
32
+ * 流程:
33
+ * - resolveModel:用 resolveClassifierModel 把 ClassifierConfig.model 解析为 ResolvedModel。
34
+ * - streamSimple:用 getApiProvider(model.api) 拿 provider,调 provider.streamSimple。
35
+ * 无 provider → throw(caller 捕获转 fallback,但 classifier 内部已 try/catch streamSimple)。
36
+ * - onLog:console.warn(生产日志)。
37
+ *
38
+ * 返回 createClassifier 的结果({ classifyRisk })。
39
+ */
40
+ export function createProductionClassifier(): {
41
+ classifyRisk: ReturnType<typeof createClassifier>["classifyRisk"];
42
+ } {
43
+ return createClassifier({
44
+ resolveModel: (config: ClassifierConfig) => resolveClassifierModel(config.model),
45
+ streamSimple: (model, context, options) => {
46
+ const provider = getApiProvider(model.api);
47
+ if (!provider) {
48
+ throw new Error(`[pi-permission] No API provider registered for api: ${model.api}`);
49
+ }
50
+ return provider.streamSimple(model, context, options);
51
+ },
52
+ onLog: (msg: string) => console.warn(msg),
53
+ });
54
+ }
55
+
56
+ // ──────────────────────── createPipelineDeps ────────────────────────
57
+
58
+ /**
59
+ * 模块级 classifier 单例(m1 修正)。
60
+ *
61
+ * classifier 工厂返回 { classifyRisk },本身无状态(classifyRisk 每次调用自包含所有输入),
62
+ * 故模块级单例是安全的(不违反 session 隔离:不同 session 仅 config/rules 不同,classifyRisk 接收
63
+ * 这些参数而非持有状态)。
64
+ *
65
+ * 采用惰性初始化(首次 createPipelineDeps 时创建)而非 import-time 副作用:
66
+ * - 避免模块加载时即创建 classifier(若 classifier 创建有副作用如读 models.json,import 时触发不直观)。
67
+ * - index.ts 的 processToolCall 每次 tool_call 都调 createPipelineDeps,惰性单例保证 classifier
68
+ * 全进程只创建一次(修复原实现每次 tool_call 重建 classifier 的问题)。
69
+ */
70
+ let classifierSingleton: { classifyRisk: ReturnType<typeof createClassifier>["classifyRisk"] } | undefined;
71
+
72
+ /**
73
+ * 测试用:重置 classifier 单例(强制下次 createPipelineDeps 重建)。
74
+ * @internal 仅测试使用,生产代码不应调用。
75
+ */
76
+ export function _resetClassifierSingletonForTest(): void {
77
+ classifierSingleton = undefined;
78
+ }
79
+
80
+ /**
81
+ * 装配生产环境 CheckPermissionDeps。
82
+ *
83
+ * @param approvalCtx 从 ExtensionContext 提取的审批 UI 上下文(mode + ui.*)
84
+ * @returns CheckPermissionDeps(AST + rules + classifier + approval 全部装配真实实现)
85
+ *
86
+ * 注:classifier 单例(m1:模块级惰性初始化,全进程只创建一次)。approvalCtx 每次 tool_call
87
+ * 闭包捕获(保证 mode 切换后下次 tool_call 用新 mode —— 但 mode 在 session 内稳定,
88
+ * 切换走 /permission 命令重载 config,approvalCtx.mode 反映运行时 mode)。
89
+ */
90
+ export function createPipelineDeps(approvalCtx: ApprovalContext): CheckPermissionDeps {
91
+ if (!classifierSingleton) {
92
+ classifierSingleton = createProductionClassifier();
93
+ }
94
+ return {
95
+ analyzeBashStructure,
96
+ matchRulesForArgv,
97
+ getDefaultRules,
98
+ classifier: classifierSingleton,
99
+ isHeadless: () => approvalCtx.mode !== "tui" && approvalCtx.mode !== "rpc",
100
+ requestUserApproval: (req, ctx, signal) => requestUserApproval(req, ctx, signal, approvalCtx),
101
+ };
102
+ }