@zhushanwen/pi-permission 1.4.2 → 1.4.4

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.
@@ -21,13 +21,11 @@
21
21
 
22
22
  import { type Component, Container, type SelectItem, SelectList, type SelectListTheme, truncateToWidth } from "@earendil-works/pi-tui";
23
23
  import type { Api, Model } from "@earendil-works/pi-ai";
24
+ import { parseModelRef } from "@zhushanwen/pi-llm-shared";
24
25
 
25
26
  import type { ResolvedModelEntry } from "./classifier/model-resolver.js";
26
27
  import { DEFAULT_SELECT_THEME } from "./select-theme.js";
27
28
 
28
- // 保持既有 public 名(model-picker.test.ts MPT7 直接消费此路径)。
29
- export { DEFAULT_SELECT_THEME };
30
-
31
29
  // ──────────────────────── 类型 ────────────────────────
32
30
 
33
31
  /** 选择结果(discriminated union);undefined = cancel / 降级。 */
@@ -134,11 +132,6 @@ export class ProviderModelSelectorComponent extends Container {
134
132
  return lines;
135
133
  }
136
134
 
137
- /** 退出(外部 abort 用)。复用 _resolved 守卫防二次 done。 */
138
- cancel(): void {
139
- this.settle(undefined);
140
- }
141
-
142
135
  private settle(result: SelectionResult | undefined): void {
143
136
  if (this._resolved) return;
144
137
  this._resolved = true;
@@ -190,13 +183,16 @@ export class ProviderModelSelectorComponent extends Container {
190
183
  return items;
191
184
  }
192
185
 
193
- /** 计算 provider stage 预选 index(currentSpec='auto' → 0;'provider/model' → findIndex)。 */
186
+ /**
187
+ * 计算 provider stage 预选 index(currentSpec='auto' → 0;'provider/model' → findIndex)。
188
+ * 无效 ref(缺 / 或前后为空,如 'provider/')→ parseModelRef 返 null → 0(整体回 Auto,
189
+ * ext-simplify-18 D4 登记的行为微变:不再预选半截 provider)。
190
+ */
194
191
  private computeProviderSelectedIndex(): number {
195
192
  if (this.currentSpec === "auto") return 0;
196
- const slashIdx = this.currentSpec.indexOf("/");
197
- if (slashIdx <= 0) return 0;
198
- const provider = this.currentSpec.slice(0, slashIdx);
199
- const idx = this.providers.indexOf(provider);
193
+ const parsed = parseModelRef(this.currentSpec);
194
+ if (!parsed) return 0;
195
+ const idx = this.providers.indexOf(parsed.provider);
200
196
  return idx >= 0 ? idx + 1 : 0; // +1 跳过 'Auto'
201
197
  }
202
198
 
@@ -235,12 +231,11 @@ export class ProviderModelSelectorComponent extends Container {
235
231
  return list;
236
232
  }
237
233
 
238
- /** 计算 model stage 预选 index。 */
234
+ /** 计算 model stage 预选 index(无效 ref → parseModelRef 返 null → 0)。 */
239
235
  private computeModelSelectedIndex(models: readonly ResolvedModelEntry[]): number {
240
- const slashIdx = this.currentSpec.indexOf("/");
241
- if (slashIdx <= 0) return 0;
242
- const modelId = this.currentSpec.slice(slashIdx + 1);
243
- const idx = models.findIndex((m) => m.id === modelId);
236
+ const parsed = parseModelRef(this.currentSpec);
237
+ if (!parsed) return 0;
238
+ const idx = models.findIndex((m) => m.id === parsed.modelId);
244
239
  return idx >= 0 ? idx : 0;
245
240
  }
246
241
 
@@ -282,34 +277,28 @@ export interface ModelPickerContext {
282
277
  };
283
278
  }
284
279
 
285
- /** listAvailableModels 注入签名(E2 改走 ctx.modelRegistry,签名带 ctx;便于测试 mock)。 */
286
- export type ListAvailableModelsFn = (
287
- ctx: ModelPickerContext,
288
- ) => Map<string, ResolvedModelEntry[]>;
289
-
290
280
  /**
291
281
  * 通过 overlay 选择模型(provider/model 或 auto)。
292
282
  *
293
283
  * @param ctx UI 上下文(mode + ui.*)
294
284
  * @param currentSpec 当前 classifier.model('auto' 或 'provider/model-id'),用于预选
295
- * @param models 可选,预加载的 models Map(避免重复读盘);未传则用默认 listAvailableModels
285
+ * @param models 可用模型 Map(listAvailableModels 产物,按 provider 分组;调用方判空降级)
296
286
  * @returns 'auto' / 'provider/model-id' / undefined(cancel 或 headless 降级)
297
287
  */
298
288
  export async function pickModelViaOverlay(
299
289
  ctx: ModelPickerContext,
300
290
  currentSpec: string,
301
- models?: Map<string, ResolvedModelEntry[]>,
291
+ models: Map<string, ResolvedModelEntry[]>,
302
292
  ): Promise<string | undefined> {
303
- const modelsByProvider = models ?? listAvailableModelsDefault(ctx);
304
- if (modelsByProvider.size === 0) return undefined;
293
+ if (models.size === 0) return undefined;
305
294
 
306
- const providers = [...modelsByProvider.keys()];
295
+ const providers = [...models.keys()];
307
296
 
308
297
  switch (ctx.mode) {
309
298
  case "tui":
310
- return await pickViaTui(ctx, currentSpec, providers, modelsByProvider);
299
+ return await pickViaTui(ctx, currentSpec, providers, models);
311
300
  case "rpc":
312
- return await pickViaRpc(ctx, currentSpec, providers, modelsByProvider);
301
+ return await pickViaRpc(ctx, currentSpec, providers, models);
313
302
  case "json":
314
303
  case "print":
315
304
  default:
@@ -317,14 +306,6 @@ export async function pickModelViaOverlay(
317
306
  }
318
307
  }
319
308
 
320
- /** 默认 listAvailableModels(生产路径用,index.ts 装配时注入真实实现)。 */
321
- let listAvailableModelsDefault: ListAvailableModelsFn = (): Map<string, ResolvedModelEntry[]> => new Map();
322
-
323
- /** 注入默认 listAvailableModels 实现(index.ts 装配时调用,或测试覆盖)。 */
324
- export function setDefaultListAvailableModels(fn: ListAvailableModelsFn): void {
325
- listAvailableModelsDefault = fn;
326
- }
327
-
328
309
  // ──────────────────────── TUI 分支 ────────────────────────
329
310
 
330
311
  /**
package/src/pipeline.ts CHANGED
@@ -4,8 +4,8 @@
4
4
  * 架构(自上而下):
5
5
  * - checkPermission:纯函数,按 mode 分发。deps 全注入(测试可 mock,不触碰 fs/network)。
6
6
  * - runLayer2:bash 走 matchRulesForArgv(遍历 AST 拆出的 argv[]);非 bash 走
7
- * matchNonBashTool(G1:W3 matcher 对 toolName!=='bash' 直接 ask,但 W5 需要让
8
- * Read/Write/Edit 也评估用户规则,故本模块自带 helper,复用 wildcardToRegExp)。
7
+ * matchNonBashTool(G1:让 Read/Write/Edit 也评估用户规则;pattern 双语义与
8
+ * last-match-wins 循环均经 matcher 单源,wildcardToRegExp 只编译 tool 字段)。
9
9
  * - runLayer3WithRacing:AI Classifier + 用户审批竞速(移植 classifier-racing.ts)。
10
10
  * G3:用户审批的 ctx.ui.custom 工厂在调之前先检查 signal.aborted,避免 AI 先于
11
11
  * UI 触发 abort 时 comp 尚未创建导致 cancel 落空。
@@ -14,10 +14,16 @@
14
14
  *
15
15
  * fail-closed 原则:任何异常路径 → ask(交人工,不静默放行)。
16
16
  * checkPermission 永不 throw(caller index.ts 的 tool_call handler 依赖此契约)。
17
+ *
18
+ * 白盒导出声明(E9):buildApprovalRequest / matchNonBashTool / runLayer2 /
19
+ * applyAutoApproveOverrides / runLayer3WithRacing 是内部测试 seam,非包公共 API
20
+ * (包 main 只导出工厂函数,外部消费者不存在)——pipeline.test.ts / e2e-modes.test.ts
21
+ * 白盒直测锚定安全语义(runLayer2 聚合表 / matchNonBashTool 非 bash 语义),故保留。
17
22
  */
18
23
 
24
+ import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
19
25
  import { getLogger } from "@zhushanwen/pi-extension-logger";
20
- import { matchRules } from "./rules/matcher.js";
26
+ import { lastMatchWins, matchRules, resolvePattern } from "./rules/matcher.js";
21
27
  import { wildcardToRegExp } from "./rules/wildcard.js";
22
28
  import type {
23
29
  ApprovalRequest,
@@ -68,20 +74,20 @@ export function buildApprovalRequest(
68
74
  /**
69
75
  * G1 修正:非 bash 工具规则匹配 helper。
70
76
  *
71
- * W3 matcher.ts 的 matchRules 对 toolName!=='bash' 直接返回 ask(W3 只管 bash),
72
- * 但 W5 需要让 Read/Write/Edit 也评估用户规则(用户可能写 `read ~/.ssh/*` 的 deny 规则)。
73
- * 故本模块自带 helper,不依赖 matchRules 的非 bash 路径。
77
+ * W5 需要让 Read/Write/Edit 也评估用户规则(用户可能写 `read ~/.ssh/*` 的 deny 规则),
78
+ * matcher 的 bash 入口不覆盖该场景,故本模块自带 helper。
74
79
  *
75
80
  * M5 修正:pattern 对 path 匹配(而非 toolName)。用户规则如 `read ~/.ssh/*` 的
76
81
  * pattern `~/.ssh/*` 应对文件路径生效,对 toolName='read' 匹配是失效的。
77
82
  * - tool 字段仍对 toolName 匹配(wildcard,支持 'read'/'write'/'*')。
78
83
  * - pattern 字段对 path 匹配;path 为 undefined 时退化为对 toolName 匹配
79
84
  * (兼容 rule.tool='read' pattern='*' 这种不关心路径的规则)。
80
- * - pattern 字段用 wildcardToRegExp 编译(用户规则是 OpenCode wildcard)。
81
- * - builtin-danger 规则的 pattern 是 RegExp 源串(含 \b),这里用 new RegExp(pattern,'i')。
85
+ * - pattern 双语义分发(builtin-danger RegExp 源串 vs 其余 wildcard)走
86
+ * matcher.resolvePattern 单源(E7/M10,含 patternCache 复用)。
82
87
  * - 无匹配 → ask(与 G1 一致,deny 到下游)。
83
88
  *
84
- * ~20 行,复用 wildcardToRegExp(不重新实现 wildcard 语义)。
89
+ * 复用 wildcardToRegExp(tool 字段编译)/ resolvePattern / lastMatchWins,
90
+ * 不重新实现任何匹配语义。
85
91
  */
86
92
  export function matchNonBashTool(
87
93
  toolName: string,
@@ -92,20 +98,13 @@ export function matchNonBashTool(
92
98
  return { action: "ask", matchedRule: undefined };
93
99
  }
94
100
  const matchTarget = path ?? toolName;
95
- let winner: RuleMatchResult = { action: "ask", matchedRule: undefined };
96
- for (const rule of rules) {
97
- // tool 字段匹配(wildcard,支持 '*' / 精确 'read')
98
- const toolRe = wildcardToRegExp(rule.tool);
99
- if (!toolRe.test(toolName)) continue;
100
- // pattern 字段:builtin-danger 是 RegExp 源串,其余是 wildcard。
101
- const patternRe =
102
- rule.source === "builtin-danger" ? new RegExp(rule.pattern, "i") : wildcardToRegExp(rule.pattern);
103
- // M5:pattern 对 path 匹配(path 缺省时对 toolName,'*' 总是命中)。
104
- if (patternRe.test(matchTarget)) {
105
- winner = { action: rule.action, matchedRule: rule };
106
- }
107
- }
108
- return winner;
101
+ // M5:pattern 对 path 匹配(path 缺省时对 toolName,'*' 总是命中);
102
+ // E11:last-match-wins 循环走 matcher 单源(tool 不匹配时短路,不编译 pattern)。
103
+ return lastMatchWins(
104
+ rules,
105
+ (rule) =>
106
+ wildcardToRegExp(rule.tool).test(toolName) && resolvePattern(rule).test(matchTarget),
107
+ );
109
108
  }
110
109
 
111
110
  /**
@@ -141,7 +140,7 @@ export function runLayer2(
141
140
  if (argvList.length === 0) {
142
141
  // bash 但 AST 没拆出命令(parseError 或空)→ 仍做 C1 完整字符串 deny 检查
143
142
  // (命令本身可能含管道,AST 拆不出但 deny 规则能命中)
144
- const fullCmdDeny = matchRules("bash", command, rules);
143
+ const fullCmdDeny = matchRules(command, rules);
145
144
  if (fullCmdDeny.action === "deny") return fullCmdDeny;
146
145
  return { action: "ask", matchedRule: undefined };
147
146
  }
@@ -160,7 +159,7 @@ export function runLayer2(
160
159
  // C1:对完整 command 字符串做 deny 补充检查(覆盖跨 argv 管道如 curl|sh)。
161
160
  // 仅 deny 能覆盖 argv 级结果;完整字符串检查不查白名单(matchRules 语义),
162
161
  // 故不会把 argv 级 deny/ask 提升为 allow。
163
- const fullCmdDeny = matchRules("bash", command, rules);
162
+ const fullCmdDeny = matchRules(command, rules);
164
163
  if (fullCmdDeny.action === "deny") return fullCmdDeny;
165
164
  if (sawAsk) return { action: "ask", matchedRule: undefined };
166
165
  return lastAllow ?? { action: "ask", matchedRule: undefined };
@@ -224,7 +223,7 @@ function startAiClassification(
224
223
  signal: AbortSignal,
225
224
  ): Promise<ClassifierResult> {
226
225
  return deps.classifier.classifyRisk(ctx, config, signal).catch((err: unknown) => {
227
- const msg = err instanceof Error ? err.message : String(err);
226
+ const msg = toErrorMessage(err);
228
227
  return {
229
228
  outcome: "ask" as const,
230
229
  risk_level: "medium" as const,
@@ -554,8 +553,7 @@ async function runAstLayer(
554
553
  * @param config classifier 配置(auto 模式用)
555
554
  * @param userRules 用户规则(与 getDefaultRules 拼接)
556
555
  * @param deps 注入依赖
557
- * @param ctxBase 工具调用上下文基线(cwd/agentName)
558
- * @param signal 外层 abort signal
556
+ * @param ctxBase 工具调用上下文基线(cwd/agentName,含 signal)
559
557
  */
560
558
  export async function checkPermission(
561
559
  toolName: string,
@@ -589,7 +587,7 @@ export async function checkPermission(
589
587
  const rules = [...deps.getDefaultRules(), ...userRules];
590
588
  // C1:传入完整 command(bash 跨 argv 管道 deny 补充检查);
591
589
  // M5:传入 path(非 bash 工具规则对 path 匹配)。
592
- const layer2 = runLayer2ForArgvList(toolName, ast.argvList, rules, deps, command, ctx.path);
590
+ const layer2 = runLayer2(toolName, ast.argvList, rules, deps.matchRulesForArgv, command, ctx.path);
593
591
 
594
592
  const decided = ruleDecision(layer2);
595
593
  if (decided !== null) {
@@ -606,21 +604,6 @@ export async function checkPermission(
606
604
  return await runLayer3WithRacing(deps, ctx, config, ctxBase.signal);
607
605
  }
608
606
 
609
- /**
610
- * 层 2 规则匹配(用注入的 deps.matchRulesForArgv)。
611
- * 薄封装 runLayer2,把注入的 matcher 传入纯逻辑版。
612
- */
613
- function runLayer2ForArgvList(
614
- toolName: string,
615
- argvList: string[][],
616
- rules: readonly Rule[],
617
- deps: CheckPermissionDeps,
618
- command: string | undefined,
619
- path: string | undefined,
620
- ): RuleMatchResult {
621
- return runLayer2(toolName, argvList, rules, deps.matchRulesForArgv, command, path);
622
- }
623
-
624
607
  /** approve/strict 模式的人工审批封装(无 AI)。 */
625
608
  async function askUser(
626
609
  deps: CheckPermissionDeps,
@@ -2,7 +2,7 @@
2
2
  * W8: Rule editor overlay — three-mode dispatch (TUI / RPC / headless).
3
3
  *
4
4
  * editRulesViaOverlay:主入口,按 ctx.mode 分发。
5
- * editViaRpc:RPC 循环(list → action → fill → ...),deps 注入。
5
+ * editViaRpc:RPC 循环(list → action → fill → ...)。
6
6
  * G16:RPC listOptions 用 applyOps(initialRules, ops) 重建。
7
7
  */
8
8
 
@@ -12,9 +12,6 @@ import type { Rule } from "./types.js";
12
12
 
13
13
  // ──────────────────────── 类型 ────────────────────────
14
14
 
15
- /** RuleOp re-export(rule-templates.ts 定义)。 */
16
- export type { RuleOp } from "./rule-templates.js";
17
-
18
15
  /** 编辑结果:RuleOp[](有变更)或 undefined(cancel / headless 降级)。 */
19
16
  export type RuleEditorResult = RuleOp[] | undefined;
20
17
 
@@ -33,12 +30,6 @@ export interface RuleEditorContext {
33
30
  };
34
31
  }
35
32
 
36
- /** editViaRpc 依赖注入(便于测试 mock)。 */
37
- export interface RuleEditorRpcDeps {
38
- select: (title: string, options: string[]) => Promise<string | undefined>;
39
- input: (title: string, placeholder?: string) => Promise<string | undefined>;
40
- }
41
-
42
33
  // ──────────────────────── editRulesViaOverlay(主入口) ────────────────────────
43
34
 
44
35
  /**
@@ -47,20 +38,18 @@ export interface RuleEditorRpcDeps {
47
38
  * @param ctx UI 上下文(mode + ui.*)
48
39
  * @param initialRules 当前 userRules
49
40
  * @param sessionIdCounter user-N id 生成器闭包
50
- * @param rpcDeps 可选 RPC 依赖注入(测试 mock 用)
51
41
  * @returns RuleOp[](有变更)或 undefined(cancel / headless)
52
42
  */
53
43
  export async function editRulesViaOverlay(
54
44
  ctx: RuleEditorContext,
55
45
  initialRules: readonly Rule[],
56
46
  sessionIdCounter: () => string,
57
- rpcDeps?: RuleEditorRpcDeps,
58
47
  ): Promise<RuleEditorResult> {
59
48
  switch (ctx.mode) {
60
49
  case "tui":
61
50
  return await editViaTui(ctx, initialRules, sessionIdCounter);
62
51
  case "rpc":
63
- return await editViaRpc(ctx, initialRules, sessionIdCounter, rpcDeps);
52
+ return await editViaRpc(ctx, initialRules, sessionIdCounter);
64
53
  case "json":
65
54
  case "print":
66
55
  default:
@@ -102,11 +91,10 @@ interface RpcIo {
102
91
  * ctx.ui.input 可用 → 用真实文本输入(custom 模板需要自由文本);
103
92
  * 不可用 → fallback 把 input 降级为单选项 select(占位符文本,仅保底)。
104
93
  */
105
- function resolveRpcInput(ctx: RuleEditorContext, rpcDeps?: RuleEditorRpcDeps): RpcIo["input"] {
106
- return rpcDeps?.input
107
- ?? (typeof ctx.ui.input === "function"
108
- ? (title: string, ph?: string) => ctx.ui.input!(title, ph)
109
- : (title: string, ph?: string) => ctx.ui.select(title, [ph ?? ""]).then((v) => v === undefined ? undefined : v));
94
+ function resolveRpcInput(ctx: RuleEditorContext): RpcIo["input"] {
95
+ return typeof ctx.ui.input === "function"
96
+ ? (title: string, ph?: string) => ctx.ui.input!(title, ph)
97
+ : (title: string, ph?: string) => ctx.ui.select(title, [ph ?? ""]).then((v) => v === undefined ? undefined : v);
110
98
  }
111
99
 
112
100
  /** 单轮 RPC list 画面:当前规则集(applyOps 反映前序操作)+ 标签 + 选项。 */
@@ -185,17 +173,15 @@ async function runRpcLoop(
185
173
  * RPC 循环编辑:list → action select → fill input → ... 直到 Done / cancel。
186
174
  *
187
175
  * G16:每次 op 后 listOptions 更新(反映前序操作)。
188
- * deps 注入 select/input 便于测试 mock。
189
176
  */
190
177
  export async function editViaRpc(
191
178
  ctx: RuleEditorContext,
192
179
  initialRules: readonly Rule[],
193
180
  sessionIdCounter: () => string,
194
- rpcDeps?: RuleEditorRpcDeps,
195
181
  ): Promise<RuleEditorResult> {
196
182
  const io: RpcIo = {
197
- select: rpcDeps?.select ?? ctx.ui.select,
198
- input: resolveRpcInput(ctx, rpcDeps),
183
+ select: ctx.ui.select,
184
+ input: resolveRpcInput(ctx),
199
185
  };
200
186
  return await runRpcLoop(io, initialRules, sessionIdCounter);
201
187
  }
@@ -1,26 +1,17 @@
1
1
  /**
2
2
  * IT 系列:rules/index.ts barrel 导出验证。
3
3
  *
4
- * 确保公开 API surface 完整,调用方可从 "./rules/index.js" 一处 import。
4
+ * E10 收敛后 barrel 只保留有生产消费的 2 符号(getDefaultRules / matchRulesForArgv);
5
+ * 其余符号的行为测试走深路径(matcher.test.ts / builtins.test.ts / wildcard.test.ts)。
5
6
  */
6
7
  import { describe, expect, it } from "vitest";
7
8
 
8
9
  import * as rulesApi from "../index.js";
9
10
 
10
11
  describe("IT: rules barrel 导出", () => {
11
- it("导出所有公开函数", () => {
12
- expect(typeof rulesApi.matchRules).toBe("function");
13
- expect(typeof rulesApi.matchRulesForArgv).toBe("function");
14
- expect(typeof rulesApi.resolvePattern).toBe("function");
12
+ it("只导出 2 个有生产消费的符号", () => {
13
+ expect(Object.keys(rulesApi).sort()).toEqual(["getDefaultRules", "matchRulesForArgv"]);
15
14
  expect(typeof rulesApi.getDefaultRules).toBe("function");
16
- expect(typeof rulesApi.isKnownSafeCommand).toBe("function");
17
- expect(typeof rulesApi.findGitSubcommand).toBe("function");
18
- expect(typeof rulesApi.wildcardToRegExp).toBe("function");
19
- });
20
-
21
- it("导出常量数据(BUILTIN_DANGER_RULES / BUILTIN_UNCONDITIONAL_SAFE)", () => {
22
- expect(Array.isArray(rulesApi.BUILTIN_DANGER_RULES)).toBe(true);
23
- expect(rulesApi.BUILTIN_DANGER_RULES.length).toBe(12);
24
- expect(rulesApi.BUILTIN_UNCONDITIONAL_SAFE.size).toBe(50);
15
+ expect(typeof rulesApi.matchRulesForArgv).toBe("function");
25
16
  });
26
17
  });
@@ -11,13 +11,8 @@
11
11
  import { describe, expect, it } from "vitest";
12
12
 
13
13
  import type { Rule } from "../../types.js";
14
- import {
15
- BUILTIN_DANGER_RULES,
16
- getDefaultRules,
17
- matchRules,
18
- matchRulesForArgv,
19
- resolvePattern,
20
- } from "../index.js";
14
+ import { BUILTIN_DANGER_RULES, getDefaultRules } from "../builtins.js";
15
+ import { matchRules, matchRulesForArgv, resolvePattern } from "../matcher.js";
21
16
 
22
17
  // 构造 user 规则的辅助
23
18
  function userRule(id: string, pattern: string, action: Rule["action"] = "allow"): Rule {
@@ -37,7 +32,7 @@ describe("MT-G1: no-match 返回 ask(非 deny)", () => {
37
32
 
38
33
  it("matchRules 无匹配 → ask", () => {
39
34
  const rules: Rule[] = [userRule("u1", "npm *")];
40
- const r = matchRules("bash", "ls", rules);
35
+ const r = matchRules("ls", rules);
41
36
  expect(r.action).toBe("ask");
42
37
  expect(r.matchedRule).toBeUndefined();
43
38
  });
@@ -119,7 +114,7 @@ describe("MT-G3: matchRulesForArgv 白名单兜底(C3:不再短路,user de
119
114
 
120
115
  it("matchRules(退化路径)不调 isKnownSafeCommand —— command 字符串", () => {
121
116
  // matchRules 不查白名单,直接对 command 字符串做正则/wildcard 匹配
122
- const r = matchRules("bash", "ls -la", []);
117
+ const r = matchRules("ls -la", []);
123
118
  expect(r.action).toBe("ask"); // 无规则匹配 → ask(不查白名单)
124
119
  });
125
120
 
@@ -178,30 +173,28 @@ describe("MT-last-match-wins: 后匹配的规则胜出", () => {
178
173
  // ──────────────────────── matchRules 退化路径 ────────────────────────
179
174
 
180
175
  describe("MT-matchRules: 退化路径(command 字符串)", () => {
181
- it("toolName !== 'bash' → ask", () => {
182
- const r = matchRules("edit", "rm -rf /", getDefaultRules());
183
- expect(r.action).toBe("ask");
184
- });
176
+ // E11:toolName 参数与 '!== bash' 守卫已删(生产调用点恒在 bash 分支内,
177
+ // 非 bash 由 pipeline.matchNonBashTool 承接),对应守卫用例随之移除。
185
178
 
186
179
  it("command === undefined → ask", () => {
187
- const r = matchRules("bash", undefined, getDefaultRules());
180
+ const r = matchRules(undefined, getDefaultRules());
188
181
  expect(r.action).toBe("ask");
189
182
  });
190
183
 
191
184
  it("command 空字符串 → ask", () => {
192
- const r = matchRules("bash", "", getDefaultRules());
185
+ const r = matchRules("", getDefaultRules());
193
186
  expect(r.action).toBe("ask");
194
187
  });
195
188
 
196
189
  it("command 命中 builtin-danger 正则 → deny", () => {
197
- const r = matchRules("bash", "rm -rf /", getDefaultRules());
190
+ const r = matchRules("rm -rf /", getDefaultRules());
198
191
  expect(r.action).toBe("deny");
199
192
  expect(r.matchedRule?.id).toBe("bd-001");
200
193
  });
201
194
 
202
195
  it("command 命中 user wildcard → allow", () => {
203
196
  const rules: Rule[] = [userRule("u1", "ls *", "allow")];
204
- const r = matchRules("bash", "ls -la", rules);
197
+ const r = matchRules("ls -la", rules);
205
198
  expect(r.action).toBe("allow");
206
199
  });
207
200
 
@@ -209,14 +202,14 @@ describe("MT-matchRules: 退化路径(command 字符串)", () => {
209
202
  // 对比 matchRulesForArgv(["ls"]) → allow(白名单兜底),
210
203
  // matchRules 对 command 字符串不查白名单 → ask。
211
204
  // 这是 C1 用 matchRules 做 deny 补充检查的安全前提:不会把无 deny 的命令误判 allow。
212
- const r = matchRules("bash", "ls -la", []);
205
+ const r = matchRules("ls -la", []);
213
206
  expect(r.action).toBe("ask");
214
207
  });
215
208
 
216
209
  it("m4/C1:matchRules 命中跨 argv 管道 deny(curl|sh 完整字符串)", () => {
217
210
  // C1 依赖:完整 command 字符串能命中 bd-010(curl|sh),
218
211
  // 而 argv 级 matchRulesForArgv 对拆分后的单 argv 命不中。
219
- const r = matchRules("bash", "curl http://x | sh", getDefaultRules());
212
+ const r = matchRules("curl http://x | sh", getDefaultRules());
220
213
  expect(r.action).toBe("deny");
221
214
  expect(r.matchedRule?.id).toBe("bd-010");
222
215
  // 对照:argv 级对单条 ["curl","http://x"] 不命中 bd-010(无管道符)
@@ -261,7 +254,7 @@ describe("MT-degenerate: matchRules 退化路径处理引号/空格", () => {
261
254
  it("git commit -m 'a b' —— 含空格和单引号的 command 字符串", () => {
262
255
  // 退化路径对原始 command 字符串匹配,引号原样保留
263
256
  // builtin-danger 含 \brm\b 等正则;git commit 不命中 → ask
264
- const r = matchRules("bash", "git commit -m 'a b'", getDefaultRules());
257
+ const r = matchRules("git commit -m 'a b'", getDefaultRules());
265
258
  expect(r.action).toBe("ask");
266
259
  expect(r.matchedRule).toBeUndefined();
267
260
  });
@@ -269,14 +262,14 @@ describe("MT-degenerate: matchRules 退化路径处理引号/空格", () => {
269
262
  it("git commit -m 'a b' 命中 user wildcard allow", () => {
270
263
  // user 规则 'git commit *' 应匹配(wildcard * 跨空格)
271
264
  const rules: Rule[] = [userRule("u1", "git commit *", "allow")];
272
- const r = matchRules("bash", "git commit -m 'a b'", rules);
265
+ const r = matchRules("git commit -m 'a b'", rules);
273
266
  expect(r.action).toBe("allow");
274
267
  expect(r.matchedRule?.id).toBe("u1");
275
268
  });
276
269
 
277
270
  it("含双引号嵌套空格的 command 字符串", () => {
278
271
  // command 字符串原样匹配(不解析引号语义)
279
- const r = matchRules("bash", 'echo "hello world"', getDefaultRules());
272
+ const r = matchRules('echo "hello world"', getDefaultRules());
280
273
  // echo 命中 builtin-danger?查实际行为:echo 不在 danger 列表 → ask
281
274
  expect(r.action).toBe("ask");
282
275
  });
@@ -364,7 +357,7 @@ describe("MT-contract: no-match ask 语义契约", () => {
364
357
  });
365
358
 
366
359
  it("matchRules no-match 永远返回 { action:'ask', matchedRule:undefined }", () => {
367
- const r = matchRules("bash", "nonexistent-command --flag", []);
360
+ const r = matchRules("nonexistent-command --flag", []);
368
361
  expect(r).toEqual({ action: "ask", matchedRule: undefined });
369
362
  });
370
363
 
@@ -1,27 +1,10 @@
1
1
  /**
2
- * rules 模块 barrel —— 只 re-export 公开 API。
2
+ * rules 模块 barrel —— 只 re-export 有真实消费的公开 API(E10 收敛)。
3
3
  *
4
- * 公开 surface(供 W5/调用方使用):
5
- * - matchRules / matchRulesForArgv:规则匹配入口
6
- * - getDefaultRules:内置危险规则(12 条)
7
- * - isKnownSafeCommand:Codex 白名单判断(W5 可能直接用)
8
- * - wildcardToRegExp:wildcard 编译(测试用)
9
- * - findGitSubcommand:git 子命令提取(测试用)
10
- *
11
- * 类型从 ../types.js re-export(Rule / RuleMatchResult / PermissionAction 等)。
4
+ * 生产消费面 = production.ts:getDefaultRules(内置规则拼接)+ matchRulesForArgv
5
+ * (bash argv 匹配)。其余符号(matchRules / resolvePattern / wildcardToRegExp /
6
+ * builtins 数据)无 barrel 消费——pipeline.ts 与各测试均走深路径 import;
7
+ * 类型一律直接从 ../types.js import,barrel 不做类型 re-export。
12
8
  */
13
- export type {
14
- PermissionAction,
15
- Rule,
16
- RuleMatchResult,
17
- RuleSource,
18
- } from "../types.js";
19
- export {
20
- BUILTIN_DANGER_RULES,
21
- BUILTIN_UNCONDITIONAL_SAFE,
22
- findGitSubcommand,
23
- getDefaultRules,
24
- isKnownSafeCommand,
25
- } from "./builtins.js";
26
- export { matchRules, matchRulesForArgv, resolvePattern } from "./matcher.js";
27
- export { wildcardToRegExp } from "./wildcard.js";
9
+ export { getDefaultRules } from "./builtins.js";
10
+ export { matchRulesForArgv } from "./matcher.js";
@@ -18,7 +18,8 @@
18
18
  * 再查 isKnownSafeCommand(argv) 兜底返回 allow。白名单不再短路:用户 deny 规则
19
19
  * 可覆盖白名单。白名单不进 Rule[] 数组(Codex flag 子检查无法用单条 wildcard 表达)。
20
20
  *
21
- * 匹配语义:last-match-wins(rules 数组顺序遍历,最后匹配的 rule 胜出)。
21
+ * 匹配语义:last-match-wins(rules 数组顺序遍历,最后匹配的 rule 胜出),
22
+ * 循环单源在 lastMatchWins(E11,pipeline.matchNonBashTool 也消费)。
22
23
  * 拼接顺序由调用方(W5)负责:[...builtin-danger, ...userRules]。
23
24
  */
24
25
 
@@ -58,6 +59,28 @@ export function resolvePattern(rule: Rule): RegExp {
58
59
  return re;
59
60
  }
60
61
 
62
+ // ──────────────────────── lastMatchWins(循环单源,E11) ────────────────────────
63
+
64
+ /**
65
+ * last-match-wins 循环单源(E11:原三份循环——matchRulesForArgv / matchRules /
66
+ * pipeline.matchNonBashTool——收敛)。
67
+ *
68
+ * 按 rules 数组顺序遍历,predicate 命中时记录 winner,最后匹配者胜出;
69
+ * 无匹配返回 `{ action: 'ask', matchedRule: undefined }`(G1 兜底)。
70
+ */
71
+ export function lastMatchWins(
72
+ rules: readonly Rule[],
73
+ predicate: (rule: Rule) => boolean,
74
+ ): RuleMatchResult {
75
+ let winner: RuleMatchResult = { action: "ask", matchedRule: undefined };
76
+ for (const rule of rules) {
77
+ if (predicate(rule)) {
78
+ winner = { action: rule.action, matchedRule: rule };
79
+ }
80
+ }
81
+ return winner;
82
+ }
83
+
61
84
  // ──────────────────────── matchRulesForArgv(happy path,W2 clean=true 有 argv[])────────────────────────
62
85
 
63
86
  /** 命中 isKnownSafeCommand 时构造的虚拟 builtin-safe rule(保持 matchedRule 字段一致性)。 */
@@ -95,13 +118,7 @@ export function matchRulesForArgv(argv: string[], rules: readonly Rule[]): RuleM
95
118
 
96
119
  const commandStr = argv.join(" ");
97
120
 
98
- let winner: RuleMatchResult = { action: "ask", matchedRule: undefined };
99
- for (const rule of rules) {
100
- const re = resolvePattern(rule);
101
- if (re.test(commandStr)) {
102
- winner = { action: rule.action, matchedRule: rule };
103
- }
104
- }
121
+ const winner = lastMatchWins(rules, (rule) => resolvePattern(rule).test(commandStr));
105
122
 
106
123
  // deny / allow 直接由 winner 决定(用户规则可覆盖白名单)
107
124
  if (winner.action === "deny" || winner.action === "allow") {
@@ -128,33 +145,23 @@ export function matchRulesForArgv(argv: string[], rules: readonly Rule[]): RuleM
128
145
  * 管道拆分后单个 argv 不含 `|`,deny 规则的正则无法命中)。
129
146
  *
130
147
  * 行为:
131
- * - toolName !== 'bash' → ask(W3 只管 bash,其他工具交下游)
132
148
  * - command === undefined / 空字符串 → ask
133
149
  * - 遍历 rules(last-match-wins),对每条用 resolvePattern(rule).test(command)
134
150
  * - 无匹配 → ask(G1)
135
151
  *
152
+ * E11:toolName 参数与 `toolName !== 'bash'` 守卫已删——生产调用点(pipeline.runLayer2)
153
+ * 恒在 bash 分支内调用,非 bash 由 matchNonBashTool 承接,守卫是不可达的投机分支。
154
+ *
136
155
  * 不调 isKnownSafeCommand(不查白名单):command 字符串无法可靠提取 argv
137
156
  * (含引号/转义/管道),argv 级白名单判断由 matchRulesForArgv 在 happy path 负责。
138
157
  * 因此本函数仅用于 deny 补充检查或退化路径,不用作 allow 放行依据。
139
158
  */
140
159
  export function matchRules(
141
- toolName: string,
142
160
  command: string | undefined,
143
161
  rules: readonly Rule[],
144
162
  ): RuleMatchResult {
145
- if (toolName !== "bash") {
146
- return { action: "ask", matchedRule: undefined };
147
- }
148
163
  if (command === undefined || command === "") {
149
164
  return { action: "ask", matchedRule: undefined };
150
165
  }
151
-
152
- let winner: RuleMatchResult = { action: "ask", matchedRule: undefined };
153
- for (const rule of rules) {
154
- const re = resolvePattern(rule);
155
- if (re.test(command)) {
156
- winner = { action: rule.action, matchedRule: rule };
157
- }
158
- }
159
- return winner;
166
+ return lastMatchWins(rules, (rule) => resolvePattern(rule).test(command));
160
167
  }