@gotgenes/pi-permission-system 8.1.0 → 8.2.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 (44) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/package.json +1 -1
  3. package/src/config-loader.ts +53 -46
  4. package/src/handlers/gates/bash-external-directory.ts +2 -4
  5. package/src/handlers/gates/bash-path-extractor.ts +135 -169
  6. package/src/handlers/gates/bash-path.ts +2 -4
  7. package/src/handlers/gates/bash-token-classification.ts +105 -0
  8. package/src/handlers/gates/descriptor.ts +6 -6
  9. package/src/handlers/gates/external-directory.ts +2 -4
  10. package/src/handlers/gates/helpers.ts +30 -1
  11. package/src/handlers/gates/path.ts +2 -4
  12. package/src/handlers/gates/runner.ts +29 -56
  13. package/src/handlers/gates/tool.ts +5 -4
  14. package/src/handlers/permission-gate-handler.ts +4 -3
  15. package/src/permission-manager.ts +6 -49
  16. package/src/permission-session.ts +3 -2
  17. package/src/scope-merge.ts +72 -0
  18. package/src/session-approval.ts +43 -0
  19. package/src/session-rules.ts +13 -0
  20. package/test/config-loader.test.ts +82 -0
  21. package/test/handlers/before-agent-start.test.ts +2 -20
  22. package/test/handlers/external-directory-integration.test.ts +44 -82
  23. package/test/handlers/external-directory-session-dedup.test.ts +17 -41
  24. package/test/handlers/gates/bash-external-directory.test.ts +11 -9
  25. package/test/handlers/gates/bash-path.test.ts +5 -26
  26. package/test/handlers/gates/bash-token-classification.test.ts +241 -0
  27. package/test/handlers/gates/external-directory.test.ts +2 -5
  28. package/test/handlers/gates/helpers.test.ts +81 -0
  29. package/test/handlers/gates/path.test.ts +5 -14
  30. package/test/handlers/gates/runner.test.ts +95 -113
  31. package/test/handlers/gates/tool.test.ts +2 -2
  32. package/test/handlers/input-events.test.ts +42 -95
  33. package/test/handlers/input.test.ts +3 -71
  34. package/test/handlers/lifecycle.test.ts +3 -20
  35. package/test/handlers/tool-call-events.test.ts +30 -127
  36. package/test/handlers/tool-call.test.ts +21 -110
  37. package/test/helpers/gate-fixtures.ts +105 -0
  38. package/test/helpers/handler-fixtures.ts +141 -0
  39. package/test/helpers/manager-harness.ts +51 -0
  40. package/test/permission-session.test.ts +7 -22
  41. package/test/permission-system.test.ts +4 -40
  42. package/test/scope-merge.test.ts +116 -0
  43. package/test/session-approval.test.ts +75 -0
  44. package/test/session-rules.test.ts +49 -0
package/CHANGELOG.md CHANGED
@@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [8.2.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v8.2.0...pi-permission-system-v8.2.1) (2026-05-31)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * remove stale PermissionGateHandler import in tool-call.test.ts ([#288](https://github.com/gotgenes/pi-packages/issues/288)) ([67259f6](https://github.com/gotgenes/pi-packages/commit/67259f666938e15473016edfeafcb718abe304f7))
14
+
15
+ ## [8.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v8.1.0...pi-permission-system-v8.2.0) (2026-05-31)
16
+
17
+
18
+ ### Features
19
+
20
+ * add SessionApproval value object and SessionRules.record ([8f98d92](https://github.com/gotgenes/pi-packages/commit/8f98d9223a424b0993d51c2d9106e7d01c6819d7))
21
+ * centralize decision-event construction in buildDecisionEvent ([19c2c83](https://github.com/gotgenes/pi-packages/commit/19c2c837b1907a4c302105ee86715533477247d4))
22
+
8
23
  ## [8.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v8.0.0...pi-permission-system-v8.1.0) (2026-05-31)
9
24
 
10
25
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "8.1.0",
3
+ "version": "8.2.1",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -33,74 +33,81 @@ export interface UnifiedConfigLoadResult {
33
33
 
34
34
  export function stripJsonComments(input: string): string {
35
35
  let output = "";
36
- let inString = false;
37
- let stringQuote: '"' | "'" | "" = "";
38
- let escaping = false;
39
- let inLineComment = false;
40
- let inBlockComment = false;
41
-
42
- for (let i = 0; i < input.length; i++) {
36
+ let i = 0;
37
+ while (i < input.length) {
43
38
  const char = input[i];
44
- const next = input[i + 1] || "";
45
-
46
- if (inLineComment) {
47
- if (char === "\n") {
48
- inLineComment = false;
49
- output += char;
50
- }
51
- continue;
52
- }
39
+ const next = input[i + 1] ?? "";
53
40
 
54
- if (inBlockComment) {
55
- if (char === "*" && next === "/") {
56
- inBlockComment = false;
57
- i++;
58
- }
41
+ if (char === "/" && next === "/") {
42
+ const seg = consumeLineComment(input, i);
43
+ output += seg.output;
44
+ i = seg.nextIndex;
59
45
  continue;
60
46
  }
61
-
62
- if (!inString && char === "/" && next === "/") {
63
- inLineComment = true;
64
- i++;
47
+ if (char === "/" && next === "*") {
48
+ const seg = consumeBlockComment(input, i);
49
+ output += seg.output;
50
+ i = seg.nextIndex;
65
51
  continue;
66
52
  }
67
-
68
- if (!inString && char === "/" && next === "*") {
69
- inBlockComment = true;
70
- i++;
53
+ if (char === '"' || char === "'") {
54
+ const seg = consumeString(input, i);
55
+ output += seg.output;
56
+ i = seg.nextIndex;
71
57
  continue;
72
58
  }
73
59
 
74
60
  output += char;
61
+ i++;
62
+ }
63
+ return output;
64
+ }
75
65
 
76
- if (!inString && (char === '"' || char === "'")) {
77
- inString = true;
78
- stringQuote = char;
79
- escaping = false;
80
- continue;
81
- }
66
+ /** A consumed run of source: the text to emit and the index to resume scanning. */
67
+ interface ScanSegment {
68
+ output: string;
69
+ nextIndex: number;
70
+ }
82
71
 
83
- if (!inString) {
84
- continue;
85
- }
72
+ /** Consume a `//` line comment starting at `start`; drop the body, keep the newline. */
73
+ function consumeLineComment(input: string, start: number): ScanSegment {
74
+ const newlineIndex = input.indexOf("\n", start);
75
+ if (newlineIndex === -1) return { output: "", nextIndex: input.length };
76
+ return { output: "\n", nextIndex: newlineIndex + 1 };
77
+ }
78
+
79
+ /** Consume a block comment starting at `start`; drop it entirely. */
80
+ function consumeBlockComment(input: string, start: number): ScanSegment {
81
+ const closeIndex = input.indexOf("*/", start + 2);
82
+ if (closeIndex === -1) return { output: "", nextIndex: input.length };
83
+ return { output: "", nextIndex: closeIndex + 2 };
84
+ }
86
85
 
86
+ /**
87
+ * Consume a string literal starting at the opening quote at `start`.
88
+ * Honors backslash escapes so an escaped quote does not close the literal.
89
+ * Emits the opening quote, body, and closing quote verbatim.
90
+ */
91
+ function consumeString(input: string, start: number): ScanSegment {
92
+ const quote = input[start];
93
+ let output = quote;
94
+ let i = start + 1;
95
+ let escaping = false;
96
+ while (i < input.length) {
97
+ const char = input[i];
98
+ output += char;
99
+ i++;
87
100
  if (escaping) {
88
101
  escaping = false;
89
102
  continue;
90
103
  }
91
-
92
104
  if (char === "\\") {
93
105
  escaping = true;
94
106
  continue;
95
107
  }
96
-
97
- if (char === stringQuote) {
98
- inString = false;
99
- stringQuote = "";
100
- }
108
+ if (char === quote) break;
101
109
  }
102
-
103
- return output;
110
+ return { output, nextIndex: i };
104
111
  }
105
112
 
106
113
  function normalizeOptionalBoolean(value: unknown): boolean | undefined {
@@ -1,5 +1,6 @@
1
1
  import { getNonEmptyString, toRecord } from "#src/common";
2
2
  import type { Rule } from "#src/rule";
3
+ import { SessionApproval } from "#src/session-approval";
3
4
  import { deriveApprovalPattern } from "#src/session-rules";
4
5
  import type { PermissionCheckResult } from "#src/types";
5
6
  import { extractExternalPathsFromBashCommand } from "./bash-path-extractor";
@@ -106,10 +107,7 @@ export async function describeBashExternalDirectoryGate(
106
107
  cwd: tcc.cwd,
107
108
  agentName: tcc.agentName ?? undefined,
108
109
  },
109
- sessionApproval: {
110
- surface: "external_directory",
111
- patterns,
112
- },
110
+ sessionApproval: SessionApproval.multiple("external_directory", patterns),
113
111
  promptDetails: {
114
112
  source: "tool_call",
115
113
  agentName: tcc.agentName,
@@ -1,6 +1,10 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { basename, resolve } from "node:path";
3
3
 
4
+ import {
5
+ classifyTokenAsPathCandidate,
6
+ classifyTokenAsRuleCandidate,
7
+ } from "#src/handlers/gates/bash-token-classification";
4
8
  import {
5
9
  isPathWithinDirectory,
6
10
  isSafeSystemPath,
@@ -237,6 +241,47 @@ function extractCommandName(node: TSNode): string | undefined {
237
241
  return undefined;
238
242
  }
239
243
 
244
+ /**
245
+ * Describes what the walker should do when it encounters a flag word inside
246
+ * a pattern-first command. Using a discriminated union lets the `switch` in
247
+ * `collectPatternCommandTokens` narrow `nextArgAction` without a non-null
248
+ * assertion (which would trigger the Biome/ESLint assertion conflict).
249
+ */
250
+ type PatternCommandFlagDirective =
251
+ | { kind: "end-of-flags" }
252
+ | { kind: "regular-flag" }
253
+ | {
254
+ kind: "consume-arg";
255
+ nextArgAction: "skip" | "extract";
256
+ setsExplicitScript: boolean;
257
+ };
258
+
259
+ /**
260
+ * Classify a flag word from a pattern-first command into a directive that
261
+ * tells the walker how to handle the flag and its following argument.
262
+ */
263
+ function classifyPatternCommandFlag(
264
+ text: string,
265
+ config: PatternCommandConfig,
266
+ ): PatternCommandFlagDirective {
267
+ if (text === "--") return { kind: "end-of-flags" };
268
+ if (config.argConsumingFlags.has(text)) {
269
+ return {
270
+ kind: "consume-arg",
271
+ nextArgAction: "skip",
272
+ setsExplicitScript: text === "-e" || text === "-f",
273
+ };
274
+ }
275
+ if (config.fileConsumingFlags.has(text)) {
276
+ return {
277
+ kind: "consume-arg",
278
+ nextArgAction: "extract",
279
+ setsExplicitScript: true,
280
+ };
281
+ }
282
+ return { kind: "regular-flag" };
283
+ }
284
+
240
285
  /**
241
286
  * Collect path-candidate tokens from a command known to have
242
287
  * pattern/script arguments in leading positional slots.
@@ -254,14 +299,14 @@ function extractCommandName(node: TSNode): string | undefined {
254
299
  */
255
300
  function collectPatternCommandTokens(
256
301
  node: TSNode,
257
- tokens: string[],
258
302
  config: PatternCommandConfig,
259
- ): void {
303
+ ): string[] {
260
304
  const patternPositionals = config.patternPositionals ?? 1;
261
305
  let hasExplicitScript = false;
262
306
  let positionalsSeen = 0;
263
307
  let nextArgAction: "skip" | "extract" | null = null;
264
308
  let pastEndOfFlags = false;
309
+ const tokens: string[] = [];
265
310
 
266
311
  for (let i = 0; i < node.childCount; i++) {
267
312
  const child = node.child(i);
@@ -274,7 +319,7 @@ function collectPatternCommandTokens(
274
319
  // Only process argument-like nodes; recurse into others
275
320
  // (e.g. command_substitution) for nested commands.
276
321
  if (!ARG_NODE_TYPES.has(child.type)) {
277
- collectPathCandidateTokens(child, tokens);
322
+ tokens.push(...collectPathCandidateTokens(child));
278
323
  continue;
279
324
  }
280
325
 
@@ -298,23 +343,18 @@ function collectPatternCommandTokens(
298
343
  text.startsWith("-") &&
299
344
  text.length > 1
300
345
  ) {
301
- if (text === "--") {
302
- pastEndOfFlags = true;
303
- continue;
304
- }
305
- if (config.argConsumingFlags.has(text)) {
306
- nextArgAction = "skip";
307
- if (text === "-e" || text === "-f") {
308
- hasExplicitScript = true;
309
- }
310
- continue;
346
+ const directive = classifyPatternCommandFlag(text, config);
347
+ switch (directive.kind) {
348
+ case "end-of-flags":
349
+ pastEndOfFlags = true;
350
+ break;
351
+ case "consume-arg":
352
+ nextArgAction = directive.nextArgAction;
353
+ if (directive.setsExplicitScript) hasExplicitScript = true;
354
+ break;
355
+ case "regular-flag":
356
+ break;
311
357
  }
312
- if (config.fileConsumingFlags.has(text)) {
313
- nextArgAction = "extract";
314
- hasExplicitScript = true;
315
- continue;
316
- }
317
- // Regular flag — skip it.
318
358
  continue;
319
359
  }
320
360
 
@@ -327,181 +367,107 @@ function collectPatternCommandTokens(
327
367
  // File argument — collect as path candidate.
328
368
  tokens.push(text);
329
369
  }
370
+
371
+ return tokens;
330
372
  }
331
373
 
332
374
  /**
333
- * Recursively visit the AST and collect resolved text of nodes that
334
- * represent command arguments or redirect destinations.
335
- *
336
- * Skips `heredoc_body`, `heredoc_end`, and `comment` subtrees entirely.
337
- *
338
- * For commands in `PATTERN_FIRST_COMMANDS`, uses position-based
339
- * argument skipping to avoid collecting inline patterns/scripts
340
- * as path candidates. For all other commands, collects all
341
- * arguments generically.
375
+ * Collect all argument tokens from a generic (non-pattern-first) command node,
376
+ * skipping the command name and variable assignments.
342
377
  */
343
- function collectPathCandidateTokens(node: TSNode, tokens: string[]): void {
344
- if (SKIP_SUBTREE_TYPES.has(node.type)) return;
345
-
346
- // Extract arguments from `command` nodes.
347
- if (node.type === "command") {
348
- const commandName = extractCommandName(node);
349
- const patternConfig = commandName
350
- ? PATTERN_FIRST_COMMANDS.get(commandName)
351
- : undefined;
352
-
353
- if (patternConfig) {
354
- collectPatternCommandTokens(node, tokens, patternConfig);
355
- return;
356
- }
357
-
358
- // Generic extraction: collect all arguments (skip command name).
359
- let seenCommandName = false;
360
- for (let i = 0; i < node.childCount; i++) {
361
- const child = node.child(i);
362
- if (!child) continue;
378
+ function collectGenericCommandTokens(node: TSNode): string[] {
379
+ const tokens: string[] = [];
380
+ let seenCommandName = false;
363
381
 
364
- if (child.type === "command_name") {
365
- seenCommandName = true;
366
- continue;
367
- }
368
- // Skip variable_assignment nodes (FOO=/bar)
369
- if (child.type === "variable_assignment") continue;
370
-
371
- // If there was no explicit command_name node, the first word-like
372
- // child is the command name itself — skip it.
373
- if (!seenCommandName && ARG_NODE_TYPES.has(child.type)) {
374
- seenCommandName = true;
375
- continue;
376
- }
382
+ for (let i = 0; i < node.childCount; i++) {
383
+ const child = node.child(i);
384
+ if (!child) continue;
377
385
 
378
- // Argument nodes: resolve their text and collect.
379
- if (ARG_NODE_TYPES.has(child.type)) {
380
- tokens.push(resolveNodeText(child));
381
- continue;
382
- }
386
+ if (child.type === "command_name") {
387
+ seenCommandName = true;
388
+ continue;
389
+ }
390
+ // Skip variable_assignment nodes (FOO=/bar)
391
+ if (child.type === "variable_assignment") continue;
383
392
 
384
- // Recurse into other children (e.g. command_substitution nested in args)
385
- collectPathCandidateTokens(child, tokens);
393
+ // If there was no explicit command_name node, the first word-like
394
+ // child is the command name itself — skip it.
395
+ if (!seenCommandName && ARG_NODE_TYPES.has(child.type)) {
396
+ seenCommandName = true;
397
+ continue;
386
398
  }
387
- return;
388
- }
389
399
 
390
- // Extract redirect destinations from `file_redirect` nodes.
391
- if (node.type === "file_redirect") {
392
- for (let i = 0; i < node.childCount; i++) {
393
- const child = node.child(i);
394
- if (!child) continue;
395
- if (
396
- child.type === "word" ||
397
- child.type === "concatenation" ||
398
- child.type === "string" ||
399
- child.type === "raw_string"
400
- ) {
401
- tokens.push(resolveNodeText(child));
402
- }
400
+ // Argument nodes: resolve their text and collect.
401
+ if (ARG_NODE_TYPES.has(child.type)) {
402
+ tokens.push(resolveNodeText(child));
403
+ continue;
403
404
  }
404
- return;
405
+
406
+ // Recurse into other children (e.g. command_substitution nested in args)
407
+ tokens.push(...collectPathCandidateTokens(child));
405
408
  }
406
409
 
407
- // For all other node types, recurse into children.
410
+ return tokens;
411
+ }
412
+
413
+ /**
414
+ * Collect redirect-destination tokens from a `file_redirect` node.
415
+ */
416
+ function collectRedirectTokens(node: TSNode): string[] {
417
+ const tokens: string[] = [];
408
418
  for (let i = 0; i < node.childCount; i++) {
409
419
  const child = node.child(i);
410
420
  if (!child) continue;
411
- collectPathCandidateTokens(child, tokens);
421
+ if (ARG_NODE_TYPES.has(child.type)) {
422
+ tokens.push(resolveNodeText(child));
423
+ }
412
424
  }
425
+ return tokens;
413
426
  }
414
427
 
415
428
  /**
416
- * URL pattern to skip tokens that look like URLs rather than paths.
417
- */
418
- const URL_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//i;
419
-
420
- /**
421
- * Regex metacharacter sequences that are never found in real filesystem paths.
422
- * If a token contains any of these, it is almost certainly a regex pattern
423
- * (e.g. a grep argument) rather than a path.
429
+ * Select the collection strategy for a `command` node: pattern-first
430
+ * commands use `collectPatternCommandTokens`; all others use
431
+ * `collectGenericCommandTokens`.
424
432
  */
425
- const REGEX_METACHAR_PATTERN = /\.\*|\.\+|\\\||\\\(|\\\)|\[.*?\]|\^\//;
433
+ function collectCommandTokens(node: TSNode): string[] {
434
+ const commandName = extractCommandName(node);
435
+ const config = commandName
436
+ ? PATTERN_FIRST_COMMANDS.get(commandName)
437
+ : undefined;
438
+ return config
439
+ ? collectPatternCommandTokens(node, config)
440
+ : collectGenericCommandTokens(node);
441
+ }
426
442
 
427
443
  /**
428
- * Broader token classification for cross-cutting `path` rules.
444
+ * Recursively visit the AST and collect resolved text of nodes that
445
+ * represent command arguments or redirect destinations.
429
446
  *
430
- * Accepts the same rejections as `classifyTokenAsPathCandidate` (empty, flags,
431
- * env assignments, URLs, @scope/package, bare-slash, regex metacharacters),
432
- * but also accepts:
433
- * - Tokens starting with `.` (dot-files: `.env`, `./src`)
434
- * - Tokens containing `/` (relative paths: `src/foo.ts`)
447
+ * Skips `heredoc_body`, `heredoc_end`, and `comment` subtrees entirely.
435
448
  *
436
- * Does NOT require the strict "must start with `/` or `~/` or contain `..`"
437
- * gate that the external-directory classifier uses.
449
+ * For commands in `PATTERN_FIRST_COMMANDS`, uses position-based
450
+ * argument skipping to avoid collecting inline patterns/scripts
451
+ * as path candidates. For all other commands, collects all
452
+ * arguments generically.
438
453
  */
439
- function classifyTokenAsRuleCandidate(token: string): string | null {
440
- if (!token) return null;
441
- if (token.startsWith("-")) return null;
442
-
443
- const eqIndex = token.indexOf("=");
444
- const slashIndex = token.indexOf("/");
445
- if (eqIndex !== -1 && (slashIndex === -1 || eqIndex < slashIndex)) {
446
- return null;
447
- }
448
-
449
- if (URL_PATTERN.test(token)) return null;
450
- if (token.startsWith("@") && !token.startsWith("@/")) return null;
451
- if (/^\/+$/.test(token)) return null;
452
- if (REGEX_METACHAR_PATTERN.test(token)) return null;
454
+ function collectPathCandidateTokens(node: TSNode): string[] {
455
+ if (SKIP_SUBTREE_TYPES.has(node.type)) return [];
456
+ if (node.type === "command") return collectCommandTokens(node);
457
+ if (node.type === "file_redirect") return collectRedirectTokens(node);
453
458
 
454
- // Accept: starts with . (dot-files, ./ relative), contains / (paths),
455
- // starts with / or ~/ (absolute/home), or contains .. (parent traversal).
456
- if (token.startsWith(".")) return token;
457
- if (token.includes("/")) return token;
458
- if (token.startsWith("~/")) return token;
459
- if (token.includes("..")) return token;
460
-
461
- return null;
462
- }
463
-
464
- /**
465
- * Determines whether a token looks like a path candidate worth resolving.
466
- * Returns the raw token string if it's a candidate, or null to skip.
467
- */
468
- function classifyTokenAsPathCandidate(token: string): string | null {
469
- // Skip empty tokens
470
- if (!token) return null;
471
-
472
- // Skip flags
473
- if (token.startsWith("-")) return null;
474
-
475
- // Skip env assignments (FOO=/bar)
476
- const eqIndex = token.indexOf("=");
477
- const slashIndex = token.indexOf("/");
478
- if (eqIndex !== -1 && (slashIndex === -1 || eqIndex < slashIndex)) {
479
- return null;
459
+ const tokens: string[] = [];
460
+ for (let i = 0; i < node.childCount; i++) {
461
+ const child = node.child(i);
462
+ if (child) tokens.push(...collectPathCandidateTokens(child));
480
463
  }
481
-
482
- // Skip URLs
483
- if (URL_PATTERN.test(token)) return null;
484
-
485
- // Skip @scope/package patterns
486
- if (token.startsWith("@") && !token.startsWith("@/")) return null;
487
-
488
- // Skip bare-slash tokens (// JS comments, lone /, etc.) — they resolve to root
489
- // and are never meaningful path arguments in practice.
490
- if (/^\/+$/.test(token)) return null;
491
-
492
- // Skip tokens that contain regex metacharacter sequences — these are almost
493
- // certainly grep/sed/awk patterns, not filesystem paths.
494
- // Matches: .*, .+, \|, \(, \), [...], or ^/ (anchored regex starting with /)
495
- if (REGEX_METACHAR_PATTERN.test(token)) return null;
496
-
497
- // Must look like a path: starts with /, ~/, or contains ..
498
- if (token.startsWith("/")) return token;
499
- if (token.startsWith("~/")) return token;
500
- if (token.includes("..")) return token;
501
-
502
- return null;
464
+ return tokens;
503
465
  }
504
466
 
467
+ // Token classification is delegated to bash-token-classification.ts,
468
+ // which exports classifyTokenAsPathCandidate and classifyTokenAsRuleCandidate
469
+ // with a shared rejectNonPathToken predicate eliminating the prior clone.
470
+
505
471
  // ── Leading cd detection ───────────────────────────────────────────────────
506
472
 
507
473
  /**
@@ -596,10 +562,10 @@ export async function extractExternalPathsFromBashCommand(
596
562
  if (!tree) return [];
597
563
 
598
564
  let cdTarget: string | undefined;
599
- const tokens: string[] = [];
565
+ let tokens: string[] = [];
600
566
  try {
601
567
  cdTarget = extractLeadingCdTarget(tree.rootNode);
602
- collectPathCandidateTokens(tree.rootNode, tokens);
568
+ tokens = collectPathCandidateTokens(tree.rootNode);
603
569
  } finally {
604
570
  tree.delete();
605
571
  }
@@ -647,9 +613,9 @@ export async function extractTokensForPathRules(
647
613
  const tree = parser.parse(command);
648
614
  if (!tree) return [];
649
615
 
650
- const tokens: string[] = [];
616
+ let tokens: string[] = [];
651
617
  try {
652
- collectPathCandidateTokens(tree.rootNode, tokens);
618
+ tokens = collectPathCandidateTokens(tree.rootNode);
653
619
  } finally {
654
620
  tree.delete();
655
621
  }
@@ -1,5 +1,6 @@
1
1
  import { getNonEmptyString, toRecord } from "#src/common";
2
2
  import type { Rule } from "#src/rule";
3
+ import { SessionApproval } from "#src/session-approval";
3
4
  import { deriveApprovalPattern } from "#src/session-rules";
4
5
  import type { PermissionCheckResult } from "#src/types";
5
6
  import { extractTokensForPathRules } from "./bash-path-extractor";
@@ -117,10 +118,7 @@ export async function describeBashPathGate(
117
118
  pathValue: worstToken,
118
119
  agentName: tcc.agentName ?? undefined,
119
120
  },
120
- sessionApproval: {
121
- surface: "path",
122
- pattern,
123
- },
121
+ sessionApproval: SessionApproval.single("path", pattern),
124
122
  promptDetails: {
125
123
  source: "tool_call",
126
124
  agentName: tcc.agentName,