@gotgenes/pi-permission-system 39.1.2 → 40.0.0

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/CHANGELOG.md CHANGED
@@ -5,6 +5,23 @@ 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
+ ## [40.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v39.1.2...pi-permission-system-v40.0.0) (2026-10-06)
9
+
10
+
11
+ ### Features
12
+
13
+ * **pi-permission-system:** report which spelling of a bash command a rule matched ([9c1a9da](https://github.com/gotgenes/pi-packages/commit/9c1a9da4ee19e25aa6ea8366f3d6a5062e2d3c2c)), closes [#910](https://github.com/gotgenes/pi-packages/issues/910)
14
+ * **pi-permission-system:** show the matching bash spelling in the ask dialog and the review log ([09c1ef6](https://github.com/gotgenes/pi-packages/commit/09c1ef62e9b1fdc6dc4beabc7193ac89ef9496ce)), closes [#910](https://github.com/gotgenes/pi-packages/issues/910)
15
+
16
+ ### Bug Fixes
17
+
18
+ * **pi-permission-system:** **breaking:** match a bash rule written with an absolute path against the relative spelling of that path ([8883d96](https://github.com/gotgenes/pi-packages/commit/8883d960f4ccbe6131e22959e3acbc35abe2558b)), closes [#910](https://github.com/gotgenes/pi-packages/issues/910)
19
+
20
+ ### Documentation
21
+
22
+ * **pi-permission-system:** document the absolute argument spelling of a bash command ([29816e7](https://github.com/gotgenes/pi-packages/commit/29816e7664cb2a813f042fe1b41d7b46ce5c870e)), closes [#910](https://github.com/gotgenes/pi-packages/issues/910)
23
+ * **pi-permission-system:** note that a slash-bearing non-path argument is spelled too ([8a21456](https://github.com/gotgenes/pi-packages/commit/8a21456faa43eb86ac0c0cb790cff3e170b73ee2)), closes [#910](https://github.com/gotgenes/pi-packages/issues/910)
24
+
8
25
  ## [39.1.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v39.1.1...pi-permission-system-v39.1.2) (2026-10-06)
9
26
 
10
27
 
package/dist/public.d.ts CHANGED
@@ -63,6 +63,13 @@ interface PermissionCheckResult {
63
63
  * {@link floorExemption} says the wrapper's floor has no reason to hold.
64
64
  */
65
65
  executedUnit?: string;
66
+ /**
67
+ * The spelling of the bash unit the winning rule matched, when the rule did
68
+ * not also match the unit as typed — the absolute spelling of a relative
69
+ * path argument, say (#910). Display-only: absent whenever the typed text
70
+ * decided, and dropped by a floor that replaces {@link matchedPattern}.
71
+ */
72
+ matchedSpelling?: string;
66
73
  /**
67
74
  * Set when the winning bash unit is a wrapper the floor no longer covers,
68
75
  * naming why (#803, #963). Recorded in the review log so an allow the floor would
@@ -145,6 +152,12 @@ interface PromptRequestFacts {
145
152
  readonly value: string;
146
153
  /** The matched rule, including a sentinel such as `<indirection-bash-wrapper>`. */
147
154
  readonly matchedPattern: string | null;
155
+ /**
156
+ * For bash, the spelling of the unit the matched rule matched when it did not
157
+ * match {@link value} as typed — the absolute spelling of a relative path
158
+ * argument, say. `null` when the typed text decided.
159
+ */
160
+ readonly matchedSpelling: string | null;
148
161
  /**
149
162
  * Where the offending bash unit runs, when it came from a substitution or a
150
163
  * subshell. A fact rather than a rendered clause: it is what makes the
@@ -453,6 +453,41 @@ Control-flow bodies (`if`/`while`/`for`/`case`) and `{ … }` brace groups are n
453
453
 
454
454
  A command that starts with `~`, `$HOME`, or `${HOME}` also matches a home-anchored pattern spelled any of those ways, or with the absolute home directory (see [Home Directory Expansion in Patterns](#home-directory-expansion-in-patterns)).
455
455
 
456
+ A path argument also matches in its absolute spelling.
457
+ Each command is matched as typed and with every path argument replaced by the absolute path it resolves to, against the working directory a literal `cd` earlier in the chain moved to.
458
+ The last rule matching either text wins, as for any other pattern.
459
+
460
+ ```jsonc
461
+ {
462
+ "permission": {
463
+ "bash": {
464
+ "*": "allow",
465
+ "rm *": "ask",
466
+ "rm /tmp/agent-builds/*": "allow"
467
+ }
468
+ }
469
+ }
470
+ ```
471
+
472
+ With this policy, `cd /tmp && rm agent-builds/x` runs, because `rm agent-builds/x` is also matched as `rm /tmp/agent-builds/x`.
473
+ An absolute `deny` reaches the relative spelling the same way.
474
+ An absolute `allow` written *before* the broader `ask` still loses to it.
475
+
476
+ Some arguments get no absolute spelling and are matched as typed only:
477
+
478
+ - an argument after a `cd` whose target is not literal (`cd "$DIR"`, `cd -`, a bare `cd`), since the directory is not known;
479
+ - an argument holding a glob (`*`, `?`, `[`), quoted or not;
480
+ - an argument a variable or substitution computes (`$DIR/x`, `$(pwd)/x`);
481
+ - a bare name such as `config.json` that names nothing on disk;
482
+ - a POSIX absolute path on Windows (`/tmp/x`), which is kept as typed.
483
+
484
+ The absolute spelling replaces every path argument of a command at once, and it is separate from the home spelling above.
485
+ A rule naming one argument absolute and another relative, or naming both the expanded home and an absolute argument, matches neither text.
486
+ A quoted argument is spelled without its quotes (`rm "a b/c"` is also matched as `rm /tmp/a b/c`).
487
+ Whether an argument is a path is decided by its shape, so a slash-bearing word that is not one is spelled too: `git push origin feature/x` is also matched as `git push origin <cwd>/feature/x`, which only a rule naming that absolute form would match.
488
+ When the spelling is what a rule matched, the prompt shows it on a `matched as` line beside the rule, and the review log records it as `matchedSpelling`.
489
+ A session approval still records the command as typed, so approving `rm agent-builds/x` does not cover a later `rm /tmp/agent-builds/x`.
490
+
456
491
  A leading environment-variable assignment prefix is stripped before matching, so the rule gates the underlying command rather than the prefix.
457
492
  So `AWS_PROFILE=prod aws ec2 …` is matched as `aws ec2 …` — a `aws *` rule applies even though the invocation begins with `AWS_PROFILE=`.
458
493
  Prefixes like `PGPASSWORD=` and `KUBECONFIG=` are handled the same way.
@@ -428,16 +428,17 @@ Forwarding is orthogonal to origin: a forwarded subagent prompt keeps its origin
428
428
  The facts every render of the ask shows, that no renderer's budget may elide.
429
429
  Nested rather than flattened so the event and the prompt payload share one shape: a fact added here reaches the bus without a second hand-maintained declaration.
430
430
 
431
- | Field | Type | Description |
432
- | ----------------- | ---------------------------- | ------------------------------------------------------------------------------------------------ |
433
- | `requester` | `PromptRequester` | Who is asking, and whether the ask arrived from a subagent |
434
- | `surface` | `string` | The **gate** surface the rule fired on — `"external_directory"`, `"path"`, `"bash"`, a tool name |
435
- | `toolName` | `string \| null` | The gated tool name; `null` when the ask is not tool-shaped |
436
- | `invokedToolName` | `string \| null` | The invoked name when a shell alias re-exposes bash under another name |
437
- | `value` | `string` | The decision-relevant value: the command, path, MCP target, or skill name |
438
- | `matchedPattern` | `string \| null` | The matched rule, including a sentinel such as `<indirection-bash-wrapper>` |
439
- | `commandContext` | `BashCommandContext \| null` | Where the offending bash unit runs, when it came from a substitution or subshell |
440
- | `executedUnit` | `string \| null` | For bash, the unit that will actually run, including inside an unstrippable wrapper |
431
+ | Field | Type | Description |
432
+ | ----------------- | ---------------------------- | -------------------------------------------------------------------------------------------------- |
433
+ | `requester` | `PromptRequester` | Who is asking, and whether the ask arrived from a subagent |
434
+ | `surface` | `string` | The **gate** surface the rule fired on — `"external_directory"`, `"path"`, `"bash"`, a tool name |
435
+ | `toolName` | `string \| null` | The gated tool name; `null` when the ask is not tool-shaped |
436
+ | `invokedToolName` | `string \| null` | The invoked name when a shell alias re-exposes bash under another name |
437
+ | `value` | `string` | The decision-relevant value: the command, path, MCP target, or skill name |
438
+ | `matchedPattern` | `string \| null` | The matched rule, including a sentinel such as `<indirection-bash-wrapper>` |
439
+ | `matchedSpelling` | `string \| null` | For bash, the spelling of the command the rule matched, when it did not match the command as typed |
440
+ | `commandContext` | `BashCommandContext \| null` | Where the offending bash unit runs, when it came from a substitution or subshell |
441
+ | `executedUnit` | `string \| null` | For bash, the unit that will actually run, including inside an unstrippable wrapper |
441
442
 
442
443
  `PromptRequester` carries `agentName` (`string | null`), `forwarded` (`boolean`), and `sessionId` (`string | null`, the requesting session for a forwarded ask).
443
444
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "39.1.2",
3
+ "version": "40.0.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -7,6 +7,7 @@ import {
7
7
  import { normalizePathPolicyLiteral } from "#src/access-intent/path-normalization";
8
8
  import type { PathNormalizer } from "#src/path/path-normalizer";
9
9
  import { isSafeSystemPath } from "#src/path/safe-system-paths";
10
+ import type { ArgumentSpeller } from "./command-enumeration";
10
11
  import {
11
12
  ARG_NODE_TYPES,
12
13
  SKIP_SUBTREE_TYPES,
@@ -26,6 +27,7 @@ import {
26
27
  collectRedirectTokens,
27
28
  extractCommandName,
28
29
  type PathToken,
30
+ type SourceSpan,
29
31
  type TokenRole,
30
32
  } from "./token-collection";
31
33
 
@@ -46,15 +48,13 @@ type EffectiveBase =
46
48
  | { readonly kind: "unknown" };
47
49
 
48
50
  /**
49
- * A path-candidate token paired with the effective working directory projected
50
- * onto the point in the command stream where it appears, the effect its
51
- * position proved, and the role its collector gave it.
51
+ * A collected {@link PathToken} — its text, the effect its position proved,
52
+ * the role its collector gave it, and its source span when it is a whole
53
+ * argument — paired with the effective working directory projected onto the
54
+ * point in the command stream where it appears.
52
55
  */
53
- interface PathCandidate {
54
- readonly token: string;
56
+ interface PathCandidate extends PathToken {
55
57
  readonly base: EffectiveBase;
56
- readonly effect: TokenEffect;
57
- readonly role: TokenRole;
58
58
  }
59
59
 
60
60
  /** A promoted bare token and its resolved path, before an effect is attached. */
@@ -89,6 +89,12 @@ export interface ResolvedBashPaths {
89
89
  readonly externalAccesses: readonly BashExternalPath[];
90
90
  /** Every path-rule token paired with its cd-aware policy values (#393). */
91
91
  readonly ruleCandidates: readonly BashPathRuleCandidate[];
92
+ /**
93
+ * The absolute spelling of each primary-tree argument node a rule candidate
94
+ * resolved to an absolute path, for the command enumerator to spell a unit
95
+ * with (#910). See {@link BashPathResolver.resolve} for what is left out.
96
+ */
97
+ readonly argumentSpellings: ArgumentSpeller;
92
98
  }
93
99
 
94
100
  // ── Walk-time constants ──────────────────────────────────────────────────────
@@ -155,6 +161,11 @@ export class BashPathResolver {
155
161
  * A token spelled from a `HOME` the program rebinds leaves both slices before
156
162
  * projection: path normalization would otherwise expand its `~`/`$HOME` prefix
157
163
  * to the startup home, naming a directory the shell never touches.
164
+ *
165
+ * The argument spellings come from the same projection as the rule
166
+ * candidates, keyed by the source span of a whole-argument token. A salvaged
167
+ * fragment's tokens carry no span, because its indices are its own re-parse's
168
+ * and would collide with the primary tree's.
158
169
  */
159
170
  resolve(
160
171
  rootNode: TSNode,
@@ -166,16 +177,21 @@ export class BashPathResolver {
166
177
  : this.deriveBaseFromCdTarget(CWD_BASE, this.workdir);
167
178
  const collected = this.collectPathCandidates(rootNode, initialBase);
168
179
  for (const salvaged of salvagedRoots) {
169
- this.walkForCandidates(salvaged, UNKNOWN_BASE, collected);
180
+ const fragment: PathCandidate[] = [];
181
+ this.walkForCandidates(salvaged, UNKNOWN_BASE, fragment);
182
+ collected.push(...fragment.map(withoutSpan));
170
183
  }
171
184
  const candidates = collected.filter(
172
185
  ({ token }) => !this.words.spellsReboundHome(token),
173
186
  );
187
+ const { ruleCandidates, argumentSpellings } =
188
+ this.projectRuleCandidates(candidates);
174
189
  return {
175
190
  externalAccesses: this.withWorkdirExternal(
176
191
  this.projectExternalPaths(candidates),
177
192
  ),
178
- ruleCandidates: this.projectRuleCandidates(candidates),
193
+ ruleCandidates,
194
+ argumentSpellings,
179
195
  };
180
196
  }
181
197
 
@@ -575,13 +591,15 @@ export class BashPathResolver {
575
591
  * A token after a non-literal `cd` keeps only its literal value so no
576
592
  * spurious absolute rule can match (#393).
577
593
  */
578
- private projectRuleCandidates(
579
- candidates: readonly PathCandidate[],
580
- ): BashPathRuleCandidate[] {
594
+ private projectRuleCandidates(candidates: readonly PathCandidate[]): {
595
+ ruleCandidates: BashPathRuleCandidate[];
596
+ argumentSpellings: ArgumentSpeller;
597
+ } {
581
598
  const seen = new Map<string, number>();
582
599
  const result: BashPathRuleCandidate[] = [];
600
+ const spellings = new Map<string, string>();
583
601
 
584
- for (const { token, base, effect, role } of candidates) {
602
+ for (const { token, base, effect, role, span } of candidates) {
585
603
  const shaped = admittedByRole(role)
586
604
  ? token
587
605
  : classifyTokenAsRuleCandidate(token, this.normalizer.flavor);
@@ -594,6 +612,11 @@ export class BashPathResolver {
594
612
  const matchValues = probed.path.matchValues();
595
613
  if (matchValues.length === 0) continue;
596
614
 
615
+ // Recorded per occurrence, ahead of the fold below, so `cp a/x a/x`
616
+ // spells both words rather than the first.
617
+ const spelling = span && this.absoluteSpellingOf(token, probed.path);
618
+ if (spelling) spellings.set(spanKey(span), spelling);
619
+
597
620
  const key = matchValues.join("\0");
598
621
  const index = seen.get(key);
599
622
  if (index !== undefined) {
@@ -611,7 +634,35 @@ export class BashPathResolver {
611
634
  result.push({ ...probed, effect });
612
635
  }
613
636
 
614
- return result;
637
+ return {
638
+ ruleCandidates: result,
639
+ argumentSpellings: {
640
+ absoluteSpellingOf: (node) =>
641
+ spellings.get(
642
+ spanKey({ start: node.startIndex, end: node.endIndex }),
643
+ ),
644
+ },
645
+ };
646
+ }
647
+
648
+ /**
649
+ * The absolute spelling of a whole-argument token, or `undefined` when giving
650
+ * one would invent rather than resolve.
651
+ *
652
+ * Absolute only: a token after a non-literal `cd` resolves to its literal,
653
+ * which is not absolute, so the check that keeps the spelling absolute is
654
+ * also what keeps that token unspelled (#393). A glob is never spelled,
655
+ * quoted or not — what it names is decided by expanding it (#822) — and a
656
+ * token already spelled as it resolves needs no second spelling.
657
+ */
658
+ private absoluteSpellingOf(
659
+ token: string,
660
+ path: AccessPath,
661
+ ): string | undefined {
662
+ if (GLOB_CHARACTERS.test(token)) return undefined;
663
+ const spelling = path.value();
664
+ if (!spelling || spelling === token) return undefined;
665
+ return this.normalizer.isAbsolute(spelling) ? spelling : undefined;
615
666
  }
616
667
 
617
668
  /**
@@ -683,6 +734,24 @@ export class BashPathResolver {
683
734
 
684
735
  // ── Pure AST/string helpers ──────────────────────────────────────────────────
685
736
 
737
+ /** A character that makes the shell expand a word against the filesystem. */
738
+ const GLOB_CHARACTERS = /[*?[]/;
739
+
740
+ /** The key an argument spelling is filed under: its node's source span. */
741
+ function spanKey({ start, end }: SourceSpan): string {
742
+ return `${start}:${end}`;
743
+ }
744
+
745
+ /** A salvaged candidate, which carries no span in the primary tree's coordinates. */
746
+ function withoutSpan({
747
+ token,
748
+ effect,
749
+ role,
750
+ base,
751
+ }: PathCandidate): PathCandidate {
752
+ return { token, effect, role, base };
753
+ }
754
+
686
755
  /**
687
756
  * True when the statement at `index` is immediately followed by the background
688
757
  * operator (`&`) — distinct from the `&&` / `||` / `;` current-shell
@@ -699,8 +768,8 @@ function tagTokens(
699
768
  base: EffectiveBase,
700
769
  out: PathCandidate[],
701
770
  ): void {
702
- for (const { token, effect, role } of tokens) {
703
- out.push({ token, base, effect, role });
771
+ for (const token of tokens) {
772
+ out.push({ ...token, base });
704
773
  }
705
774
  }
706
775
 
@@ -77,13 +77,28 @@ export interface BashCommand {
77
77
  * Other spellings of {@link text} the shell runs identically, matched with
78
78
  * it as aliases of one invocation. Absent when there are none.
79
79
  *
80
- * Today the only spelling is the home-expanded one, for a unit whose text
81
- * opens with `~`, `$HOME`, or `${HOME}` in a program that leaves `HOME`
82
- * alone ({@link WordReader.spellHomeAtStart}).
80
+ * Two producers supply them, each spelling the text on its own: the
81
+ * home-expanded spelling of a unit whose text opens with `~`, `$HOME`, or
82
+ * `${HOME}` in a program that leaves `HOME` alone
83
+ * ({@link WordReader.spellHomeAtStart}), and the argument spelling, in which
84
+ * every argument word the {@link ArgumentSpeller} resolved to an absolute
85
+ * path is replaced by that path (#910).
83
86
  */
84
87
  readonly spellings?: readonly string[];
85
88
  }
86
89
 
90
+ /**
91
+ * What the enumerator asks about one argument word of a unit: the absolute
92
+ * spelling of the path it names, as the party that resolved the program's
93
+ * paths knows it, or `undefined` when there is none to give.
94
+ *
95
+ * Asked by node rather than by text, because only the node says which
96
+ * occurrence of a token a unit's word is.
97
+ */
98
+ export interface ArgumentSpeller {
99
+ absoluteSpellingOf(node: TSNode): string | undefined;
100
+ }
101
+
87
102
  /**
88
103
  * What the statement enclosing a command unit establishes about it.
89
104
  *
@@ -119,16 +134,27 @@ interface UnitScope {
119
134
  * program's variables are the same wherever in it a word sits.
120
135
  */
121
136
  readonly words: WordReader;
137
+ /**
138
+ * What an argument word's absolute spelling is, from the party that resolved
139
+ * the program's paths. Relayed unchanged, including into nested executions:
140
+ * it answers by node, so each unit asks about its own words. Absent for a
141
+ * salvaged region, whose nodes are not the primary tree's.
142
+ */
143
+ readonly speller?: ArgumentSpeller;
122
144
  }
123
145
 
124
146
  /** A top-level command in the current shell, writing no file, fully parsed. */
125
- function topLevelScope(words: WordReader): UnitScope {
126
- return {
147
+ function topLevelScope(
148
+ words: WordReader,
149
+ speller: ArgumentSpeller | undefined,
150
+ ): UnitScope {
151
+ const scope: UnitScope = {
127
152
  writesViaRedirect: false,
128
153
  parseUnresolved: false,
129
154
  salvaged: false,
130
155
  words,
131
156
  };
157
+ return speller === undefined ? scope : { ...scope, speller };
132
158
  }
133
159
 
134
160
  /**
@@ -267,9 +293,10 @@ const STATEMENT_TYPES = new Set([
267
293
  export function collectCommands(
268
294
  node: TSNode,
269
295
  words: WordReader,
296
+ speller?: ArgumentSpeller,
270
297
  ): BashCommand[] {
271
298
  const out: BashCommand[] = [];
272
- collectCommandsInto(node, topLevelScope(words), out);
299
+ collectCommandsInto(node, topLevelScope(words, speller), out);
273
300
  return out;
274
301
  }
275
302
 
@@ -462,10 +489,12 @@ function makeUnit(
462
489
  * surely as one on the enclosing statement.
463
490
  */
464
491
  function makeCommandUnit(node: TSNode, scope: UnitScope): BashCommand {
465
- const { text, words } = readCommandUnit(node, scope.words);
466
- const homeSpelling = scope.words.spellHomeAtStart(text);
492
+ const { text, words, argumentSpelling } = readCommandUnit(node, scope);
467
493
  return makeUnit(text, scope, {
468
- spellings: homeSpelling === undefined ? undefined : [homeSpelling],
494
+ spellings: distinctSpellings(text, [
495
+ scope.words.spellHomeAtStart(text),
496
+ argumentSpelling,
497
+ ]),
469
498
  wrapperKind: classifyWrapperWords(words),
470
499
  executedUnit: executedUnitOf(text, words) ?? undefined,
471
500
  floorExemption: floorExemptionOf(words, redirectedScope(node, scope)),
@@ -520,10 +549,11 @@ function redirectedScope(node: TSNode, scope: UnitScope): UnitScope {
520
549
  */
521
550
  function readCommandUnit(
522
551
  node: TSNode,
523
- reader: WordReader,
552
+ scope: UnitScope,
524
553
  ): {
525
554
  text: string;
526
555
  words: CommandWord[];
556
+ argumentSpelling?: string;
527
557
  } {
528
558
  const nodes = commandWordNodes(node);
529
559
  if (nodes.length === 0) return { text: node.text, words: [] };
@@ -531,18 +561,47 @@ function readCommandUnit(
531
561
  const redirects = hostedRedirects(node);
532
562
  const words: CommandWord[] = [];
533
563
  let text = "";
564
+ let spelled = "";
565
+ let respelled = false;
534
566
  let previous: TSNode | undefined;
535
567
  for (const word of nodes) {
536
- if (previous) text += gapBetween(node, previous, word, redirects);
537
- words.push({
538
- ...reader.argWord(word),
539
- text: word.text,
540
- offset: text.length,
541
- });
568
+ if (previous) {
569
+ const gap = gapBetween(node, previous, word, redirects);
570
+ text += gap;
571
+ spelled += gap;
572
+ }
573
+ const argWord = scope.words.argWord(word);
574
+ words.push({ ...argWord, text: word.text, offset: text.length });
542
575
  text += word.text;
576
+ const spelling = argWord.computed
577
+ ? undefined
578
+ : scope.speller?.absoluteSpellingOf(word);
579
+ respelled ||= spelling !== undefined;
580
+ spelled += spelling ?? word.text;
543
581
  previous = word;
544
582
  }
545
- return { text, words };
583
+ return respelled
584
+ ? { text, words, argumentSpelling: spelled }
585
+ : { text, words };
586
+ }
587
+
588
+ /**
589
+ * The spellings that differ from `text`, each once, or `undefined` when none
590
+ * does — so a unit with nothing to respell keeps the shape it had.
591
+ */
592
+ function distinctSpellings(
593
+ text: string,
594
+ candidates: readonly (string | undefined)[],
595
+ ): readonly string[] | undefined {
596
+ const spellings = [
597
+ ...new Set(
598
+ candidates.filter(
599
+ (spelling): spelling is string =>
600
+ spelling !== undefined && spelling !== text,
601
+ ),
602
+ ),
603
+ ];
604
+ return spellings.length === 0 ? undefined : spellings;
546
605
  }
547
606
 
548
607
  /**
@@ -670,6 +729,7 @@ function collectHostedCommands(
670
729
  parseUnresolved: false,
671
730
  salvaged: scope.salvaged,
672
731
  words: scope.words,
732
+ speller: scope.speller,
673
733
  },
674
734
  out,
675
735
  );
@@ -10,7 +10,7 @@ import {
10
10
  collectSalvagedCommands,
11
11
  } from "./command-enumeration";
12
12
  import { WordReader } from "./node-text";
13
- import { getParser } from "./parser";
13
+ import { type BashReparser, getParser, getWarmBashParser } from "./parser";
14
14
  import { ShellVariables } from "./shell-variable-expansion";
15
15
  import { withSalvagedRoots } from "./unresolved-salvage";
16
16
 
@@ -58,7 +58,33 @@ export class BashProgram {
58
58
  normalizer: PathNormalizer,
59
59
  options?: { workdir?: string },
60
60
  ): Promise<BashProgram> {
61
- const parser = await getParser();
61
+ return BashProgram.build(await getParser(), command, normalizer, options);
62
+ }
63
+
64
+ /**
65
+ * Parse a bash command synchronously over the warmed parser, or answer `null`
66
+ * in the pre-warm window so the caller can fall back rather than block.
67
+ *
68
+ * Builds exactly the program {@link parse} builds; the service's advisory
69
+ * query uses it so its answer resolves the same slices the gate does (#309).
70
+ */
71
+ static parseSync(
72
+ command: string,
73
+ normalizer: PathNormalizer,
74
+ options?: { workdir?: string },
75
+ ): BashProgram | null {
76
+ const parser = getWarmBashParser();
77
+ if (!parser) return null;
78
+ return BashProgram.build(parser, command, normalizer, options);
79
+ }
80
+
81
+ /** Parse `command` with `parser` and resolve every slice synchronously. */
82
+ private static build(
83
+ parser: BashReparser,
84
+ command: string,
85
+ normalizer: PathNormalizer,
86
+ options?: { workdir?: string },
87
+ ): BashProgram {
62
88
  const tree = parser.parse(command);
63
89
  if (!tree) return new BashProgram(command, [], [], []);
64
90
 
@@ -67,15 +93,15 @@ export class BashProgram {
67
93
  const words = new WordReader(
68
94
  ShellVariables.scan([tree.rootNode, ...salvaged]),
69
95
  );
70
- const { externalAccesses, ruleCandidates } = new BashPathResolver(
71
- normalizer,
72
- words,
73
- options?.workdir,
74
- ).resolve(tree.rootNode, salvaged);
96
+ const { externalAccesses, ruleCandidates, argumentSpellings } =
97
+ new BashPathResolver(normalizer, words, options?.workdir).resolve(
98
+ tree.rootNode,
99
+ salvaged,
100
+ );
75
101
  return new BashProgram(
76
102
  command,
77
103
  [
78
- ...collectCommands(tree.rootNode, words),
104
+ ...collectCommands(tree.rootNode, words, argumentSpellings),
79
105
  ...salvaged.flatMap((root) => collectSalvagedCommands(root, words)),
80
106
  ],
81
107
  externalAccesses,
@@ -39,6 +39,19 @@ export interface PathToken {
39
39
  readonly token: string;
40
40
  readonly effect: TokenEffect;
41
41
  readonly role: TokenRole;
42
+ /**
43
+ * The source span of the argument node, set only when {@link token} is that
44
+ * whole node's resolved text. A token read from part of an argument (an
45
+ * option's embedded value, a script file glued to its flag) carries none, so
46
+ * nothing keyed on the span can mistake it for the whole word.
47
+ */
48
+ readonly span?: SourceSpan;
49
+ }
50
+
51
+ /** Where a node sits in the parsed source: `startIndex` to `endIndex`. */
52
+ export interface SourceSpan {
53
+ readonly start: number;
54
+ readonly end: number;
42
55
  }
43
56
 
44
57
  // ── Public surface ─────────────────────────────────────────────────────────
@@ -160,7 +173,7 @@ export function collectRedirectTokens(
160
173
  i === target && provesTarget(effect, child, token, words)
161
174
  ? "redirect-destination"
162
175
  : "operand";
163
- tokens.push({ token, effect, role });
176
+ tokens.push(wholeArgumentToken(child, token, effect, role));
164
177
  }
165
178
  }
166
179
  tokens.push(...collectHostedExecutionTokens(child, words));
@@ -271,11 +284,9 @@ function collectStatementOperandTokens(
271
284
  tokens.push(...collectPathCandidateTokens(child, words));
272
285
  continue;
273
286
  }
274
- tokens.push({
275
- token: words.text(child),
276
- effect: UNPROVEN_EFFECT,
277
- role: "operand",
278
- });
287
+ tokens.push(
288
+ wholeArgumentToken(child, words.text(child), UNPROVEN_EFFECT, "operand"),
289
+ );
279
290
  tokens.push(...collectHostedExecutionTokens(child, words));
280
291
  }
281
292
  return tokens;
@@ -833,7 +844,12 @@ function collectPatternCommandTokens(
833
844
  tokens.push(...collectPathCandidateTokens(child, words));
834
845
  continue;
835
846
  }
836
- const discharge = dischargePendingConsumption(consumption, text, effect);
847
+ const discharge = dischargePendingConsumption(
848
+ consumption,
849
+ child,
850
+ text,
851
+ effect,
852
+ );
837
853
  if (discharge.token) tokens.push(discharge.token);
838
854
  if (discharge.consumed) continue;
839
855
  }
@@ -907,7 +923,7 @@ function collectPatternCommandTokens(
907
923
  if (!hasExplicitScript && positionalsSeen < patternPositionals) {
908
924
  positionalsSeen++; // Skip: this is an inline pattern/script.
909
925
  } else {
910
- tokens.push({ token: text, effect, role: "operand" });
926
+ tokens.push(wholeArgumentToken(child, text, effect, "operand"));
911
927
  }
912
928
  // A quoted token that did not act as a flag above has its embedded value
913
929
  // split here instead.
@@ -957,6 +973,7 @@ interface ConsumptionDischarge {
957
973
  /** Apply a pending consumption to the argument text that follows its flag. */
958
974
  function dischargePendingConsumption(
959
975
  role: PatternFlagRole,
976
+ argument: TSNode,
960
977
  text: string,
961
978
  effect: TokenEffect,
962
979
  ): ConsumptionDischarge {
@@ -964,7 +981,7 @@ function dischargePendingConsumption(
964
981
  case "script-file":
965
982
  return {
966
983
  consumed: true,
967
- token: { token: text, effect, role: "operand" },
984
+ token: wholeArgumentToken(argument, text, effect, "operand"),
968
985
  };
969
986
  case "script":
970
987
  case "value":
@@ -1015,7 +1032,9 @@ function collectGenericCommandTokens(
1015
1032
 
1016
1033
  // Argument nodes: resolve their text and collect.
1017
1034
  if (ARG_NODE_TYPES.has(child.type)) {
1018
- tokens.push({ token: words.text(child), effect, role: "operand" });
1035
+ tokens.push(
1036
+ wholeArgumentToken(child, words.text(child), effect, "operand"),
1037
+ );
1019
1038
  continue;
1020
1039
  }
1021
1040
 
@@ -1025,3 +1044,21 @@ function collectGenericCommandTokens(
1025
1044
 
1026
1045
  return tokens;
1027
1046
  }
1047
+
1048
+ /**
1049
+ * A token that is `node`'s whole resolved text, carrying the node's source span
1050
+ * so a reader of the span knows the token stands for the entire word.
1051
+ */
1052
+ function wholeArgumentToken(
1053
+ node: TSNode,
1054
+ token: string,
1055
+ effect: TokenEffect,
1056
+ role: TokenRole,
1057
+ ): PathToken {
1058
+ return {
1059
+ token,
1060
+ effect,
1061
+ role,
1062
+ span: { start: node.startIndex, end: node.endIndex },
1063
+ };
1064
+ }
@@ -228,14 +228,16 @@ function floorUnparsedUnit(
228
228
  * it.
229
229
  *
230
230
  * Spreads `resolved`, so a `source: "session"` grant survives to `GateRunner`'s
231
- * session fast path.
231
+ * session fast path. Drops `matchedSpelling`: it names what the replaced rule
232
+ * matched, and beside the sentinel it would name a match that did not decide.
232
233
  */
233
234
  function floorToAsk(
234
235
  resolved: PermissionCheckResult,
235
236
  sentinel: string,
236
237
  ): PermissionCheckResult {
238
+ const { matchedSpelling: _replaced, ...unspelled } = resolved;
237
239
  return {
238
- ...resolved,
240
+ ...unspelled,
239
241
  state: "ask",
240
242
  matchedPattern: sentinel,
241
243
  floor: sentinel,
@@ -42,6 +42,7 @@ import { isSensitiveName, REDACTED_PLACEHOLDER } from "./log-redaction";
42
42
  export const COMMAND_BEARING_LOG_KEYS: ReadonlySet<string> = new Set([
43
43
  "command",
44
44
  "executedUnit",
45
+ "matchedSpelling",
45
46
  ]);
46
47
 
47
48
  /** A range of the command to replace, and what to put in its place. */
@@ -376,7 +376,7 @@ export class PermissionManager implements ScopedPermissionManager {
376
376
  intent.command,
377
377
  intent.spellings,
378
378
  );
379
- return buildCheckResult(
379
+ const { result, matchedValue } = evaluateCheck(
380
380
  surface,
381
381
  values,
382
382
  resultExtras,
@@ -385,6 +385,12 @@ export class PermissionManager implements ScopedPermissionManager {
385
385
  fullRules,
386
386
  this.flavor,
387
387
  );
388
+ // `values[0]` is the unit as typed, and the evaluator reports the first
389
+ // value the winning rule matches, so any other value is a spelling the
390
+ // typed text did not match.
391
+ return matchedValue === values[0]
392
+ ? result
393
+ : { ...result, matchedSpelling: matchedValue };
388
394
  }
389
395
 
390
396
  // kind === "tool"
@@ -423,6 +429,30 @@ function buildCheckResult(
423
429
  fullRules: Ruleset,
424
430
  flavor: PathFlavor,
425
431
  ): PermissionCheckResult {
432
+ return evaluateCheck(
433
+ surface,
434
+ values,
435
+ resultExtras,
436
+ normalizedToolName,
437
+ toolName,
438
+ fullRules,
439
+ flavor,
440
+ ).result;
441
+ }
442
+
443
+ /**
444
+ * {@link buildCheckResult}, plus the candidate value the decision was reported
445
+ * under — for a caller that knows what that value means on its own surface.
446
+ */
447
+ function evaluateCheck(
448
+ surface: string,
449
+ values: string[],
450
+ resultExtras: Record<string, unknown>,
451
+ normalizedToolName: string,
452
+ toolName: string,
453
+ fullRules: Ruleset,
454
+ flavor: PathFlavor,
455
+ ): { result: PermissionCheckResult; matchedValue: string } {
426
456
  const { rule, value } = evaluateAnyValue(surface, values, fullRules, flavor);
427
457
 
428
458
  // For MCP, replace the normalizer's fallback target with the actual
@@ -433,16 +463,19 @@ function buildCheckResult(
433
463
  : resultExtras;
434
464
 
435
465
  return {
436
- toolName,
437
- state: rule.action,
438
- reason: rule.reason,
439
- matchedPattern:
440
- rule.layer === "config" || rule.layer === "session"
441
- ? rule.pattern
442
- : undefined,
443
- source: deriveSource(rule, normalizedToolName),
444
- origin: rule.origin,
445
- ...extras,
466
+ result: {
467
+ toolName,
468
+ state: rule.action,
469
+ reason: rule.reason,
470
+ matchedPattern:
471
+ rule.layer === "config" || rule.layer === "session"
472
+ ? rule.pattern
473
+ : undefined,
474
+ source: deriveSource(rule, normalizedToolName),
475
+ origin: rule.origin,
476
+ ...extras,
477
+ },
478
+ matchedValue: value,
446
479
  };
447
480
  }
448
481
 
@@ -222,6 +222,9 @@ function coreFacts(payload: PromptPayload): Fact[] {
222
222
  if (request.matchedPattern !== null) {
223
223
  facts.push({ label: "rule", text: request.matchedPattern });
224
224
  }
225
+ if (request.matchedSpelling !== null) {
226
+ facts.push({ label: "matched as", text: request.matchedSpelling });
227
+ }
225
228
  if (request.value !== "" && request.value !== request.toolName) {
226
229
  facts.push({ label, text: request.value });
227
230
  }
@@ -56,6 +56,7 @@ function degradedForwardedPayload(
56
56
  value: request.value ?? "",
57
57
  matchedPattern: null,
58
58
  commandContext: null,
59
+ matchedSpelling: null,
59
60
  executedUnit: null,
60
61
  },
61
62
  // Nothing to carry: the wire no longer relays a sentence, and inventing
@@ -82,6 +82,7 @@ export function buildBashExternalDirectoryAskPayload(
82
82
  value: facts.command,
83
83
  matchedPattern: facts.matchedPattern ?? null,
84
84
  commandContext: null,
85
+ matchedSpelling: null,
85
86
  executedUnit: null,
86
87
  },
87
88
  evidence: [
@@ -113,6 +114,7 @@ function pathPayload(
113
114
  value: facts.pathValue,
114
115
  matchedPattern: facts.matchedPattern ?? null,
115
116
  commandContext: null,
117
+ matchedSpelling: null,
116
118
  executedUnit: null,
117
119
  },
118
120
  evidence,
@@ -63,6 +63,12 @@ export interface PromptRequestFacts {
63
63
  readonly value: string;
64
64
  /** The matched rule, including a sentinel such as `<indirection-bash-wrapper>`. */
65
65
  readonly matchedPattern: string | null;
66
+ /**
67
+ * For bash, the spelling of the unit the matched rule matched when it did not
68
+ * match {@link value} as typed — the absolute spelling of a relative path
69
+ * argument, say. `null` when the typed text decided.
70
+ */
71
+ readonly matchedSpelling: string | null;
66
72
  /**
67
73
  * Where the offending bash unit runs, when it came from a substitution or a
68
74
  * subshell. A fact rather than a rendered clause: it is what makes the
@@ -172,6 +178,9 @@ function asPromptRequestFacts(value: unknown): PromptRequestFacts | undefined {
172
178
  candidate.commandContext,
173
179
  BASH_COMMAND_CONTEXTS,
174
180
  );
181
+ // Absent from a request a node built before the fact existed, which states
182
+ // no spelling rather than a malformed one.
183
+ const matchedSpelling = candidate.matchedSpelling ?? null;
175
184
  if (
176
185
  !requester ||
177
186
  commandContext === undefined ||
@@ -180,6 +189,7 @@ function asPromptRequestFacts(value: unknown): PromptRequestFacts | undefined {
180
189
  !isNullableString(candidate.toolName) ||
181
190
  !isNullableString(candidate.invokedToolName) ||
182
191
  !isNullableString(candidate.matchedPattern) ||
192
+ !isNullableString(matchedSpelling) ||
183
193
  !isNullableString(candidate.executedUnit)
184
194
  ) {
185
195
  return undefined;
@@ -192,6 +202,7 @@ function asPromptRequestFacts(value: unknown): PromptRequestFacts | undefined {
192
202
  invokedToolName: candidate.invokedToolName,
193
203
  value: candidate.value,
194
204
  matchedPattern: candidate.matchedPattern,
205
+ matchedSpelling,
195
206
  commandContext: commandContext.value,
196
207
  executedUnit: candidate.executedUnit,
197
208
  };
@@ -25,6 +25,7 @@ export function renderReviewLogFacts(
25
25
  return {
26
26
  surface: request.surface,
27
27
  ...present("matchedPattern", request.matchedPattern),
28
+ ...present("matchedSpelling", request.matchedSpelling),
28
29
  ...present("executedUnit", request.executedUnit),
29
30
  ...present("commandContext", request.commandContext),
30
31
  ...present("invokedToolName", request.invokedToolName),
@@ -42,6 +42,7 @@ function skillPayload(
42
42
  value: skillName,
43
43
  matchedPattern: null,
44
44
  commandContext: null,
45
+ matchedSpelling: null,
45
46
  executedUnit: null,
46
47
  },
47
48
  evidence,
@@ -44,6 +44,7 @@ export function buildToolAskPayload(facts: ToolAskFacts): PromptPayload {
44
44
  invokedToolName: distinctInvokedName(facts),
45
45
  value: askValue(check, bash, mcp),
46
46
  matchedPattern: check.matchedPattern ?? null,
47
+ matchedSpelling: check.matchedSpelling ?? null,
47
48
  commandContext: check.commandContext ?? null,
48
49
  executedUnit: check.executedUnit ?? null,
49
50
  },
@@ -1,5 +1,6 @@
1
- import { parseBashCommandsSync } from "#src/access-intent/bash/sync-commands";
1
+ import { BashProgram } from "#src/access-intent/bash/program";
2
2
  import { resolveBashCommandCheck } from "#src/handlers/gates/bash-command";
3
+ import type { PathNormalizer } from "#src/path/path-normalizer";
3
4
  import type { ScopedPermissionResolver } from "#src/policy/permission-resolver";
4
5
  import type { PermissionCheckResult } from "#src/types";
5
6
 
@@ -14,7 +15,11 @@ import type { PermissionCheckResult } from "#src/types";
14
15
  * sentinels — `<unparseable-bash-command>` (#452) and `<unparsed-bash-subtree>`
15
16
  * (#840) — at parity with the gate.
16
17
  *
17
- * In the pre-warm window (`parseBashCommandsSync` returns `null`) it falls back
18
+ * The command is parsed by `BashProgram.parseSync` over the session's
19
+ * `normalizer`, the same builder the gate's program comes from, so every slice
20
+ * the gate resolves a unit with is resolved here too.
21
+ *
22
+ * In the pre-warm window (`parseSync` returns `null`) it falls back
18
23
  * to the pre-#309 whole-string match, so the advisory answer is never *weaker*
19
24
  * than before — only strengthened once warm.
20
25
  *
@@ -25,9 +30,10 @@ export function resolveBashAdvisoryCheck(
25
30
  command: string,
26
31
  agentName: string | undefined,
27
32
  resolver: ScopedPermissionResolver,
33
+ normalizer: PathNormalizer,
28
34
  ): PermissionCheckResult {
29
- const commands = parseBashCommandsSync(command);
30
- if (commands === null) {
35
+ const program = BashProgram.parseSync(command, normalizer);
36
+ if (program === null) {
31
37
  return resolver.resolve({
32
38
  kind: "tool",
33
39
  surface: "bash",
@@ -35,5 +41,10 @@ export function resolveBashAdvisoryCheck(
35
41
  agentName,
36
42
  });
37
43
  }
38
- return resolveBashCommandCheck(command, commands, agentName, resolver);
44
+ return resolveBashCommandCheck(
45
+ command,
46
+ program.commands(),
47
+ agentName,
48
+ resolver,
49
+ );
39
50
  }
@@ -67,7 +67,12 @@ export class LocalPermissionsService implements PermissionsService {
67
67
  // the enforcement gate enforces (#309). A cold parser falls back to the
68
68
  // whole-string match inside resolveBashAdvisoryCheck.
69
69
  if (surface === "bash") {
70
- return resolveBashAdvisoryCheck(value ?? "", agentName, this.resolver);
70
+ return resolveBashAdvisoryCheck(
71
+ value ?? "",
72
+ agentName,
73
+ this.resolver,
74
+ this.session.getPathNormalizer(),
75
+ );
71
76
  }
72
77
  const intent = buildAccessIntentForSurface(
73
78
  surface,
package/src/types.ts CHANGED
@@ -83,6 +83,13 @@ export interface PermissionCheckResult {
83
83
  * {@link floorExemption} says the wrapper's floor has no reason to hold.
84
84
  */
85
85
  executedUnit?: string;
86
+ /**
87
+ * The spelling of the bash unit the winning rule matched, when the rule did
88
+ * not also match the unit as typed — the absolute spelling of a relative
89
+ * path argument, say (#910). Display-only: absent whenever the typed text
90
+ * decided, and dropped by a floor that replaces {@link matchedPattern}.
91
+ */
92
+ matchedSpelling?: string;
86
93
  /**
87
94
  * Set when the winning bash unit is a wrapper the floor no longer covers,
88
95
  * naming why (#803, #963). Recorded in the review log so an allow the floor would
@@ -1,43 +0,0 @@
1
- import {
2
- type BashCommand,
3
- collectCommands,
4
- collectSalvagedCommands,
5
- } from "./command-enumeration";
6
- import { WordReader } from "./node-text";
7
- import { getWarmBashParser } from "./parser";
8
- import { ShellVariables } from "./shell-variable-expansion";
9
- import { withSalvagedRoots } from "./unresolved-salvage";
10
-
11
- /**
12
- * Synchronously enumerate the command-pattern units of a bash command using the
13
- * warmed tree-sitter parser.
14
- *
15
- * Returns `null` when the parser has not been warmed yet (the pre-warm window),
16
- * so the caller can fall back to whole-string matching rather than block. Once
17
- * warm it mirrors the enumeration the gate performs (`BashProgram.commands()`):
18
- * chains split, nested substitutions/subshells descend, opaque wrappers flagged
19
- * (#306). Only the command-pattern surface is produced — no path slices, so no
20
- * `PathNormalizer` is needed.
21
- *
22
- * An unparseable command yields an empty array (the caller's decompose path
23
- * fails it closed via `resolveBashCommandCheck`, #452).
24
- */
25
- export function parseBashCommandsSync(command: string): BashCommand[] | null {
26
- const parser = getWarmBashParser();
27
- if (!parser) return null;
28
- const tree = parser.parse(command);
29
- if (!tree) return [];
30
- try {
31
- return withSalvagedRoots(tree.rootNode, parser, (salvaged) => {
32
- const words = new WordReader(
33
- ShellVariables.scan([tree.rootNode, ...salvaged]),
34
- );
35
- return [
36
- ...collectCommands(tree.rootNode, words),
37
- ...salvaged.flatMap((root) => collectSalvagedCommands(root, words)),
38
- ];
39
- });
40
- } finally {
41
- tree.delete();
42
- }
43
- }