@gotgenes/pi-permission-system 9.0.1 → 9.2.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.
@@ -1,5 +1,5 @@
1
1
  import { createRequire } from "node:module";
2
- import { basename, resolve } from "node:path";
2
+ import { basename, isAbsolute, join, resolve } from "node:path";
3
3
 
4
4
  import {
5
5
  classifyTokenAsPathCandidate,
@@ -10,6 +10,7 @@ import {
10
10
  isSafeSystemPath,
11
11
  normalizePathForComparison,
12
12
  } from "#src/path-utils";
13
+ import type { BashCommandContext } from "#src/types";
13
14
 
14
15
  // ── tree-sitter-bash lazy parser ───────────────────────────────────────────
15
16
 
@@ -21,6 +22,8 @@ interface TSNode {
21
22
  readonly type: string;
22
23
  readonly text: string;
23
24
  readonly childCount: number;
25
+ /** False for anonymous tokens (operators, delimiters); true for named nodes. */
26
+ readonly isNamed: boolean;
24
27
  child(index: number): TSNode | null;
25
28
  }
26
29
 
@@ -55,6 +58,46 @@ function getParser(): Promise<TSParser> {
55
58
 
56
59
  // ── Parsed bash command representation ───────────────────────────────────────
57
60
 
61
+ /**
62
+ * One command-pattern unit of a parsed bash program.
63
+ *
64
+ * Minimal by design — `text` is the simple-command (or whole compound
65
+ * statement) string matched against the bash rules. The type is the stable
66
+ * extension point: #306 adds an execution `context`, #307 adds per-command
67
+ * path candidates and an effective working directory.
68
+ */
69
+ export interface BashCommand {
70
+ readonly text: string;
71
+ /**
72
+ * Execution context for a nested command (substitution or subshell); absent
73
+ * for a current-shell (top-level) command.
74
+ */
75
+ readonly context?: BashCommandContext;
76
+ }
77
+
78
+ /**
79
+ * The working directory in force where a path candidate appears.
80
+ *
81
+ * A `known` base carries an `offset` to be joined with `cwd` at resolution time
82
+ * (the parse-time walk never sees `cwd`): a relative-or-absolute path string
83
+ * built by folding the literal targets of current-shell `cd` commands (`""` =
84
+ * `cwd`); an absolute offset (from `cd /abs`) ignores `cwd` at resolution time.
85
+ * An `unknown` base marks a non-literal `cd` target (`cd "$DIR"`, `cd $(…)`,
86
+ * `cd -`, bare `cd`, `cd ~…`) that made the effective directory unresolvable.
87
+ */
88
+ type EffectiveBase =
89
+ | { readonly kind: "known"; readonly offset: string }
90
+ | { readonly kind: "unknown" };
91
+
92
+ /**
93
+ * A path-candidate token paired with the effective working directory projected
94
+ * onto the point in the command stream where it appears.
95
+ */
96
+ interface PathCandidate {
97
+ readonly token: string;
98
+ readonly base: EffectiveBase;
99
+ }
100
+
58
101
  /**
59
102
  * A bash command parsed once into a reusable representation.
60
103
  *
@@ -67,29 +110,29 @@ function getParser(): Promise<TSParser> {
67
110
  */
68
111
  export class BashProgram {
69
112
  private constructor(
70
- private readonly rawTokens: readonly string[],
71
- private readonly leadingCdTarget: string | undefined,
72
- private readonly topLevelCommandTexts: readonly string[],
113
+ private readonly rawCandidates: readonly PathCandidate[],
114
+ private readonly commandUnits: readonly BashCommand[],
73
115
  ) {}
74
116
 
75
117
  /**
76
118
  * Parse a bash command into a `BashProgram`.
77
119
  *
78
- * Uses tree-sitter-bash to build the full AST, walks command-argument and
79
- * redirect-destination nodes once into raw candidate tokens, and records the
80
- * leading `cd` target. Heredoc bodies, comments, and other non-argument
81
- * content are skipped. An unparseable command yields an empty program.
120
+ * Uses tree-sitter-bash to build the full AST and walks command-argument and
121
+ * redirect-destination nodes once into raw candidate tokens, each tagged with
122
+ * the effective working directory projected onto its position by folding
123
+ * current-shell `cd` commands. Heredoc bodies, comments, and other
124
+ * non-argument content are skipped. An unparseable command yields an empty
125
+ * program.
82
126
  */
83
127
  static async parse(command: string): Promise<BashProgram> {
84
128
  const parser = await getParser();
85
129
  const tree = parser.parse(command);
86
- if (!tree) return new BashProgram([], undefined, []);
130
+ if (!tree) return new BashProgram([], []);
87
131
 
88
132
  try {
89
- const leadingCdTarget = extractLeadingCdTarget(tree.rootNode);
90
- const rawTokens = collectPathCandidateTokens(tree.rootNode);
91
- const topLevelCommandTexts = collectTopLevelCommandTexts(tree.rootNode);
92
- return new BashProgram(rawTokens, leadingCdTarget, topLevelCommandTexts);
133
+ const rawCandidates = collectPathCandidates(tree.rootNode);
134
+ const commandUnits = collectCommands(tree.rootNode);
135
+ return new BashProgram(rawCandidates, commandUnits);
93
136
  } finally {
94
137
  tree.delete();
95
138
  }
@@ -102,14 +145,10 @@ export class BashProgram {
102
145
  * paths; does NOT filter by CWD. Returns deduplicated tokens for rule
103
146
  * evaluation.
104
147
  */
105
- // Used by the facades (bash-path-extractor.ts) and tests. Fallow's syntactic
106
- // analysis cannot resolve the static-factory return type (private ctor), so
107
- // it reports a false positive here.
108
- // fallow-ignore-next-line unused-class-member
109
148
  pathTokens(): string[] {
110
149
  const seen = new Set<string>();
111
150
  const result: string[] = [];
112
- for (const token of this.rawTokens) {
151
+ for (const { token } of this.rawCandidates) {
113
152
  const candidate = classifyTokenAsRuleCandidate(token);
114
153
  if (!candidate) continue;
115
154
  if (!seen.has(candidate)) {
@@ -121,17 +160,7 @@ export class BashProgram {
121
160
  }
122
161
 
123
162
  /**
124
- * Deduplicated paths that resolve outside `cwd`.
125
- *
126
- * When the command begins with `cd <dir> && …`, relative candidate paths are
127
- * resolved against `<dir>` (if it stays within CWD) rather than CWD itself,
128
- * mirroring how the shell would resolve them.
129
- */
130
- // Used by the facades (bash-path-extractor.ts) and tests. Fallow's syntactic
131
- // analysis cannot resolve the static-factory return type (private ctor), so
132
- // it reports a false positive here.
133
- /**
134
- * The top-level simple-commands of the chain, in source order.
163
+ * The top-level command-pattern units of the chain, in source order.
135
164
  *
136
165
  * Splits on the shell chain operators (`&&`, `||`, `;`, `|`, `&`, newlines);
137
166
  * quotes, command substitution, and subshells are respected by the parser and
@@ -143,22 +172,49 @@ export class BashProgram {
143
172
  // syntactic analysis cannot resolve the static-factory return type (private
144
173
  // ctor), so it reports a false positive here.
145
174
  // fallow-ignore-next-line unused-class-member
146
- topLevelCommands(): string[] {
147
- return [...this.topLevelCommandTexts];
175
+ commands(): BashCommand[] {
176
+ return [...this.commandUnits];
148
177
  }
149
178
 
150
- // fallow-ignore-next-line unused-class-member
179
+ /**
180
+ * Deduplicated paths that resolve outside `cwd`.
181
+ *
182
+ * Each candidate is resolved against the effective working directory in force
183
+ * where it appears, projected by folding a sequence of current-shell `cd`
184
+ * commands (joined by `&&`, `||`, `;`, or a newline). A `cd` inside a
185
+ * pipeline or a backgrounded command runs in a subshell and does not update
186
+ * the running directory.
187
+ */
151
188
  externalPaths(cwd: string): string[] {
152
- const resolveBase = computeEffectiveResolveBase(this.leadingCdTarget, cwd);
153
189
  const normalizedCwd = normalizePathForComparison(cwd, cwd);
154
190
 
155
191
  const seen = new Set<string>();
156
192
  const externalPaths: string[] = [];
157
193
 
158
- for (const token of this.rawTokens) {
194
+ for (const { token, base } of this.rawCandidates) {
159
195
  const candidate = classifyTokenAsPathCandidate(token);
160
196
  if (!candidate) continue;
161
197
 
198
+ // Unknown effective directory: a relative candidate could resolve
199
+ // anywhere, so flag it conservatively (resolving against `cwd` only for a
200
+ // display path). Absolute / `~` candidates are base-independent and
201
+ // resolve normally below.
202
+ if (base.kind === "unknown" && isRelativeCandidate(candidate)) {
203
+ const normalized = normalizePathForComparison(candidate, cwd);
204
+ if (
205
+ normalized &&
206
+ normalizedCwd !== "" &&
207
+ !isSafeSystemPath(normalized) &&
208
+ !seen.has(normalized)
209
+ ) {
210
+ seen.add(normalized);
211
+ externalPaths.push(normalized);
212
+ }
213
+ continue;
214
+ }
215
+
216
+ const resolveBase =
217
+ base.kind === "known" ? resolve(cwd, base.offset) : cwd;
162
218
  const normalized = normalizePathForComparison(candidate, resolveBase);
163
219
  if (!normalized) continue;
164
220
 
@@ -592,14 +648,12 @@ function collectPathCandidateTokens(node: TSNode): string[] {
592
648
  // which exports classifyTokenAsPathCandidate and classifyTokenAsRuleCandidate
593
649
  // with a shared rejectNonPathToken predicate eliminating the prior clone.
594
650
 
595
- // ── Top-level command enumeration ───────────────────────────────────────────
651
+ // ── Command enumeration ──────────────────────────────────────────────────────
596
652
 
597
653
  /**
598
- * Container node types descended into when enumerating top-level commands.
599
- * A `cd` or `rm` inside a subshell or compound statement is NOT a top-level
600
- * command, so those node types are deliberately absent.
654
+ * Container node types descended into when enumerating command units.
601
655
  */
602
- const TOP_LEVEL_COMMAND_DESCEND = new Set([
656
+ const COMMAND_ENUM_DESCEND = new Set([
603
657
  "program",
604
658
  "list",
605
659
  "pipeline",
@@ -607,18 +661,12 @@ const TOP_LEVEL_COMMAND_DESCEND = new Set([
607
661
  ]);
608
662
 
609
663
  /**
610
- * Node types skipped during top-level command enumeration: chain-operator and
611
- * separator tokens, redirect targets, comments, and heredoc bodies. None of
612
- * these is a command to evaluate.
664
+ * Named node types skipped during command enumeration: redirect targets,
665
+ * comments, and heredoc bodies — none is a command to evaluate. Anonymous
666
+ * tokens (chain operators `&&`/`;`/`|`, substitution and subshell delimiters
667
+ * `$(`/`)`/`` ` ``/`(`) are filtered by the `isNamed` guard, not listed here.
613
668
  */
614
- const TOP_LEVEL_COMMAND_SKIP = new Set([
615
- "&&",
616
- "||",
617
- ";",
618
- "&",
619
- "|",
620
- "|&",
621
- "\n",
669
+ const COMMAND_ENUM_SKIP = new Set([
622
670
  "file_redirect",
623
671
  "heredoc_redirect",
624
672
  "herestring_redirect",
@@ -628,100 +676,300 @@ const TOP_LEVEL_COMMAND_SKIP = new Set([
628
676
  ]);
629
677
 
630
678
  /**
631
- * Collect the text of each top-level simple-command in the program.
679
+ * Nested execution contexts whose interior commands really execute and must be
680
+ * evaluated too: command substitution (`$(…)`, backticks) and process
681
+ * substitution (`<(…)`/`>(…)`). Subshells (`( … )`) are handled separately
682
+ * because they are also emitted whole.
683
+ */
684
+ const NESTED_EXECUTION_CONTEXTS = new Map<string, BashCommandContext>([
685
+ ["command_substitution", "command_substitution"],
686
+ ["process_substitution", "process_substitution"],
687
+ ]);
688
+
689
+ /**
690
+ * Enumerate the command units of a bash program, in source order.
632
691
  *
633
692
  * Descends container nodes (`program`, `list`, `pipeline`, `redirected_statement`)
634
- * and emits each `command` node's text. Chain-operator tokens and redirect
635
- * targets are skipped. Any other top-level statement node (subshell, compound
636
- * statement, control-flow) is emitted whole without descending, so its inner
637
- * commands are matched as part of the enclosing statement's text rather than
638
- * independently (the top-level scope).
693
+ * and emits each `command` node whole. Additionally descends into the three
694
+ * nested execution contexts command substitution (`$(…)`, backticks), process
695
+ * substitution (`<(…)`/`>(…)`), and subshells (`( )`) emitting each inner
696
+ * command as its own unit *in addition to* the enclosing command, since those
697
+ * inner commands really execute (#306). Control-flow bodies and `{ … }` brace
698
+ * groups are emitted whole without descending (deferred).
699
+ *
700
+ * The enclosing command/subshell is always still emitted whole, so adding the
701
+ * nested units can only ever produce a more-restrictive decision, never weaker.
639
702
  */
640
- function collectTopLevelCommandTexts(node: TSNode): string[] {
641
- if (node.type === "command") return [node.text];
642
- if (TOP_LEVEL_COMMAND_SKIP.has(node.type)) return [];
643
- if (TOP_LEVEL_COMMAND_DESCEND.has(node.type)) {
644
- const texts: string[] = [];
645
- for (let i = 0; i < node.childCount; i++) {
646
- const child = node.child(i);
647
- if (child) texts.push(...collectTopLevelCommandTexts(child));
703
+ function collectCommands(node: TSNode): BashCommand[] {
704
+ const out: BashCommand[] = [];
705
+ collectCommandsInto(node, undefined, out);
706
+ return out;
707
+ }
708
+
709
+ function collectCommandsInto(
710
+ node: TSNode,
711
+ context: BashCommandContext | undefined,
712
+ out: BashCommand[],
713
+ ): void {
714
+ // Anonymous tokens (operators `&&`/`;`/`|`, delimiters `$(`/`)`/`` ` ``/`(`)
715
+ // carry no command.
716
+ if (!node.isNamed) return;
717
+ if (COMMAND_ENUM_SKIP.has(node.type)) return;
718
+
719
+ if (node.type === "command") {
720
+ out.push(makeUnit(node.text, context));
721
+ // A command's text already contains any substitution; descend its subtree
722
+ // to ALSO emit the inner commands of command/process substitutions.
723
+ collectSubstitutionCommands(node, out);
724
+ return;
725
+ }
726
+
727
+ if (node.type === "subshell") {
728
+ out.push(makeUnit(node.text, context)); // never-weaker whole emit
729
+ descendCommandChildren(node, "subshell", out);
730
+ return;
731
+ }
732
+
733
+ if (COMMAND_ENUM_DESCEND.has(node.type)) {
734
+ descendCommandChildren(node, context, out);
735
+ return;
736
+ }
737
+
738
+ // Any other named statement (compound_statement `{ … }`, if/while/for/case,
739
+ // function_definition): emit whole, do not descend — deferred (#306).
740
+ out.push(makeUnit(node.text, context));
741
+ }
742
+
743
+ function makeUnit(
744
+ text: string,
745
+ context: BashCommandContext | undefined,
746
+ ): BashCommand {
747
+ return context ? { text, context } : { text };
748
+ }
749
+
750
+ function descendCommandChildren(
751
+ node: TSNode,
752
+ context: BashCommandContext | undefined,
753
+ out: BashCommand[],
754
+ ): void {
755
+ for (let i = 0; i < node.childCount; i++) {
756
+ const child = node.child(i);
757
+ if (child) collectCommandsInto(child, context, out);
758
+ }
759
+ }
760
+
761
+ /**
762
+ * Search a command's subtree for command/process substitutions and enumerate
763
+ * the commands inside them, tagged with the substitution's execution context.
764
+ * A substitution can nest under `command_name` (when the whole command is
765
+ * `$(…)`) or under an argument, so the entire subtree is searched.
766
+ */
767
+ function collectSubstitutionCommands(node: TSNode, out: BashCommand[]): void {
768
+ for (let i = 0; i < node.childCount; i++) {
769
+ const child = node.child(i);
770
+ if (!child) continue;
771
+ const nestedContext = NESTED_EXECUTION_CONTEXTS.get(child.type);
772
+ if (nestedContext) {
773
+ descendCommandChildren(child, nestedContext, out);
774
+ } else {
775
+ collectSubstitutionCommands(child, out);
648
776
  }
649
- return texts;
650
777
  }
651
- // Any other named statement node (subshell, compound_statement, if/while/for,
652
- // function_definition, …): emit whole, do not descend.
653
- return [node.text];
654
778
  }
655
779
 
656
- // ── Leading cd detection ───────────────────────────────────────────────────
780
+ // ── Effective working directory projection ─────────────────────────────────
781
+
782
+ /** The working directory in force at the start of a program (`cwd`). */
783
+ const CWD_BASE: EffectiveBase = { kind: "known", offset: "" };
784
+
785
+ /** The effective directory after a non-literal or unresolvable `cd`. */
786
+ const UNKNOWN_BASE: EffectiveBase = { kind: "unknown" };
657
787
 
658
788
  /**
659
- * Walk down from the root to find the first `command` node in the program.
789
+ * Walk the AST once, collecting every path-candidate token tagged with the
790
+ * effective working directory projected onto its position.
660
791
  *
661
- * Only descends into `program` and `list` nodes subshells, pipelines, and
662
- * other compound statements are ignored because a `cd` inside them does not
663
- * affect the outer shell's working directory.
792
+ * The effective directory is stateful: it starts at `cwd` and each current-shell
793
+ * `cd <literal>` (joined by `&&`, `||`, `;`, or a newline) folds into it for
794
+ * subsequent commands. A `cd` inside a pipeline or a backgrounded command runs
795
+ * in a subshell and does not update the running directory; subshell and
796
+ * brace-group interiors inherit the enclosing base without folding their own
797
+ * `cd`s (a conservative first tier).
798
+ */
799
+ function collectPathCandidates(rootNode: TSNode): PathCandidate[] {
800
+ const out: PathCandidate[] = [];
801
+ walkForCandidates(rootNode, CWD_BASE, out);
802
+ return out;
803
+ }
804
+
805
+ /**
806
+ * Collect a single node's candidates tagged with `base`, returning the
807
+ * effective base in force *after* the node (the input base unless the node is a
808
+ * current-shell `cd <literal>` that folds the running directory).
664
809
  */
665
- function findFirstCommand(node: TSNode): TSNode | null {
666
- if (node.type === "command") return node;
667
- if (node.type === "program" || node.type === "list") {
668
- const firstChild = node.child(0);
669
- if (firstChild) return findFirstCommand(firstChild);
810
+ function walkForCandidates(
811
+ node: TSNode,
812
+ base: EffectiveBase,
813
+ out: PathCandidate[],
814
+ ): EffectiveBase {
815
+ switch (node.type) {
816
+ case "program":
817
+ case "list":
818
+ case "redirected_statement":
819
+ return walkCurrentShellSequence(node, base, out);
820
+ case "command":
821
+ tagTokens(collectCommandTokens(node), base, out);
822
+ return foldCd(node, base);
823
+ case "subshell":
824
+ // A subshell runs in a child shell: its interior `cd`s fold within the
825
+ // subshell but reset on exit, so the folded base is discarded.
826
+ walkCurrentShellSequence(node, base, out);
827
+ return base;
828
+ case "compound_statement":
829
+ // A `{ … }` brace group runs in the current shell, so its `cd`s persist
830
+ // to following commands — thread and return the folded base.
831
+ return walkCurrentShellSequence(node, base, out);
832
+ default:
833
+ // Pipelines, control-flow bodies, redirect targets, and command/process
834
+ // substitution interiors: collect every candidate in the subtree tagged
835
+ // with the enclosing base and do not fold their internal `cd`s. (Folding
836
+ // inside substitutions is deferred — conservative, never under-flags.)
837
+ tagTokens(collectPathCandidateTokens(node), base, out);
838
+ return base;
670
839
  }
671
- return null;
672
840
  }
673
841
 
674
842
  /**
675
- * Extract the target directory of a leading `cd` command from the parsed AST.
676
- *
677
- * When a bash command begins with `cd <dir> && …`, the shell resolves
678
- * subsequent relative paths against `<dir>`, not the original working
679
- * directory. The external-directory guard must do the same, otherwise a
680
- * path that the shell keeps inside the working directory can appear to
681
- * escape it and trigger a spurious permission prompt.
682
- *
683
- * Returns `undefined` when the first command is not `cd`, or when the
684
- * target cannot be meaningfully resolved (`cd -`, bare `cd`, or `cd ~…`).
843
+ * Fold a current-shell sequence (`program` / `list` / `redirected_statement`):
844
+ * thread the effective base left-to-right through the children so a `cd` updates
845
+ * the base for following siblings. A statement immediately followed by the
846
+ * background operator (`&`) runs in a subshell, so its folded base is discarded.
685
847
  */
686
- function extractLeadingCdTarget(rootNode: TSNode): string | undefined {
687
- const firstCmd = findFirstCommand(rootNode);
688
- if (!firstCmd) return undefined;
848
+ function walkCurrentShellSequence(
849
+ seqNode: TSNode,
850
+ base: EffectiveBase,
851
+ out: PathCandidate[],
852
+ ): EffectiveBase {
853
+ let current = base;
854
+ for (let i = 0; i < seqNode.childCount; i++) {
855
+ const child = seqNode.child(i);
856
+ if (!child?.isNamed) continue;
857
+ if (SKIP_SUBTREE_TYPES.has(child.type)) continue;
858
+ const after = walkForCandidates(child, current, out);
859
+ current = isBackgrounded(seqNode, i) ? current : after;
860
+ }
861
+ return current;
862
+ }
689
863
 
690
- const cmdName = extractCommandName(firstCmd);
691
- if (cmdName !== "cd") return undefined;
864
+ /**
865
+ * True when the statement at `index` is immediately followed by the background
866
+ * operator (`&`) — distinct from the `&&` / `||` / `;` current-shell separators.
867
+ */
868
+ function isBackgrounded(seqNode: TSNode, index: number): boolean {
869
+ const next = seqNode.child(index + 1);
870
+ if (!next || next.isNamed) return false;
871
+ return next.type === "&";
872
+ }
873
+
874
+ function tagTokens(
875
+ tokens: readonly string[],
876
+ base: EffectiveBase,
877
+ out: PathCandidate[],
878
+ ): void {
879
+ for (const token of tokens) out.push({ token, base });
880
+ }
881
+
882
+ /**
883
+ * True when a path candidate is relative (resolved against the effective
884
+ * directory) rather than absolute (`/…`) or home-relative (`~…`), which are
885
+ * base-independent. Used to decide which candidates an unknown base affects.
886
+ */
887
+ function isRelativeCandidate(candidate: string): boolean {
888
+ return !candidate.startsWith("/") && !candidate.startsWith("~");
889
+ }
692
890
 
693
- for (let i = 0; i < firstCmd.childCount; i++) {
694
- const child = firstCmd.child(i);
891
+ /**
892
+ * Compute the effective base after a command runs. Returns `base` unchanged
893
+ * unless the command is `cd`:
894
+ *
895
+ * - `cd /abs` (absolute literal) → a fresh known base, recovering from an
896
+ * earlier unknown base.
897
+ * - `cd rel` (relative literal) → fold into a known base, or stay unknown if the
898
+ * base was already unknown.
899
+ * - `cd "$DIR"` / `cd $(…)` / `cd -` / bare `cd` / `cd ~…` (non-literal) →
900
+ * unknown.
901
+ */
902
+ function foldCd(commandNode: TSNode, base: EffectiveBase): EffectiveBase {
903
+ if (extractCommandName(commandNode) !== "cd") return base;
904
+ const target = cdLiteralTarget(commandNode);
905
+ if (target === null) return UNKNOWN_BASE;
906
+ if (isAbsolute(target)) return { kind: "known", offset: target };
907
+ if (base.kind === "unknown") return UNKNOWN_BASE;
908
+ return { kind: "known", offset: join(base.offset, target) };
909
+ }
910
+
911
+ /**
912
+ * Resolve the literal target of a `cd` command, or `null` when the first
913
+ * argument is not a static literal (contains an expansion or command
914
+ * substitution) or cannot be resolved against the working directory (`cd -`,
915
+ * `cd ~…`, bare `cd`).
916
+ */
917
+ function cdLiteralTarget(commandNode: TSNode): string | null {
918
+ for (let i = 0; i < commandNode.childCount; i++) {
919
+ const child = commandNode.child(i);
695
920
  if (!child) continue;
696
921
  if (child.type === "command_name" || child.type === "variable_assignment")
697
922
  continue;
698
- if (!ARG_NODE_TYPES.has(child.type)) continue;
699
-
700
- const text = resolveNodeText(child);
701
- // Skip `--` (end-of-flags marker)
702
- if (text === "--") continue;
703
- // `cd -` jumps to $OLDPWD; `cd ~…` is home-relative — neither can be
704
- // resolved against the working directory.
705
- if (text === "-" || text.startsWith("~")) return undefined;
706
- return text;
923
+ if (!child.isNamed) continue;
924
+ // Skip the `--` end-of-flags marker; the next argument is the target.
925
+ if (child.type === "word" && child.text === "--") continue;
926
+ if (!ARG_NODE_TYPES.has(child.type)) return null;
927
+ return literalTextOf(child);
707
928
  }
708
- return undefined;
929
+ return null;
709
930
  }
710
931
 
711
932
  /**
712
- * Compute the effective base directory for resolving relative path candidates.
713
- *
714
- * When the leading `cd` target stays within the working directory, subsequent
715
- * relative paths should be resolved against it. An escaping target is itself
716
- * an external access (reported via its own candidate token) and must never
717
- * silence checks on subsequent paths, so the function falls back to `cwd`.
933
+ * The literal string value of an argument node, or `null` when it contains a
934
+ * variable expansion / command substitution or is a non-resolvable `cd`
935
+ * destination (`-`, `~…`).
718
936
  */
719
- function computeEffectiveResolveBase(
720
- cdTarget: string | undefined,
721
- cwd: string,
722
- ): string {
723
- if (cdTarget === undefined) return cwd;
724
- const resolved = resolve(cwd, cdTarget);
725
- const normalizedCwd = resolve(cwd);
726
- return isPathWithinDirectory(resolved, normalizedCwd) ? resolved : cwd;
937
+ function literalTextOf(node: TSNode): string | null {
938
+ switch (node.type) {
939
+ case "word": {
940
+ const text = node.text;
941
+ if (text === "-" || text.startsWith("~")) return null;
942
+ return text;
943
+ }
944
+ case "raw_string": {
945
+ const text = node.text;
946
+ return text.length >= 2 && text.startsWith("'") && text.endsWith("'")
947
+ ? text.slice(1, -1)
948
+ : text;
949
+ }
950
+ case "concatenation": {
951
+ let result = "";
952
+ for (let i = 0; i < node.childCount; i++) {
953
+ const child = node.child(i);
954
+ if (!child) continue;
955
+ const part = literalTextOf(child);
956
+ if (part === null) return null;
957
+ result += part;
958
+ }
959
+ return result;
960
+ }
961
+ case "string": {
962
+ let result = "";
963
+ for (let i = 0; i < node.childCount; i++) {
964
+ const child = node.child(i);
965
+ if (!child) continue;
966
+ if (child.type === '"') continue;
967
+ if (child.type !== "string_content") return null;
968
+ result += child.text;
969
+ }
970
+ return result;
971
+ }
972
+ default:
973
+ return null;
974
+ }
727
975
  }
@@ -29,6 +29,7 @@ import {
29
29
  import { resolveBashCommandCheck } from "./gates/bash-command";
30
30
  import { describeBashExternalDirectoryGate } from "./gates/bash-external-directory";
31
31
  import { describeBashPathGate } from "./gates/bash-path";
32
+ import { BashProgram } from "./gates/bash-program";
32
33
  import type { GateResult, GateRunnerDeps } from "./gates/descriptor";
33
34
  import { isGateBypass } from "./gates/descriptor";
34
35
  import { describeExternalDirectoryGate } from "./gates/external-directory";
@@ -87,6 +88,14 @@ export class PermissionGateHandler {
87
88
  cwd: ctx.cwd,
88
89
  };
89
90
 
91
+ // Parse the bash command exactly once per tool_call; the three bash gates
92
+ // share this single BashProgram instead of each re-parsing (#308).
93
+ const command = getNonEmptyString(toRecord(tcc.input).command);
94
+ const bashProgram =
95
+ tcc.toolName === "bash" && command
96
+ ? await BashProgram.parse(command)
97
+ : null;
98
+
90
99
  // ── Shared gate adapter closures ─────────────────────────────────────
91
100
  const canConfirm = () => this.session.canPrompt(ctx);
92
101
  const promptPermission = (details: PromptPermissionDetails) =>
@@ -166,19 +175,27 @@ export class PermissionGateHandler {
166
175
  () =>
167
176
  describeBashExternalDirectoryGate(
168
177
  tcc,
178
+ bashProgram,
179
+ checkPermission,
180
+ getSessionRuleset,
181
+ ),
182
+ () =>
183
+ describeBashPathGate(
184
+ tcc,
185
+ bashProgram,
169
186
  checkPermission,
170
187
  getSessionRuleset,
171
188
  ),
172
- () => describeBashPathGate(tcc, checkPermission, getSessionRuleset),
173
- async () => {
189
+ () => {
174
190
  // Bash commands may chain several sub-commands (`a && b`, `a | b`, …);
175
- // evaluate each on the bash surface and select the most restrictive,
176
- // rather than matching the whole program string (#301). Other tools
177
- // evaluate their single input directly.
191
+ // evaluate each unit from the shared parse on the bash surface and
192
+ // select the most restrictive, rather than matching the whole program
193
+ // string (#301). Other tools evaluate their single input directly.
178
194
  const toolCheck =
179
- tcc.toolName === "bash"
180
- ? await resolveBashCommandCheck(
181
- getNonEmptyString(toRecord(tcc.input).command) ?? "",
195
+ tcc.toolName === "bash" && bashProgram
196
+ ? resolveBashCommandCheck(
197
+ command ?? "",
198
+ bashProgram.commands(),
182
199
  tcc.agentName ?? undefined,
183
200
  getSessionRuleset(),
184
201
  checkPermission,