@gotgenes/pi-permission-system 9.0.0 → 9.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.
@@ -1,636 +1,27 @@
1
- import { createRequire } from "node:module";
2
- import { basename, resolve } from "node:path";
3
-
4
- import {
5
- classifyTokenAsPathCandidate,
6
- classifyTokenAsRuleCandidate,
7
- } from "#src/handlers/gates/bash-token-classification";
8
- import {
9
- isPathWithinDirectory,
10
- isSafeSystemPath,
11
- normalizePathForComparison,
12
- } from "#src/path-utils";
13
-
14
- // ── tree-sitter-bash lazy parser ───────────────────────────────────────────
15
-
16
- /**
17
- * Minimal subset of web-tree-sitter's SyntaxNode used by the AST walker.
18
- * Defined locally so callers do not need to import web-tree-sitter types.
19
- */
20
- interface TSNode {
21
- readonly type: string;
22
- readonly text: string;
23
- readonly childCount: number;
24
- child(index: number): TSNode | null;
25
- }
26
-
27
- /**
28
- * Minimal subset of web-tree-sitter's Parser used by this module.
29
- */
30
- interface TSParser {
31
- parse(input: string): { rootNode: TSNode; delete(): void } | null;
32
- delete(): void;
33
- }
34
-
35
- let parserPromise: Promise<TSParser> | null = null;
36
-
37
- async function initParser(): Promise<TSParser> {
38
- // Use named imports — web-tree-sitter exports Parser as a named class.
39
- const { Parser, Language } = await import("web-tree-sitter");
40
- const req = createRequire(import.meta.url);
41
- const treeSitterWasm = req.resolve("web-tree-sitter/web-tree-sitter.wasm");
42
- await Parser.init({ locateFile: () => treeSitterWasm });
43
-
44
- const parser = new Parser();
45
- const bashWasm = req.resolve("tree-sitter-bash/tree-sitter-bash.wasm");
46
- const bash = await Language.load(bashWasm);
47
- parser.setLanguage(bash);
48
- return parser;
49
- }
50
-
51
- function getParser(): Promise<TSParser> {
52
- parserPromise ??= initParser();
53
- return parserPromise;
54
- }
55
-
56
- // ── AST walker ─────────────────────────────────────────────────────────────
57
-
58
- /**
59
- * Node types whose subtrees must never be descended into for
60
- * path extraction — their text content is not a command argument.
61
- */
62
- const SKIP_SUBTREE_TYPES = new Set(["heredoc_body", "heredoc_end", "comment"]);
1
+ import { BashProgram } from "./bash-program";
63
2
 
64
3
  /**
65
- * Resolve the "shell value" of an argument node the string the shell
66
- * would pass to the command after quote removal.
4
+ * Extract paths from a bash command string that resolve outside CWD.
67
5
  *
68
- * - `word` → `.text` (already unquoted)
69
- * - `raw_string` strip surrounding single quotes
70
- * - `string` → strip surrounding double quotes, concatenate children text
71
- * - `concatenation` → concatenate resolved children
72
- * - other → `.text` as fallback
73
- */
74
- function resolveNodeText(node: TSNode): string {
75
- switch (node.type) {
76
- case "word":
77
- return node.text;
78
- case "raw_string": {
79
- // Strip surrounding single quotes: 'content' → content
80
- const t = node.text;
81
- if (t.length >= 2 && t.startsWith("'") && t.endsWith("'")) {
82
- return t.slice(1, -1);
83
- }
84
- return t;
85
- }
86
- case "string": {
87
- // Double-quoted string: concatenate the resolved text of inner children,
88
- // skipping the quote-delimiter nodes (literal `"`).
89
- let result = "";
90
- for (let i = 0; i < node.childCount; i++) {
91
- const child = node.child(i);
92
- if (!child) continue;
93
- // Skip the literal `"` delimiters
94
- if (child.type === '"') continue;
95
- result += resolveNodeText(child);
96
- }
97
- return result;
98
- }
99
- case "string_content":
100
- case "simple_expansion":
101
- case "expansion":
102
- return node.text;
103
- case "concatenation": {
104
- let result = "";
105
- for (let i = 0; i < node.childCount; i++) {
106
- const child = node.child(i);
107
- if (!child) continue;
108
- result += resolveNodeText(child);
109
- }
110
- return result;
111
- }
112
- default:
113
- return node.text;
114
- }
115
- }
116
-
117
- // ── Pattern-first command config ───────────────────────────────────────────
118
-
119
- interface PatternCommandConfig {
120
- /** Flags that consume the next argument as a non-path value (pattern, separator, etc.) */
121
- readonly argConsumingFlags: ReadonlySet<string>;
122
- /** Flags that consume the next argument as a file path */
123
- readonly fileConsumingFlags: ReadonlySet<string>;
124
- /**
125
- * Number of leading positional arguments that are patterns/scripts, not paths.
126
- * Default: 1 (covers sed, awk, grep, rg).
127
- * sd uses 2 (FIND and REPLACE_WITH are both non-path positionals).
128
- */
129
- readonly patternPositionals?: number;
130
- }
131
-
132
- /**
133
- * Commands whose first N positional arguments are inline patterns/scripts,
134
- * not filesystem paths. The map stores per-command flag configuration so
135
- * the walker can correctly identify which arguments are consumed by flags
136
- * vs. which are positional.
137
- */
138
- const PATTERN_FIRST_COMMANDS: ReadonlyMap<string, PatternCommandConfig> =
139
- new Map([
140
- [
141
- "sed",
142
- {
143
- argConsumingFlags: new Set(["-e", "-i"]),
144
- fileConsumingFlags: new Set(["-f"]),
145
- },
146
- ],
147
- [
148
- "awk",
149
- {
150
- argConsumingFlags: new Set(["-e", "-F", "-v"]),
151
- fileConsumingFlags: new Set(["-f"]),
152
- },
153
- ],
154
- [
155
- "gawk",
156
- {
157
- argConsumingFlags: new Set(["-e", "-F", "-v"]),
158
- fileConsumingFlags: new Set(["-f"]),
159
- },
160
- ],
161
- [
162
- "nawk",
163
- {
164
- argConsumingFlags: new Set(["-e", "-F", "-v"]),
165
- fileConsumingFlags: new Set(["-f"]),
166
- },
167
- ],
168
- [
169
- "grep",
170
- {
171
- argConsumingFlags: new Set(["-e", "-A", "-B", "-C", "-m"]),
172
- fileConsumingFlags: new Set(["-f"]),
173
- },
174
- ],
175
- [
176
- "egrep",
177
- {
178
- argConsumingFlags: new Set(["-e", "-A", "-B", "-C", "-m"]),
179
- fileConsumingFlags: new Set(["-f"]),
180
- },
181
- ],
182
- [
183
- "fgrep",
184
- {
185
- argConsumingFlags: new Set(["-e", "-A", "-B", "-C", "-m"]),
186
- fileConsumingFlags: new Set(["-f"]),
187
- },
188
- ],
189
- [
190
- "rg",
191
- {
192
- argConsumingFlags: new Set([
193
- "-e",
194
- "-A",
195
- "-B",
196
- "-C",
197
- "-m",
198
- "-g",
199
- "-t",
200
- "-T",
201
- "-j",
202
- "-M",
203
- "-r",
204
- "-E",
205
- ]),
206
- fileConsumingFlags: new Set(["-f"]),
207
- },
208
- ],
209
- [
210
- "sd",
211
- {
212
- argConsumingFlags: new Set(["-n", "-f"]),
213
- fileConsumingFlags: new Set([]),
214
- patternPositionals: 2,
215
- },
216
- ],
217
- ]);
218
-
219
- /** Node types that represent argument values in the AST. */
220
- const ARG_NODE_TYPES = new Set([
221
- "word",
222
- "concatenation",
223
- "string",
224
- "raw_string",
225
- ]);
226
-
227
- /**
228
- * Extract the command name from a `command` node.
229
- * Returns the basename (e.g. `/usr/bin/sed` → `sed`), or undefined
230
- * if the command name cannot be determined (e.g. variable expansion).
231
- */
232
- function extractCommandName(node: TSNode): string | undefined {
233
- for (let i = 0; i < node.childCount; i++) {
234
- const child = node.child(i);
235
- if (!child) continue;
236
- if (child.type === "command_name") {
237
- const text = resolveNodeText(child);
238
- return text ? basename(text) : undefined;
239
- }
240
- }
241
- return undefined;
242
- }
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
-
285
- /**
286
- * Collect path-candidate tokens from a command known to have
287
- * pattern/script arguments in leading positional slots.
288
- *
289
- * Uses position-based skipping: the first N positional arguments
290
- * (where N = patternPositionals, default 1) are assumed to be
291
- * inline patterns/scripts and are skipped. Remaining positional
292
- * arguments are collected as path candidates.
293
- *
294
- * Flags listed in `argConsumingFlags` consume the next argument
295
- * (skipped). Flags in `fileConsumingFlags` consume the next
296
- * argument as a file path (collected). The flags `-e` and `-f`
297
- * additionally signal that an explicit script was provided via
298
- * flag, so no inline positional script is expected.
299
- */
300
- function collectPatternCommandTokens(
301
- node: TSNode,
302
- config: PatternCommandConfig,
303
- ): string[] {
304
- const patternPositionals = config.patternPositionals ?? 1;
305
- let hasExplicitScript = false;
306
- let positionalsSeen = 0;
307
- let nextArgAction: "skip" | "extract" | null = null;
308
- let pastEndOfFlags = false;
309
- const tokens: string[] = [];
310
-
311
- for (let i = 0; i < node.childCount; i++) {
312
- const child = node.child(i);
313
- if (!child) continue;
314
-
315
- // Skip command_name and variable_assignment nodes.
316
- if (child.type === "command_name" || child.type === "variable_assignment")
317
- continue;
318
-
319
- // Only process argument-like nodes; recurse into others
320
- // (e.g. command_substitution) for nested commands.
321
- if (!ARG_NODE_TYPES.has(child.type)) {
322
- tokens.push(...collectPathCandidateTokens(child));
323
- continue;
324
- }
325
-
326
- const text = resolveNodeText(child);
327
-
328
- // Handle consumed argument from previous flag.
329
- if (nextArgAction === "skip") {
330
- nextArgAction = null;
331
- continue;
332
- }
333
- if (nextArgAction === "extract") {
334
- tokens.push(text);
335
- nextArgAction = null;
336
- continue;
337
- }
338
-
339
- // Flag detection (only before "--" end-of-flags marker).
340
- if (
341
- !pastEndOfFlags &&
342
- child.type === "word" &&
343
- text.startsWith("-") &&
344
- text.length > 1
345
- ) {
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;
357
- }
358
- continue;
359
- }
360
-
361
- // Positional argument.
362
- if (!hasExplicitScript && positionalsSeen < patternPositionals) {
363
- positionalsSeen++;
364
- continue; // Skip: this is an inline pattern/script.
365
- }
366
-
367
- // File argument — collect as path candidate.
368
- tokens.push(text);
369
- }
370
-
371
- return tokens;
372
- }
373
-
374
- /**
375
- * Collect all argument tokens from a generic (non-pattern-first) command node,
376
- * skipping the command name and variable assignments.
377
- */
378
- function collectGenericCommandTokens(node: TSNode): string[] {
379
- const tokens: string[] = [];
380
- let seenCommandName = false;
381
-
382
- for (let i = 0; i < node.childCount; i++) {
383
- const child = node.child(i);
384
- if (!child) continue;
385
-
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;
392
-
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;
398
- }
399
-
400
- // Argument nodes: resolve their text and collect.
401
- if (ARG_NODE_TYPES.has(child.type)) {
402
- tokens.push(resolveNodeText(child));
403
- continue;
404
- }
405
-
406
- // Recurse into other children (e.g. command_substitution nested in args)
407
- tokens.push(...collectPathCandidateTokens(child));
408
- }
409
-
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[] = [];
418
- for (let i = 0; i < node.childCount; i++) {
419
- const child = node.child(i);
420
- if (!child) continue;
421
- if (ARG_NODE_TYPES.has(child.type)) {
422
- tokens.push(resolveNodeText(child));
423
- }
424
- }
425
- return tokens;
426
- }
427
-
428
- /**
429
- * Select the collection strategy for a `command` node: pattern-first
430
- * commands use `collectPatternCommandTokens`; all others use
431
- * `collectGenericCommandTokens`.
432
- */
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
- }
442
-
443
- /**
444
- * Recursively visit the AST and collect resolved text of nodes that
445
- * represent command arguments or redirect destinations.
446
- *
447
- * Skips `heredoc_body`, `heredoc_end`, and `comment` subtrees entirely.
448
- *
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.
453
- */
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);
458
-
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));
463
- }
464
- return tokens;
465
- }
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
-
471
- // ── Leading cd detection ───────────────────────────────────────────────────
472
-
473
- /**
474
- * Walk down from the root to find the first `command` node in the program.
475
- *
476
- * Only descends into `program` and `list` nodes — subshells, pipelines, and
477
- * other compound statements are ignored because a `cd` inside them does not
478
- * affect the outer shell's working directory.
479
- */
480
- function findFirstCommand(node: TSNode): TSNode | null {
481
- if (node.type === "command") return node;
482
- if (node.type === "program" || node.type === "list") {
483
- const firstChild = node.child(0);
484
- if (firstChild) return findFirstCommand(firstChild);
485
- }
486
- return null;
487
- }
488
-
489
- /**
490
- * Extract the target directory of a leading `cd` command from the parsed AST.
491
- *
492
- * When a bash command begins with `cd <dir> && …`, the shell resolves
493
- * subsequent relative paths against `<dir>`, not the original working
494
- * directory. The external-directory guard must do the same, otherwise a
495
- * path that the shell keeps inside the working directory can appear to
496
- * escape it and trigger a spurious permission prompt.
497
- *
498
- * Returns `undefined` when the first command is not `cd`, or when the
499
- * target cannot be meaningfully resolved (`cd -`, bare `cd`, or `cd ~…`).
500
- */
501
- function extractLeadingCdTarget(rootNode: TSNode): string | undefined {
502
- const firstCmd = findFirstCommand(rootNode);
503
- if (!firstCmd) return undefined;
504
-
505
- const cmdName = extractCommandName(firstCmd);
506
- if (cmdName !== "cd") return undefined;
507
-
508
- for (let i = 0; i < firstCmd.childCount; i++) {
509
- const child = firstCmd.child(i);
510
- if (!child) continue;
511
- if (child.type === "command_name" || child.type === "variable_assignment")
512
- continue;
513
- if (!ARG_NODE_TYPES.has(child.type)) continue;
514
-
515
- const text = resolveNodeText(child);
516
- // Skip `--` (end-of-flags marker)
517
- if (text === "--") continue;
518
- // `cd -` jumps to $OLDPWD; `cd ~…` is home-relative — neither can be
519
- // resolved against the working directory.
520
- if (text === "-" || text.startsWith("~")) return undefined;
521
- return text;
522
- }
523
- return undefined;
524
- }
525
-
526
- /**
527
- * Compute the effective base directory for resolving relative path candidates.
528
- *
529
- * When the leading `cd` target stays within the working directory, subsequent
530
- * relative paths should be resolved against it. An escaping target is itself
531
- * an external access (reported via its own candidate token) and must never
532
- * silence checks on subsequent paths, so the function falls back to `cwd`.
533
- */
534
- function computeEffectiveResolveBase(
535
- cdTarget: string | undefined,
536
- cwd: string,
537
- ): string {
538
- if (cdTarget === undefined) return cwd;
539
- const resolved = resolve(cwd, cdTarget);
540
- const normalizedCwd = resolve(cwd);
541
- return isPathWithinDirectory(resolved, normalizedCwd) ? resolved : cwd;
542
- }
543
-
544
- // ── Public extractors ──────────────────────────────────────────────────────
545
-
546
- /**
547
- * Extracts paths from a bash command string that resolve outside CWD.
548
- * Uses tree-sitter-bash to parse the command into a full AST, then walks
549
- * command argument and redirect-destination nodes. Heredoc bodies, comments,
550
- * and other non-argument content are skipped, eliminating false positives.
551
- *
552
- * When the command begins with `cd <dir> && …`, relative candidate paths are
553
- * resolved against `<dir>` (if it stays within CWD) rather than CWD itself,
554
- * mirroring how the shell would resolve them.
6
+ * Thin facade over {@link BashProgram.externalPaths}; parses the command and
7
+ * returns the cd-aware external paths. See `BashProgram` for the parsing and
8
+ * resolution semantics.
555
9
  */
556
10
  export async function extractExternalPathsFromBashCommand(
557
11
  command: string,
558
12
  cwd: string,
559
13
  ): Promise<string[]> {
560
- const parser = await getParser();
561
- const tree = parser.parse(command);
562
- if (!tree) return [];
563
-
564
- let cdTarget: string | undefined;
565
- let tokens: string[] = [];
566
- try {
567
- cdTarget = extractLeadingCdTarget(tree.rootNode);
568
- tokens = collectPathCandidateTokens(tree.rootNode);
569
- } finally {
570
- tree.delete();
571
- }
572
-
573
- const resolveBase = computeEffectiveResolveBase(cdTarget, cwd);
574
- const normalizedCwd = normalizePathForComparison(cwd, cwd);
575
-
576
- const seen = new Set<string>();
577
- const externalPaths: string[] = [];
578
-
579
- for (const token of tokens) {
580
- const candidate = classifyTokenAsPathCandidate(token);
581
- if (!candidate) continue;
582
-
583
- const normalized = normalizePathForComparison(candidate, resolveBase);
584
- if (!normalized) continue;
585
-
586
- if (
587
- normalizedCwd !== "" &&
588
- !isSafeSystemPath(normalized) &&
589
- !isPathWithinDirectory(normalized, normalizedCwd) &&
590
- !seen.has(normalized)
591
- ) {
592
- seen.add(normalized);
593
- externalPaths.push(normalized);
594
- }
595
- }
596
-
597
- return externalPaths;
14
+ return (await BashProgram.parse(command)).externalPaths(cwd);
598
15
  }
599
16
 
600
17
  /**
601
- * Extract tokens from a bash command that may be file paths, using a broader
18
+ * Extract tokens from a bash command that may be file paths, using the broader
602
19
  * filter suitable for cross-cutting `path` permission rules.
603
20
  *
604
- * Unlike `extractExternalPathsFromBashCommand`, this function:
605
- * - Accepts relative paths (`.env`, `src/foo.ts`, `./build`)
606
- * - Does NOT filter by CWD — returns raw tokens for rule evaluation
607
- * - Returns deduplicated tokens
21
+ * Thin facade over {@link BashProgram.pathTokens}.
608
22
  */
609
23
  export async function extractTokensForPathRules(
610
24
  command: string,
611
25
  ): Promise<string[]> {
612
- const parser = await getParser();
613
- const tree = parser.parse(command);
614
- if (!tree) return [];
615
-
616
- let tokens: string[] = [];
617
- try {
618
- tokens = collectPathCandidateTokens(tree.rootNode);
619
- } finally {
620
- tree.delete();
621
- }
622
-
623
- const seen = new Set<string>();
624
- const result: string[] = [];
625
-
626
- for (const token of tokens) {
627
- const candidate = classifyTokenAsRuleCandidate(token);
628
- if (!candidate) continue;
629
- if (!seen.has(candidate)) {
630
- seen.add(candidate);
631
- result.push(candidate);
632
- }
633
- }
634
-
635
- return result;
26
+ return (await BashProgram.parse(command)).pathTokens();
636
27
  }
@@ -4,6 +4,7 @@ import { SessionApproval } from "#src/session-approval";
4
4
  import { deriveApprovalPattern } from "#src/session-rules";
5
5
  import type { PermissionCheckResult } from "#src/types";
6
6
  import { extractTokensForPathRules } from "./bash-path-extractor";
7
+ import { pickMostRestrictive } from "./candidate-check";
7
8
  import type { GateResult } from "./descriptor";
8
9
  import { formatPathAskPrompt } from "./path";
9
10
  import type { ToolCallContext } from "./types";
@@ -45,8 +46,9 @@ export async function describeBashPathGate(
45
46
  // Check each token against path rules with session rules appended.
46
47
  const sessionRules = getSessionRuleset();
47
48
 
48
- let worstCheck: PermissionCheckResult | null = null;
49
- let worstToken: string | null = null;
49
+ // Tokens whose resolved state needs a check (deny/ask), paired with the
50
+ // token that produced them so the descriptor can derive its pattern.
51
+ const uncovered: Array<{ token: string; check: PermissionCheckResult }> = [];
50
52
  let allSessionCovered = true;
51
53
 
52
54
  for (const token of tokens) {
@@ -70,13 +72,11 @@ export async function describeBashPathGate(
70
72
  }
71
73
 
72
74
  if (check.state === "deny") {
73
- worstCheck = check;
74
- worstToken = token;
75
+ uncovered.push({ token, check });
75
76
  break; // Short-circuit on deny.
76
77
  }
77
- if (check.state === "ask" && worstCheck?.state !== "ask") {
78
- worstCheck = check;
79
- worstToken = token;
78
+ if (check.state === "ask") {
79
+ uncovered.push({ token, check });
80
80
  }
81
81
  }
82
82
 
@@ -99,6 +99,12 @@ export async function describeBashPathGate(
99
99
  };
100
100
  }
101
101
 
102
+ // Pick the most restrictive (deny > ask > allow, first-wins) uncovered token.
103
+ const worstCheck = pickMostRestrictive(uncovered.map(({ check }) => check));
104
+ const worstToken = worstCheck
105
+ ? (uncovered.find(({ check }) => check === worstCheck)?.token ?? null)
106
+ : null;
107
+
102
108
  // All tokens evaluate to allow — no restriction.
103
109
  if (!worstCheck || !worstToken) return null;
104
110