@zhushanwen/pi-permission 1.4.0 → 1.4.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-permission",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "description": "Pi permission extension — four permission modes (yolo/auto/approve/strict) with three-layer pipeline (AST + rules + AI classifier).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -50,9 +50,9 @@
50
50
  "dependencies": {
51
51
  "tree-sitter-bash": "^0.25.1",
52
52
  "web-tree-sitter": "^0.26.11",
53
- "@zhushanwen/pi-ext-guards": "0.2.0",
54
- "@zhushanwen/pi-extension-logger": "0.4.0",
55
- "@zhushanwen/pi-llm-shared": "0.5.0"
53
+ "@zhushanwen/pi-ext-guards": "0.2.1",
54
+ "@zhushanwen/pi-extension-logger": "0.4.1",
55
+ "@zhushanwen/pi-llm-shared": "0.6.0"
56
56
  },
57
57
  "peerDependencies": {
58
58
  "@earendil-works/pi-ai": "^0.84.4",
@@ -40,7 +40,7 @@ beforeEach(() => {
40
40
 
41
41
  afterEach(() => {
42
42
  delete process.env.PI_CODING_AGENT_DIR;
43
- rmSync(tempDir, { recursive: true, force: true });
43
+ rmSync(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
44
44
  clearConfigCache();
45
45
  });
46
46
 
@@ -26,7 +26,7 @@ beforeEach(() => {
26
26
  afterEach(() => {
27
27
  if (originalAgentDirEnv === undefined) delete process.env.PI_CODING_AGENT_DIR;
28
28
  else process.env.PI_CODING_AGENT_DIR = originalAgentDirEnv;
29
- rmSync(tempAgentDir, { recursive: true, force: true });
29
+ rmSync(tempAgentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
30
30
  });
31
31
 
32
32
  /** 最小 mock:只记录 registerCommand 和 on 调用 */
@@ -140,7 +140,7 @@ afterEach(() => {
140
140
  delete process.env.PI_CODING_AGENT_DIR;
141
141
  Reflect.deleteProperty(globalThis, FOOTER_HANDSHAKE_KEY);
142
142
  Reflect.deleteProperty(globalThis, REQUEST_RENDER_KEY);
143
- if (existsSync(TMP_ROOT)) rmSync(TMP_ROOT, { recursive: true, force: true });
143
+ if (existsSync(TMP_ROOT)) rmSync(TMP_ROOT, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
144
144
  });
145
145
 
146
146
  // ──────────────────────── 测试 ────────────────────────
@@ -90,7 +90,7 @@ beforeEach(() => {
90
90
  afterEach(() => {
91
91
  delete process.env.PI_CODING_AGENT_DIR;
92
92
  Reflect.deleteProperty(globalThis, FOOTER_HANDSHAKE_KEY);
93
- if (existsSync(TMP_ROOT)) rmSync(TMP_ROOT, { recursive: true, force: true });
93
+ if (existsSync(TMP_ROOT)) rmSync(TMP_ROOT, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
94
94
  });
95
95
 
96
96
  describe("session_start 双派发幂等守卫(oncePerProcess,u-audit-fix)", () => {
@@ -102,90 +102,100 @@ function parseRawString(node: Node): string | null {
102
102
  }
103
103
 
104
104
  /**
105
- * 从一个 command 节点提取 argv(对应 Rust parse_plain_command_from_node)。
106
- *
107
- * 5 分支处理 named children:
108
- * 1. command_name → 其第一个 named child 必须是 word,取文本
109
- * 2. word | number → 直接取文本
110
- * 3. string → parseDoubleQuotedString(含 expansion 则 null)
111
- * 4. raw_string → parseRawString
112
- * 5. concatenation(如 -g"*.py")→ 递归拼接各 part(word/number/string/raw_string)
113
- *
114
- * 任一分支失败 → 返回 null(该 command 被跳过,但 clean 已在 DFS 阶段被置 false)。
105
+ * command_name 分支:其第一个 named child 必须是 word,取文本;否则 null。
115
106
  */
116
- function parsePlainCommand(cmd: Node): string[] | null {
117
- if (cmd.type !== "command") {
107
+ function parseCommandName(child: Node): string | null {
108
+ const wordNode = child.namedChild(0);
109
+ if (!wordNode || wordNode.type !== "word") {
118
110
  return null;
119
111
  }
112
+ return wordNode.text;
113
+ }
120
114
 
121
- const words: string[] = [];
122
- for (const child of cmd.namedChildren) {
123
- switch (child.type) {
124
- case "command_name": {
125
- const wordNode = child.namedChild(0);
126
- if (!wordNode || wordNode.type !== "word") {
127
- return null;
128
- }
129
- words.push(wordNode.text);
130
- break;
131
- }
115
+ /**
116
+ * concatenation 分支(如 -g"*.py" 或 '/usr'"/"'local'/bin):
117
+ * 递归拼接各 part(word/number/string/raw_string),空拼接结果 → null。
118
+ */
119
+ function parseConcatenation(child: Node): string | null {
120
+ let concatenated = "";
121
+ for (const part of child.namedChildren) {
122
+ switch (part.type) {
132
123
  case "word":
133
- case "number": {
134
- words.push(child.text);
124
+ case "number":
125
+ concatenated += part.text;
135
126
  break;
136
- }
137
127
  case "string": {
138
- const parsed = parseDoubleQuotedString(child);
139
- if (parsed === null) {
140
- return null;
141
- }
142
- words.push(parsed);
128
+ const parsed = parseDoubleQuotedString(part);
129
+ if (parsed === null) return null;
130
+ concatenated += parsed;
143
131
  break;
144
132
  }
145
133
  case "raw_string": {
146
- const parsed = parseRawString(child);
147
- if (parsed === null) {
148
- return null;
149
- }
150
- words.push(parsed);
151
- break;
152
- }
153
- case "concatenation": {
154
- // 拼接如 -g"*.py" 或 '/usr'"/"'local'/bin
155
- let concatenated = "";
156
- for (const part of child.namedChildren) {
157
- switch (part.type) {
158
- case "word":
159
- case "number":
160
- concatenated += part.text;
161
- break;
162
- case "string": {
163
- const parsed = parseDoubleQuotedString(part);
164
- if (parsed === null) return null;
165
- concatenated += parsed;
166
- break;
167
- }
168
- case "raw_string": {
169
- const parsed = parseRawString(part);
170
- if (parsed === null) return null;
171
- concatenated += parsed;
172
- break;
173
- }
174
- default:
175
- return null;
176
- }
177
- }
178
- if (concatenated.length === 0) {
179
- return null;
180
- }
181
- words.push(concatenated);
134
+ const parsed = parseRawString(part);
135
+ if (parsed === null) return null;
136
+ concatenated += parsed;
182
137
  break;
183
138
  }
184
139
  default:
185
- // 未知 named child(不应发生 —— DFS 阶段已拒,但保险起见 fail-closed)
186
140
  return null;
187
141
  }
188
142
  }
143
+ if (concatenated.length === 0) {
144
+ return null;
145
+ }
146
+ return concatenated;
147
+ }
148
+
149
+ /**
150
+ * 解析 command 的单个 named child 为一个 argv token(分发表)。
151
+ *
152
+ * 5 分支:
153
+ * 1. command_name → parseCommandName(第一个 named child 必须是 word)
154
+ * 2. word | number → 直接取文本
155
+ * 3. string → parseDoubleQuotedString(含 expansion 则 null)
156
+ * 4. raw_string → parseRawString
157
+ * 5. concatenation → parseConcatenation
158
+ *
159
+ * 未知 named child(不应发生 —— DFS 阶段已拒,但保险起见 fail-closed)→ null。
160
+ * 失败 → 调用方让整个 command 返回 null(该 command 被跳过,但 clean 已在 DFS 阶段被置 false)。
161
+ */
162
+ function parseChildToken(child: Node): string | null {
163
+ switch (child.type) {
164
+ case "command_name":
165
+ return parseCommandName(child);
166
+ case "word":
167
+ case "number":
168
+ return child.text;
169
+ case "string":
170
+ return parseDoubleQuotedString(child);
171
+ case "raw_string":
172
+ return parseRawString(child);
173
+ case "concatenation":
174
+ return parseConcatenation(child);
175
+ default:
176
+ return null;
177
+ }
178
+ }
179
+
180
+ /**
181
+ * 从一个 command 节点提取 argv(对应 Rust parse_plain_command_from_node)。
182
+ *
183
+ * 主函数编排:类型守卫 → 逐 named child 分发(parseChildToken)→ 组装 words。
184
+ * 任一 child 解析失败 → 返回 null。
185
+ */
186
+ function parsePlainCommand(cmd: Node): string[] | null {
187
+ if (cmd.type !== "command") {
188
+ return null;
189
+ }
190
+
191
+ const words: string[] = [];
192
+ for (const child of cmd.namedChildren) {
193
+ const token = parseChildToken(child);
194
+ if (token === null) {
195
+ return null;
196
+ }
197
+ words.push(token);
198
+ }
189
199
  return words;
190
200
  }
191
201
 
@@ -119,6 +119,78 @@ function raceResultWithDeadline(
119
119
  });
120
120
  }
121
121
 
122
+ // ──────────────────────── classifyRisk 分步 helper ────────────────────────
123
+
124
+ /**
125
+ * 构造 CallLLMOptions(timeout 秒→毫秒的换算由调用方完成后传入;
126
+ * signal 透传 callLLM → completeSimple,abort 时 reject 或 resolve(aborted)
127
+ * 都会归一为 ok:false)。
128
+ */
129
+ function buildCallOptions(
130
+ model: Model<Api>,
131
+ ctx: ToolInvocationContext,
132
+ config: ClassifierConfig,
133
+ timeoutMs: number | undefined,
134
+ signal: AbortSignal | undefined,
135
+ ): CallLLMOptions {
136
+ return {
137
+ model,
138
+ systemPrompt: CLASSIFIER_SYSTEM_PROMPT,
139
+ messages: buildMessages(ctx),
140
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
141
+ ...(signal !== undefined ? { signal } : {}),
142
+ // thinkingLevel 直接透传(含 "off");llm-shared 内部会把 "off" 映射为不传 reasoning(provider 默认)
143
+ reasoning: config.thinkingLevel,
144
+ };
145
+ }
146
+
147
+ /** 记录 LLM 调用失败日志(G3 语义:abort 与 error 分开记,即使行为上两者都 fallback 无差别)。 */
148
+ function logFailedCall(
149
+ result: Extract<CallLLMResult, { ok: false }>,
150
+ onLog?: (msg: string) => void,
151
+ ): void {
152
+ if (result.stopReason === "aborted") {
153
+ onLog?.(`[pi-permission] classifier: LLM call aborted (stopReason=aborted), returning fallback`);
154
+ } else {
155
+ const stopDetail = result.stopReason !== undefined ? ` (stopReason=${result.stopReason})` : "";
156
+ onLog?.(`[pi-permission] classifier: LLM call failed: ${result.error}${stopDetail}, returning fallback`);
157
+ }
158
+ }
159
+
160
+ /** raceWithDeadline 已收窄的结果形态(timeout/aborted 无 result 字段)。 */
161
+ type SettledRace =
162
+ | { kind: "ok"; result: CallLLMResult }
163
+ | { kind: "timeout" }
164
+ | { kind: "aborted" };
165
+
166
+ /** settleRacedResult 的输出:fallback = fail-closed ask;ok = 继续解析文本。 */
167
+ type SettledOutcome =
168
+ | { kind: "fallback"; result: ClassifierResult }
169
+ | { kind: "ok"; result: Extract<CallLLMResult, { ok: true }> };
170
+
171
+ /**
172
+ * race 结果归一:timeout / aborted / ok:false → fail-closed fallback(保留日志区分),
173
+ * 成功原样透传(调用方收窄后取 content)。
174
+ */
175
+ function settleRacedResult(settled: SettledRace, timeoutMs: number | undefined, onLog?: (msg: string) => void): SettledOutcome {
176
+ if (settled.kind === "timeout") {
177
+ onLog?.(`[pi-permission] classifier: timed out after ${timeoutMs}ms`);
178
+ return { kind: "fallback", result: { ...CLASSIFY_FALLBACK_RESULT } };
179
+ }
180
+ if (settled.kind === "aborted") {
181
+ onLog?.(`[pi-permission] classifier: aborted by signal`);
182
+ return { kind: "fallback", result: { ...CLASSIFY_FALLBACK_RESULT } };
183
+ }
184
+
185
+ // ok:false → fallback(fail-closed)。stopReason 独立透传字段保留日志区分
186
+ const result = settled.result;
187
+ if (!result.ok) {
188
+ logFailedCall(result, onLog);
189
+ return { kind: "fallback", result: { ...CLASSIFY_FALLBACK_RESULT } };
190
+ }
191
+ return { kind: "ok", result };
192
+ }
193
+
122
194
  // ──────────────────────── createClassifier ────────────────────────
123
195
 
124
196
  /**
@@ -148,21 +220,12 @@ export function createClassifier(deps: ClassifierDeps): {
148
220
  // 实证 classifier 真实用到 OAuth/配置的模型(而非 fail-closed 降级)。
149
221
  onLog?.(`[pi-permission] classifier: using model ${model.id}`);
150
222
 
151
- // 2. 构造 CallLLMOptions(timeout 秒→毫秒;signal 透传 callLLM → completeSimple,
152
- // abort 时 reject 或 resolve(aborted) 都会归一为 ok:false)
223
+ // 2. 构造 CallLLMOptions
153
224
  const timeoutMs = config.timeout > 0 ? config.timeout * MILLIS_PER_SECOND : undefined;
154
- const callPromise = callLLM({
155
- model,
156
- systemPrompt: CLASSIFIER_SYSTEM_PROMPT,
157
- messages: buildMessages(ctx),
158
- ...(timeoutMs !== undefined ? { timeoutMs } : {}),
159
- ...(signal !== undefined ? { signal } : {}),
160
- // thinkingLevel 直接透传(含 "off");llm-shared 内部会把 "off" 映射为不传 reasoning(provider 默认)
161
- reasoning: config.thinkingLevel,
162
- });
225
+ const callPromise = callLLM(buildCallOptions(model, ctx, config, timeoutMs, signal));
163
226
 
164
227
  // 3. 等待 callLLM + 外层超时/中止兜底(provider 不支持 timeoutMs/signal 时防永挂)
165
- let settled: { kind: "ok"; result: CallLLMResult } | { kind: "timeout" } | { kind: "aborted" };
228
+ let settled: SettledRace;
166
229
  try {
167
230
  settled = await raceResultWithDeadline(callPromise, timeoutMs, signal);
168
231
  } catch (error) {
@@ -171,30 +234,14 @@ export function createClassifier(deps: ClassifierDeps): {
171
234
  return { ...CLASSIFY_FALLBACK_RESULT };
172
235
  }
173
236
 
174
- if (settled.kind === "timeout") {
175
- onLog?.(`[pi-permission] classifier: timed out after ${timeoutMs}ms`);
176
- return { ...CLASSIFY_FALLBACK_RESULT };
177
- }
178
- if (settled.kind === "aborted") {
179
- onLog?.(`[pi-permission] classifier: aborted by signal`);
180
- return { ...CLASSIFY_FALLBACK_RESULT };
181
- }
182
-
183
- // 4. ok:false → fallback(fail-closed)。stopReason 独立透传字段保留日志区分
184
- // (G3 语义:abort 与 error 分开记,即使行为上两者都 fallback 无差别)。
185
- const result = settled.result;
186
- if (!result.ok) {
187
- if (result.stopReason === "aborted") {
188
- onLog?.(`[pi-permission] classifier: LLM call aborted (stopReason=aborted), returning fallback`);
189
- } else {
190
- const stopDetail = result.stopReason !== undefined ? ` (stopReason=${result.stopReason})` : "";
191
- onLog?.(`[pi-permission] classifier: LLM call failed: ${result.error}${stopDetail}, returning fallback`);
192
- }
193
- return { ...CLASSIFY_FALLBACK_RESULT };
237
+ // 4. timeout / aborted / ok:false → fail-closed fallback;成功 → 收窄透传
238
+ const settledOutcome = settleRacedResult(settled, timeoutMs, onLog);
239
+ if (settledOutcome.kind === "fallback") {
240
+ return settledOutcome.result;
194
241
  }
195
242
 
196
243
  // 5. 提取文本 → 三层容错解析
197
- return parseClassifierResponse(result.content);
244
+ return parseClassifierResponse(settledOutcome.result.content);
198
245
  }
199
246
 
200
247
  return { classifyRisk };
package/src/pipeline.ts CHANGED
@@ -442,8 +442,101 @@ export async function runLayer3WithRacing(
442
442
 
443
443
  // ──────────────────────── checkPermission(纯函数主入口) ────────────────────────
444
444
 
445
+ /** ctxBase 形态(checkPermission 第四参之后注入的上下文基线)。 */
446
+ interface ContextBase {
447
+ cwd: string;
448
+ agentName?: string;
449
+ signal?: AbortSignal;
450
+ }
451
+
452
+ /** 从 input 装配 ToolInvocationContext(command 用 AST/规则,path 其余字段透传给 UI)。 */
453
+ function buildInvocationContext(
454
+ toolName: string,
455
+ command: string | undefined,
456
+ input: Record<string, unknown>,
457
+ ctxBase: ContextBase,
458
+ ): ToolInvocationContext {
459
+ return {
460
+ toolName,
461
+ ...(command !== undefined ? { command } : {}),
462
+ path: typeof input.path === "string" ? input.path : undefined,
463
+ cwd: ctxBase.cwd,
464
+ ...(ctxBase.agentName !== undefined ? { agentName: ctxBase.agentName } : {}),
465
+ };
466
+ }
467
+
468
+ /** 层 2 allow/deny 结果 → PermissionDecision 装配(ask 返回 null 交下游)。 */
469
+ function ruleDecision(layer2: RuleMatchResult): PermissionDecision | null {
470
+ if (layer2.action === "allow") {
471
+ return {
472
+ action: "allow",
473
+ reason: layer2.matchedRule
474
+ ? `allowed by rule ${layer2.matchedRule.id}`
475
+ : "allowed by rules (no deny matched)",
476
+ source: "rule",
477
+ ...(layer2.matchedRule ? { matchedRule: layer2.matchedRule } : {}),
478
+ };
479
+ }
480
+ if (layer2.action === "deny") {
481
+ return {
482
+ action: "deny",
483
+ reason: layer2.matchedRule
484
+ ? `denied by rule ${layer2.matchedRule.id}`
485
+ : "denied by rules",
486
+ source: "rule",
487
+ ...(layer2.matchedRule ? { matchedRule: layer2.matchedRule } : {}),
488
+ };
489
+ }
490
+ return null;
491
+ }
492
+
493
+ /** 层 1 AST 输出:层 2 需要的 argvList + 提前决策(null = 无提前决策,继续层 2)。 */
494
+ interface AstLayerOutcome {
495
+ argvList: string[][];
496
+ earlyDecision: PermissionDecision | null;
497
+ }
498
+
445
499
  /**
446
- * 权限检查主入口(纯函数,deps 注入)。
500
+ * 层 1 AST(仅 bash 且有 command 时执行):clean → 透传 argvList 继续;
501
+ * 非 clean / 异常 → 按模式提前决策(approve 人工 / auto 进层 3 Racing)。
502
+ */
503
+ async function runAstLayer(
504
+ toolName: string,
505
+ command: string | undefined,
506
+ mode: PermissionMode,
507
+ config: ClassifierConfig,
508
+ ctx: ToolInvocationContext,
509
+ deps: CheckPermissionDeps,
510
+ ctxBase: ContextBase,
511
+ ): Promise<AstLayerOutcome> {
512
+ let argvList: string[][] = [];
513
+ if (toolName === "bash" && command !== undefined) {
514
+ try {
515
+ const analysis = await deps.analyzeBashStructure(command);
516
+ argvList = analysis.commands;
517
+ // AST 检测到危险结构(非 clean)→ 直接 ask(auto→AI,approve→人工)
518
+ if (!analysis.clean) {
519
+ const trigger = `AST detected dangerous structure: ${analysis.dangerousStructures.join(", ") || "unknown"}`;
520
+ if (mode === "approve") {
521
+ return { argvList, earlyDecision: await askUser(deps, ctx, trigger, ctxBase.signal) };
522
+ }
523
+ // auto:进层 3(AI 评估危险命令)。m2:透传 AST 检测的具体危险原因。
524
+ return { argvList, earlyDecision: await runLayer3WithRacing(deps, ctx, config, ctxBase.signal, trigger) };
525
+ }
526
+ } catch {
527
+ // AST 异常 → fail-closed ask(auto→AI,approve→人工)
528
+ if (mode === "approve") {
529
+ return { argvList, earlyDecision: await askUser(deps, ctx, "AST analysis failed (fail-closed)", ctxBase.signal) };
530
+ }
531
+ return { argvList, earlyDecision: await runLayer3WithRacing(deps, ctx, config, ctxBase.signal) };
532
+ }
533
+ }
534
+ return { argvList, earlyDecision: null };
535
+ }
536
+
537
+ /**
538
+ * 权限检查主入口(纯函数,deps 注入)。主函数只留编排:
539
+ * yolo/strict 快速路径 → 层 1 AST(runAstLayer)→ 层 2 规则(ruleDecision)→ 下游。
447
540
  *
448
541
  * 四档模式分支:
449
542
  * - yolo:完全放行(return allow,source='mode')。不跑任何层。
@@ -471,7 +564,7 @@ export async function checkPermission(
471
564
  config: ClassifierConfig,
472
565
  userRules: Rule[],
473
566
  deps: CheckPermissionDeps,
474
- ctxBase: { cwd: string; agentName?: string; signal?: AbortSignal },
567
+ ctxBase: ContextBase,
475
568
  ): Promise<PermissionDecision> {
476
569
  // yolo / disabled → 完全放行
477
570
  if (mode === "yolo") {
@@ -479,13 +572,7 @@ export async function checkPermission(
479
572
  }
480
573
 
481
574
  const command = extractCommand(toolName, input);
482
- const ctx: ToolInvocationContext = {
483
- toolName,
484
- ...(command !== undefined ? { command } : {}),
485
- path: typeof input.path === "string" ? input.path : undefined,
486
- cwd: ctxBase.cwd,
487
- ...(ctxBase.agentName !== undefined ? { agentName: ctxBase.agentName } : {}),
488
- };
575
+ const ctx = buildInvocationContext(toolName, command, input, ctxBase);
489
576
 
490
577
  // strict → 全部人工审批(不跑 AST/规则/AI)
491
578
  if (mode === "strict") {
@@ -493,54 +580,20 @@ export async function checkPermission(
493
580
  }
494
581
 
495
582
  // auto / approve:层 1 AST + 层 2 规则
496
- let argvList: string[][] = [];
497
- if (toolName === "bash" && command !== undefined) {
498
- try {
499
- const analysis = await deps.analyzeBashStructure(command);
500
- argvList = analysis.commands;
501
- // AST 检测到危险结构(非 clean)→ 直接 ask(auto→AI,approve→人工)
502
- if (!analysis.clean) {
503
- const trigger = `AST detected dangerous structure: ${analysis.dangerousStructures.join(", ") || "unknown"}`;
504
- if (mode === "approve") {
505
- return await askUser(deps, ctx, trigger, ctxBase.signal);
506
- }
507
- // auto:进层 3(AI 评估危险命令)。m2:透传 AST 检测的具体危险原因。
508
- return await runLayer3WithRacing(deps, ctx, config, ctxBase.signal, trigger);
509
- }
510
- } catch {
511
- // AST 异常 → fail-closed ask(auto→AI,approve→人工)
512
- if (mode === "approve") {
513
- return await askUser(deps, ctx, "AST analysis failed (fail-closed)", ctxBase.signal);
514
- }
515
- return await runLayer3WithRacing(deps, ctx, config, ctxBase.signal);
516
- }
583
+ const ast = await runAstLayer(toolName, command, mode, config, ctx, deps, ctxBase);
584
+ if (ast.earlyDecision !== null) {
585
+ return ast.earlyDecision;
517
586
  }
518
587
 
519
588
  // 层 2 规则(auto + approve 共用)
520
589
  const rules = [...deps.getDefaultRules(), ...userRules];
521
590
  // C1:传入完整 command(bash 跨 argv 管道 deny 补充检查);
522
591
  // M5:传入 path(非 bash 工具规则对 path 匹配)。
523
- const layer2 = runLayer2ForArgvList(toolName, argvList, rules, deps, command, ctx.path);
592
+ const layer2 = runLayer2ForArgvList(toolName, ast.argvList, rules, deps, command, ctx.path);
524
593
 
525
- if (layer2.action === "allow") {
526
- return {
527
- action: "allow",
528
- reason: layer2.matchedRule
529
- ? `allowed by rule ${layer2.matchedRule.id}`
530
- : "allowed by rules (no deny matched)",
531
- source: "rule",
532
- ...(layer2.matchedRule ? { matchedRule: layer2.matchedRule } : {}),
533
- };
534
- }
535
- if (layer2.action === "deny") {
536
- return {
537
- action: "deny",
538
- reason: layer2.matchedRule
539
- ? `denied by rule ${layer2.matchedRule.id}`
540
- : "denied by rules",
541
- source: "rule",
542
- ...(layer2.matchedRule ? { matchedRule: layer2.matchedRule } : {}),
543
- };
594
+ const decided = ruleDecision(layer2);
595
+ if (decided !== null) {
596
+ return decided;
544
597
  }
545
598
 
546
599
  // layer2.action === 'ask' → 下游
@@ -91,35 +91,74 @@ async function editViaTui(
91
91
 
92
92
  // ──────────────────────── RPC 分支(G16) ────────────────────────
93
93
 
94
+ /** RPC 循环内的 select/input 句柄(deps mock 优先,缺省回落 ctx.ui)。 */
95
+ interface RpcIo {
96
+ select: (title: string, options: string[]) => Promise<string | undefined>;
97
+ input: (title: string, placeholder?: string) => Promise<string | undefined>;
98
+ }
99
+
94
100
  /**
95
- * RPC 循环编辑:list → action select → fill input → ... 直到 Done / cancel。
101
+ * 解析 RPC 循环的 input 句柄:
102
+ * ctx.ui.input 可用 → 用真实文本输入(custom 模板需要自由文本);
103
+ * 不可用 → fallback 把 input 降级为单选项 select(占位符文本,仅保底)。
104
+ */
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));
110
+ }
111
+
112
+ /** 单轮 RPC list 画面:当前规则集(applyOps 反映前序操作)+ 标签 + 选项。 */
113
+ function buildRpcListState(
114
+ initialRules: readonly Rule[],
115
+ ops: RuleOp[],
116
+ ): { currentRules: Rule[]; ruleLabels: string[]; listOptions: string[] } {
117
+ const currentRules = applyOps([...initialRules], ops);
118
+ const ruleLabels = currentRules.map((r) => `[${r.action}] ${r.pattern}`);
119
+ const listOptions = [...ruleLabels, "[+ Add rule]", "[Done]"];
120
+ return { currentRules, ruleLabels, listOptions };
121
+ }
122
+
123
+ /**
124
+ * 选择了一条现有规则 → edit 模式(rpcEditFlow;ops 原地追加产生的 op)。
125
+ */
126
+ async function handleRpcRuleSelection(
127
+ io: RpcIo,
128
+ listChoice: string,
129
+ ruleLabels: readonly string[],
130
+ currentRules: readonly Rule[],
131
+ ops: RuleOp[],
132
+ ): Promise<void> {
133
+ const ruleIdx = ruleLabels.indexOf(listChoice);
134
+ if (ruleIdx >= 0 && ruleIdx < currentRules.length) {
135
+ const existingRule = currentRules[ruleIdx];
136
+ if (existingRule !== undefined) {
137
+ const editOp = await rpcEditFlow(io.select, io.input, existingRule);
138
+ if (editOp !== undefined) {
139
+ ops.push(editOp);
140
+ }
141
+ }
142
+ }
143
+ }
144
+
145
+ /**
146
+ * RPC 循环主体:list → action select → fill input → ... 直到 Done / cancel。
96
147
  *
97
148
  * G16:每次 op 后 listOptions 更新(反映前序操作)。
98
- * deps 注入 select/input 便于测试 mock。
99
149
  */
100
- export async function editViaRpc(
101
- ctx: RuleEditorContext,
150
+ async function runRpcLoop(
151
+ io: RpcIo,
102
152
  initialRules: readonly Rule[],
103
153
  sessionIdCounter: () => string,
104
- rpcDeps?: RuleEditorRpcDeps,
105
154
  ): Promise<RuleEditorResult> {
106
- const select = rpcDeps?.select ?? ctx.ui.select;
107
- // ctx.ui.input 可用 → 用真实文本输入(custom 模板需要自由文本);
108
- // 不可用 → fallback 把 input 降级为单选项 select(占位符文本,仅保底)。
109
- const input = rpcDeps?.input
110
- ?? (typeof ctx.ui.input === "function"
111
- ? (title: string, ph?: string) => ctx.ui.input!(title, ph)
112
- : (title: string, ph?: string) => ctx.ui.select(title, [ph ?? ""]).then((v) => v === undefined ? undefined : v));
113
-
114
155
  const ops: RuleOp[] = [];
115
156
 
116
157
  for (;;) {
117
158
  // 构建 listOptions(G16:用 applyOps 反映前序操作)
118
- const currentRules = applyOps([...initialRules], ops);
119
- const ruleLabels = currentRules.map((r) => `[${r.action}] ${r.pattern}`);
120
- const listOptions = [...ruleLabels, "[+ Add rule]", "[Done]"];
159
+ const { currentRules, ruleLabels, listOptions } = buildRpcListState(initialRules, ops);
121
160
 
122
- const listChoice = await select("[pi-permission] Permission Rules", listOptions);
161
+ const listChoice = await io.select("[pi-permission] Permission Rules", listOptions);
123
162
  if (listChoice === undefined) {
124
163
  // cancel
125
164
  return undefined;
@@ -130,7 +169,7 @@ export async function editViaRpc(
130
169
  }
131
170
 
132
171
  if (listChoice === "[+ Add rule]") {
133
- const op = await rpcAddFlow(select, input, sessionIdCounter);
172
+ const op = await rpcAddFlow(io.select, io.input, sessionIdCounter);
134
173
  if (op !== undefined) {
135
174
  ops.push(op);
136
175
  }
@@ -138,19 +177,29 @@ export async function editViaRpc(
138
177
  }
139
178
 
140
179
  // 选择了一条现有规则 → edit 模式
141
- const ruleIdx = ruleLabels.indexOf(listChoice);
142
- if (ruleIdx >= 0 && ruleIdx < currentRules.length) {
143
- const existingRule = currentRules[ruleIdx];
144
- if (existingRule !== undefined) {
145
- const editOp = await rpcEditFlow(select, input, existingRule);
146
- if (editOp !== undefined) {
147
- ops.push(editOp);
148
- }
149
- }
150
- }
180
+ await handleRpcRuleSelection(io, listChoice, ruleLabels, currentRules, ops);
151
181
  }
152
182
  }
153
183
 
184
+ /**
185
+ * RPC 循环编辑:list → action select → fill input → ... 直到 Done / cancel。
186
+ *
187
+ * G16:每次 op 后 listOptions 更新(反映前序操作)。
188
+ * deps 注入 select/input 便于测试 mock。
189
+ */
190
+ export async function editViaRpc(
191
+ ctx: RuleEditorContext,
192
+ initialRules: readonly Rule[],
193
+ sessionIdCounter: () => string,
194
+ rpcDeps?: RuleEditorRpcDeps,
195
+ ): Promise<RuleEditorResult> {
196
+ const io: RpcIo = {
197
+ select: rpcDeps?.select ?? ctx.ui.select,
198
+ input: resolveRpcInput(ctx, rpcDeps),
199
+ };
200
+ return await runRpcLoop(io, initialRules, sessionIdCounter);
201
+ }
202
+
154
203
  /** RPC add flow:选模板 → fill。 */
155
204
  async function rpcAddFlow(
156
205
  select: (title: string, options: string[]) => Promise<string | undefined>,