@gotgenes/pi-permission-system 39.1.1 → 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,34 @@ 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
+
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)
26
+
27
+
28
+ ### Bug Fixes
29
+
30
+ * **pi-permission-system:** a subagent's forwarded bash line is judged command by command on the parent ([0990e92](https://github.com/gotgenes/pi-packages/commit/0990e921868d077868b007682ffbaacc3a00cbc8)), closes [#1030](https://github.com/gotgenes/pi-packages/issues/1030)
31
+
32
+ ### Documentation
33
+
34
+ * **pi-permission-system:** a forwarded bash line carries every asking command ([5cb6696](https://github.com/gotgenes/pi-packages/commit/5cb6696feb8572247dd6d09d4eb980292c0510f1))
35
+
8
36
  ## [39.1.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v39.1.0...pi-permission-system-v39.1.1) (2026-10-06)
9
37
 
10
38
 
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
@@ -76,6 +83,21 @@ interface PermissionCheckResult {
76
83
  * node to clamp its own `allow` with.
77
84
  */
78
85
  floor?: string;
86
+ /**
87
+ * Every unit of the bash chain that resolved to `ask` and was not
88
+ * session-granted, in chain order, each with its own floor. Set only on an
89
+ * asking chain winner: the serving node of a forwarded ask judges each one,
90
+ * since the winner alone would approve commands it never saw.
91
+ */
92
+ askingUnits?: readonly AskingBashUnit[];
93
+ }
94
+ /**
95
+ * One unit of a bash chain the gate left asking, as the serving node of a
96
+ * forwarded ask needs it: the unit as typed, and the floor that raised it.
97
+ */
98
+ interface AskingBashUnit {
99
+ command: string;
100
+ floor?: string;
79
101
  }
80
102
 
81
103
  /**
@@ -130,6 +152,12 @@ interface PromptRequestFacts {
130
152
  readonly value: string;
131
153
  /** The matched rule, including a sentinel such as `<indirection-bash-wrapper>`. */
132
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;
133
161
  /**
134
162
  * Where the offending bash unit runs, when it came from a substitution or a
135
163
  * subshell. A fact rather than a rendered clause: it is what makes the
@@ -385,6 +413,14 @@ interface ForwardedAccessFacts {
385
413
  * than recomputing it. Absent when no floor was raised.
386
414
  */
387
415
  floor?: string;
416
+ /**
417
+ * Every unit of a bash chain the child left asking, each with its own floor.
418
+ * `matchValues` names only the chain's winner, and the serving node's answer
419
+ * approves the whole tool call, so it judges each of these instead. Absent
420
+ * for a non-bash ask and for a bash ask raised without a chain (the empty
421
+ * parse), where `matchValues` is the whole command.
422
+ */
423
+ askingUnits?: AskingBashUnit[];
388
424
  }
389
425
 
390
426
  type PermissionReviewSource = "tool_call" | "skill_input" | "skill_read";
@@ -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.
@@ -541,6 +576,7 @@ Approving one for the session works normally: the floors clamp the decision and
541
576
  A subagent's floored ask keeps its floor when it is forwarded to the parent session.
542
577
  The parent's `allow` rule does not answer it, so `sudo rm x` from a subagent prompts in the parent even under a parent `bash: *` allow.
543
578
  The parent's `deny` still denies, and a grant for the whole parent session or the parent's `yoloMode` still approves.
579
+ A subagent's chained command (`ls && rm -rf /tmp/x`) is judged by the parent command by command: every command the subagent's own policy left asking is forwarded with its floor, and the parent's `deny` or `ask` on any one of them decides, not only on the first.
544
580
 
545
581
  Because of this, set an explicit `bash` policy rather than relying on a permissive top-level `*`.
546
582
  A config whose top-level `*` is `"allow"` with no `bash` `*` policy lets every bash command silently inherit `allow`; the extension emits a startup warning in that case.
@@ -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
 
@@ -148,6 +148,7 @@ When `@gotgenes/pi-permission-system` is not installed, an implementation emits
148
148
  When a delegated or routed subagent cannot decide an `ask` where it runs — because it has no UI of its own, or because it names a parent session that is answering for it — the confirmation request is forwarded through Pi session directories instead.
149
149
  The main interactive session polls for forwarded requests, shows the confirmation prompt, writes the response, and the subagent resumes once that decision is available.
150
150
  A parent `allow`/`deny` rule governs a child's escalation directly (the serving node resolves it as recorded authority before prompting), and a "whole session" grant recorded on the parent auto-approves later forwards of the same pattern.
151
+ For a chained bash command, the parent judges every command the child left asking, not only the first, and the most restrictive answer decides.
151
152
  The exception is a bash ask the child raised through a [synthetic floor](configuration.md#fail-closed-behavior) (a wrapper such as `sudo` or `bash -c`, or a command the parser could not resolve): the child forwards the floor with the ask, and a parent `allow` rule escalates it to the parent's prompt instead of approving it.
152
153
 
153
154
  This keeps `ask` policies usable even when the original permission check happens inside a non-UI execution context.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "39.1.1",
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,