@gotgenes/pi-permission-system 9.1.0 → 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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,15 @@ 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
+ ## [9.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v9.1.0...pi-permission-system-v9.2.0) (2026-06-02)
9
+
10
+
11
+ ### Features
12
+
13
+ * flag relative paths conservatively after a non-literal cd ([6e631a0](https://github.com/gotgenes/pi-packages/commit/6e631a0c62633e0be3a498752a3bf3614d357d65)), closes [#307](https://github.com/gotgenes/pi-packages/issues/307)
14
+ * fold sequential current-shell cd into the bash effective directory ([7fd8e95](https://github.com/gotgenes/pi-packages/commit/7fd8e9525196ffa558a2b59ea9f4cf66943f9010)), closes [#307](https://github.com/gotgenes/pi-packages/issues/307)
15
+ * scope cd inside subshells and persist it across brace groups ([37b948c](https://github.com/gotgenes/pi-packages/commit/37b948c7e9fd5d9ddac3aa8c6b456039132f7c4e)), closes [#307](https://github.com/gotgenes/pi-packages/issues/307)
16
+
8
17
  ## [9.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v9.0.1...pi-permission-system-v9.1.0) (2026-06-02)
9
18
 
10
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "9.1.0",
3
+ "version": "9.2.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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,
@@ -75,6 +75,29 @@ export interface BashCommand {
75
75
  readonly context?: BashCommandContext;
76
76
  }
77
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
+
78
101
  /**
79
102
  * A bash command parsed once into a reusable representation.
80
103
  *
@@ -87,29 +110,29 @@ export interface BashCommand {
87
110
  */
88
111
  export class BashProgram {
89
112
  private constructor(
90
- private readonly rawTokens: readonly string[],
91
- private readonly leadingCdTarget: string | undefined,
113
+ private readonly rawCandidates: readonly PathCandidate[],
92
114
  private readonly commandUnits: readonly BashCommand[],
93
115
  ) {}
94
116
 
95
117
  /**
96
118
  * Parse a bash command into a `BashProgram`.
97
119
  *
98
- * Uses tree-sitter-bash to build the full AST, walks command-argument and
99
- * redirect-destination nodes once into raw candidate tokens, and records the
100
- * leading `cd` target. Heredoc bodies, comments, and other non-argument
101
- * 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.
102
126
  */
103
127
  static async parse(command: string): Promise<BashProgram> {
104
128
  const parser = await getParser();
105
129
  const tree = parser.parse(command);
106
- if (!tree) return new BashProgram([], undefined, []);
130
+ if (!tree) return new BashProgram([], []);
107
131
 
108
132
  try {
109
- const leadingCdTarget = extractLeadingCdTarget(tree.rootNode);
110
- const rawTokens = collectPathCandidateTokens(tree.rootNode);
133
+ const rawCandidates = collectPathCandidates(tree.rootNode);
111
134
  const commandUnits = collectCommands(tree.rootNode);
112
- return new BashProgram(rawTokens, leadingCdTarget, commandUnits);
135
+ return new BashProgram(rawCandidates, commandUnits);
113
136
  } finally {
114
137
  tree.delete();
115
138
  }
@@ -125,7 +148,7 @@ export class BashProgram {
125
148
  pathTokens(): string[] {
126
149
  const seen = new Set<string>();
127
150
  const result: string[] = [];
128
- for (const token of this.rawTokens) {
151
+ for (const { token } of this.rawCandidates) {
129
152
  const candidate = classifyTokenAsRuleCandidate(token);
130
153
  if (!candidate) continue;
131
154
  if (!seen.has(candidate)) {
@@ -156,21 +179,42 @@ export class BashProgram {
156
179
  /**
157
180
  * Deduplicated paths that resolve outside `cwd`.
158
181
  *
159
- * When the command begins with `cd <dir> && …`, relative candidate paths are
160
- * resolved against `<dir>` (if it stays within CWD) rather than CWD itself,
161
- * mirroring how the shell would resolve them.
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.
162
187
  */
163
188
  externalPaths(cwd: string): string[] {
164
- const resolveBase = computeEffectiveResolveBase(this.leadingCdTarget, cwd);
165
189
  const normalizedCwd = normalizePathForComparison(cwd, cwd);
166
190
 
167
191
  const seen = new Set<string>();
168
192
  const externalPaths: string[] = [];
169
193
 
170
- for (const token of this.rawTokens) {
194
+ for (const { token, base } of this.rawCandidates) {
171
195
  const candidate = classifyTokenAsPathCandidate(token);
172
196
  if (!candidate) continue;
173
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;
174
218
  const normalized = normalizePathForComparison(candidate, resolveBase);
175
219
  if (!normalized) continue;
176
220
 
@@ -733,75 +777,199 @@ function collectSubstitutionCommands(node: TSNode, out: BashCommand[]): void {
733
777
  }
734
778
  }
735
779
 
736
- // ── 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" };
737
787
 
738
788
  /**
739
- * 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.
740
791
  *
741
- * Only descends into `program` and `list` nodes subshells, pipelines, and
742
- * other compound statements are ignored because a `cd` inside them does not
743
- * 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).
744
798
  */
745
- function findFirstCommand(node: TSNode): TSNode | null {
746
- if (node.type === "command") return node;
747
- if (node.type === "program" || node.type === "list") {
748
- const firstChild = node.child(0);
749
- if (firstChild) return findFirstCommand(firstChild);
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).
809
+ */
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;
750
839
  }
751
- return null;
752
840
  }
753
841
 
754
842
  /**
755
- * Extract the target directory of a leading `cd` command from the parsed AST.
756
- *
757
- * When a bash command begins with `cd <dir> && …`, the shell resolves
758
- * subsequent relative paths against `<dir>`, not the original working
759
- * directory. The external-directory guard must do the same, otherwise a
760
- * path that the shell keeps inside the working directory can appear to
761
- * escape it and trigger a spurious permission prompt.
762
- *
763
- * Returns `undefined` when the first command is not `cd`, or when the
764
- * 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.
847
+ */
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
+ }
863
+
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.
765
886
  */
766
- function extractLeadingCdTarget(rootNode: TSNode): string | undefined {
767
- const firstCmd = findFirstCommand(rootNode);
768
- if (!firstCmd) return undefined;
887
+ function isRelativeCandidate(candidate: string): boolean {
888
+ return !candidate.startsWith("/") && !candidate.startsWith("~");
889
+ }
769
890
 
770
- const cmdName = extractCommandName(firstCmd);
771
- if (cmdName !== "cd") return undefined;
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
+ }
772
910
 
773
- for (let i = 0; i < firstCmd.childCount; i++) {
774
- const child = firstCmd.child(i);
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);
775
920
  if (!child) continue;
776
921
  if (child.type === "command_name" || child.type === "variable_assignment")
777
922
  continue;
778
- if (!ARG_NODE_TYPES.has(child.type)) continue;
779
-
780
- const text = resolveNodeText(child);
781
- // Skip `--` (end-of-flags marker)
782
- if (text === "--") continue;
783
- // `cd -` jumps to $OLDPWD; `cd ~…` is home-relative — neither can be
784
- // resolved against the working directory.
785
- if (text === "-" || text.startsWith("~")) return undefined;
786
- 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);
787
928
  }
788
- return undefined;
929
+ return null;
789
930
  }
790
931
 
791
932
  /**
792
- * Compute the effective base directory for resolving relative path candidates.
793
- *
794
- * When the leading `cd` target stays within the working directory, subsequent
795
- * relative paths should be resolved against it. An escaping target is itself
796
- * an external access (reported via its own candidate token) and must never
797
- * 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 (`-`, `~…`).
798
936
  */
799
- function computeEffectiveResolveBase(
800
- cdTarget: string | undefined,
801
- cwd: string,
802
- ): string {
803
- if (cdTarget === undefined) return cwd;
804
- const resolved = resolve(cwd, cdTarget);
805
- const normalizedCwd = resolve(cwd);
806
- 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
+ }
807
975
  }
@@ -852,15 +852,16 @@ describe("extractExternalPathsFromBashCommand", () => {
852
852
  expect(result).toHaveLength(0);
853
853
  });
854
854
 
855
- test("cd to external dir: paths after cd are still checked against cwd", async () => {
856
- // When cd target is outside cwd, we fall back to cwd as the resolve base.
857
- // The cd target itself should be flagged, and paths after cd are resolved
858
- // against cwd.
855
+ test("cd to external dir: subsequent paths resolve against the (external) effective directory", async () => {
856
+ // The effective directory is tracked faithfully: `cd /tmp` makes /tmp the
857
+ // base, so the cd target itself is flagged AND ../etc/hosts resolves to
858
+ // /etc/hosts (both outside cwd).
859
859
  const result = await extractExternalPathsFromBashCommand(
860
860
  "cd /tmp && cat ../etc/hosts",
861
861
  cwd,
862
862
  );
863
- expect(result.length).toBeGreaterThan(0);
863
+ expect(result).toContain("/tmp");
864
+ expect(result).toContain("/etc/hosts");
864
865
  });
865
866
 
866
867
  test("cd with relative target: resolves inside cwd", async () => {
@@ -880,14 +881,14 @@ describe("extractExternalPathsFromBashCommand", () => {
880
881
  expect(result.length).toBeGreaterThan(0);
881
882
  });
882
883
 
883
- test("cd is not first command: cd is ignored", async () => {
884
- // cd after another command should not affect path resolution.
884
+ test("sequential fold: a cd that is not the first command still updates the base", async () => {
885
+ // The current-shell `cd` folds even though it is not the first command;
886
+ // ../../outside.txt resolves against /projects/my-app/src → /projects/outside.txt.
885
887
  const result = await extractExternalPathsFromBashCommand(
886
888
  "echo hello && cd /projects/my-app/src && cat ../../outside.txt",
887
889
  cwd,
888
890
  );
889
- // ../../outside.txt resolves against cwd, not the cd target
890
- expect(result.length).toBeGreaterThan(0);
891
+ expect(result).toContain("/projects/outside.txt");
891
892
  });
892
893
 
893
894
  test("cd with semicolon separator", async () => {
@@ -33,6 +33,115 @@ describe("BashProgram", () => {
33
33
  const program = await BashProgram.parse("cat src/index.ts");
34
34
  expect(program.externalPaths(cwd)).toHaveLength(0);
35
35
  });
36
+
37
+ describe("effective working directory projection", () => {
38
+ it("folds a sequence of current-shell cd commands", async () => {
39
+ // cd a → cwd/a, cd b → cwd/a/b; ../c resolves to cwd/a/c (inside).
40
+ const program = await BashProgram.parse("cd a && cd b && cat ../c");
41
+ expect(program.externalPaths(cwd)).toHaveLength(0);
42
+ });
43
+
44
+ it("catches an escape masked by a later cd that the single-base model missed", async () => {
45
+ // Effective dir after `cd nested/deep && cd ..` is cwd/nested, so
46
+ // ../../etc/passwd escapes to /projects/etc/passwd.
47
+ const program = await BashProgram.parse(
48
+ "cd nested/deep && cd .. && cat ../../etc/passwd",
49
+ );
50
+ expect(program.externalPaths(cwd)).toContain("/projects/etc/passwd");
51
+ });
52
+
53
+ it("folds a cd that is not the first command", async () => {
54
+ // The single-base model ignored a cd that was not first; now `cd a`
55
+ // folds, so ../b resolves to cwd/b (inside) and is not flagged.
56
+ const program = await BashProgram.parse("mkdir d && cd a && cat ../b");
57
+ expect(program.externalPaths(cwd)).toHaveLength(0);
58
+ });
59
+
60
+ it("does not fold a backgrounded cd", async () => {
61
+ // `cd a &` runs in a subshell, so it must not update the running
62
+ // directory; ../b resolves against cwd and escapes.
63
+ const program = await BashProgram.parse("cd a & cat ../b");
64
+ expect(program.externalPaths(cwd)).toContain("/projects/b");
65
+ });
66
+
67
+ it("does not fold a cd inside a pipeline", async () => {
68
+ // Pipeline members run in subshells; the cd must not leak.
69
+ const program = await BashProgram.parse("cd nested | cat ../b");
70
+ expect(program.externalPaths(cwd)).toContain("/projects/b");
71
+ });
72
+
73
+ it("folds a cd inside a subshell for paths within that subshell", async () => {
74
+ // Inside the subshell the effective dir is cwd/sub, so ../x → cwd/x.
75
+ const program = await BashProgram.parse("( cd sub && cat ../x )");
76
+ expect(program.externalPaths(cwd)).toHaveLength(0);
77
+ });
78
+
79
+ it("does not leak a subshell cd to following commands", async () => {
80
+ // The subshell cd resets on exit, so ../y resolves against cwd.
81
+ const program = await BashProgram.parse("( cd sub ) && cat ../y");
82
+ expect(program.externalPaths(cwd)).toContain("/projects/y");
83
+ });
84
+
85
+ it("persists a cd inside a brace group to later commands in the group", async () => {
86
+ // Brace groups run in the current shell, so cd sub persists to cat ../x.
87
+ const program = await BashProgram.parse("{ cd sub; cat ../x; }");
88
+ expect(program.externalPaths(cwd)).toHaveLength(0);
89
+ });
90
+
91
+ it("persists a brace-group cd to following sibling commands", async () => {
92
+ const program = await BashProgram.parse("{ cd sub; } && cat ../x");
93
+ expect(program.externalPaths(cwd)).toHaveLength(0);
94
+ });
95
+
96
+ it("conservatively flags a relative path inside a command substitution", async () => {
97
+ // Interior cd folding inside substitutions is deferred: the interior
98
+ // inherits the enclosing base (cwd), so ../r is flagged rather than
99
+ // resolved against cwd/q. Conservative — never misses an escape.
100
+ const program = await BashProgram.parse("echo $(cd q && cat ../r)");
101
+ expect(program.externalPaths(cwd)).toContain("/projects/r");
102
+ });
103
+
104
+ it("flags relative paths conservatively after a non-literal cd", async () => {
105
+ // cd "$DIR" makes the effective dir unknowable; ../x could be anywhere,
106
+ // so it is flagged (least-privilege).
107
+ const program = await BashProgram.parse('cd "$DIR" && cat ../x');
108
+ expect(program.externalPaths(cwd)).toContain("/projects/x");
109
+ });
110
+
111
+ it("flags even a within-cwd relative path after a non-literal cd", async () => {
112
+ // Conservative cost: src/../within.txt resolves inside cwd but is still
113
+ // flagged because the effective dir is unknown.
114
+ const program = await BashProgram.parse(
115
+ 'cd "$DIR" && cat src/../within.txt',
116
+ );
117
+ expect(program.externalPaths(cwd)).toContain(
118
+ "/projects/my-app/within.txt",
119
+ );
120
+ });
121
+
122
+ it("still resolves an absolute path normally after a non-literal cd", async () => {
123
+ // Absolute paths are base-independent; one inside cwd is not flagged
124
+ // even when the effective dir is unknown.
125
+ const program = await BashProgram.parse(
126
+ 'cd "$DIR" && cat /projects/my-app/x.txt',
127
+ );
128
+ expect(program.externalPaths(cwd)).toHaveLength(0);
129
+ });
130
+
131
+ it("treats `cd -` as an unknown effective directory", async () => {
132
+ const program = await BashProgram.parse("cd - && cat ../x");
133
+ expect(program.externalPaths(cwd)).toContain("/projects/x");
134
+ });
135
+
136
+ it("recovers a known base when a later cd is absolute", async () => {
137
+ // cd "$DIR" → unknown, then cd /projects/my-app/src → known again, so
138
+ // ../x resolves to cwd and is not flagged.
139
+ const program = await BashProgram.parse(
140
+ 'cd "$DIR" && cd /projects/my-app/src && cat ../x',
141
+ );
142
+ expect(program.externalPaths(cwd)).toHaveLength(0);
143
+ });
144
+ });
36
145
  });
37
146
 
38
147
  describe("commands", () => {