@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
@@ -41,85 +41,111 @@ describe("normalizePathForComparison", () => {
41
41
  const cwd = "/projects/my-app";
42
42
 
43
43
  test("resolves absolute path unchanged", () => {
44
- expect(normalizePathForComparison("/usr/local/bin", cwd)).toBe(
44
+ expect(normalizePathForComparison("/usr/local/bin", cwd, "linux")).toBe(
45
45
  "/usr/local/bin",
46
46
  );
47
47
  });
48
48
 
49
49
  test("resolves relative path against cwd", () => {
50
- expect(normalizePathForComparison("src/foo.ts", cwd)).toBe(
50
+ expect(normalizePathForComparison("src/foo.ts", cwd, "linux")).toBe(
51
51
  "/projects/my-app/src/foo.ts",
52
52
  );
53
53
  });
54
54
 
55
55
  test("expands bare ~ to homedir", () => {
56
- expect(normalizePathForComparison("~", cwd)).toBe("/mock/home");
56
+ expect(normalizePathForComparison("~", cwd, "linux")).toBe("/mock/home");
57
57
  });
58
58
 
59
59
  test("expands ~/... to homedir-relative path", () => {
60
- expect(normalizePathForComparison("~/docs/readme.md", cwd)).toBe(
60
+ expect(normalizePathForComparison("~/docs/readme.md", cwd, "linux")).toBe(
61
61
  join("/mock/home", "docs/readme.md"),
62
62
  );
63
63
  });
64
64
 
65
65
  test("expands bare $HOME to homedir", () => {
66
- expect(normalizePathForComparison("$HOME", cwd)).toBe("/mock/home");
66
+ expect(normalizePathForComparison("$HOME", cwd, "linux")).toBe(
67
+ "/mock/home",
68
+ );
67
69
  });
68
70
 
69
71
  test("expands $HOME/... to homedir-relative path", () => {
70
- expect(normalizePathForComparison("$HOME/.ssh/config", cwd)).toBe(
72
+ expect(normalizePathForComparison("$HOME/.ssh/config", cwd, "linux")).toBe(
71
73
  join("/mock/home", ".ssh/config"),
72
74
  );
73
75
  });
74
76
 
75
77
  test("strips leading @ before resolving", () => {
76
- expect(normalizePathForComparison("@/usr/local/bin", cwd)).toBe(
78
+ expect(normalizePathForComparison("@/usr/local/bin", cwd, "linux")).toBe(
77
79
  "/usr/local/bin",
78
80
  );
79
81
  });
80
82
 
81
83
  test("strips surrounding quotes", () => {
82
- expect(normalizePathForComparison("'/usr/local/bin'", cwd)).toBe(
84
+ expect(normalizePathForComparison("'/usr/local/bin'", cwd, "linux")).toBe(
83
85
  "/usr/local/bin",
84
86
  );
85
- expect(normalizePathForComparison('"/usr/local/bin"', cwd)).toBe(
87
+ expect(normalizePathForComparison('"/usr/local/bin"', cwd, "linux")).toBe(
86
88
  "/usr/local/bin",
87
89
  );
88
90
  });
89
91
 
90
92
  test("returns empty string for blank/whitespace-only path", () => {
91
- expect(normalizePathForComparison("", cwd)).toBe("");
92
- expect(normalizePathForComparison(" ", cwd)).toBe("");
93
+ expect(normalizePathForComparison("", cwd, "linux")).toBe("");
94
+ expect(normalizePathForComparison(" ", cwd, "linux")).toBe("");
95
+ });
96
+
97
+ // ── injected platform flavor (Windows is case-folded, win32-resolved) ────
98
+
99
+ test("win32: lowercases the resolved absolute path", () => {
100
+ expect(
101
+ normalizePathForComparison(
102
+ "C:\\Users\\Foo\\Bar.txt",
103
+ "C:\\Projects",
104
+ "win32",
105
+ ),
106
+ ).toBe("c:\\users\\foo\\bar.txt");
107
+ });
108
+
109
+ test("win32: resolves a relative path against cwd with win32 rules", () => {
110
+ expect(
111
+ normalizePathForComparison("src\\foo.ts", "C:\\Projects\\App", "win32"),
112
+ ).toBe("c:\\projects\\app\\src\\foo.ts");
113
+ });
114
+
115
+ test("posix platform leaves case untouched", () => {
116
+ expect(
117
+ normalizePathForComparison("/Projects/App/Src.ts", cwd, "linux"),
118
+ ).toBe("/Projects/App/Src.ts");
93
119
  });
94
120
  });
95
121
 
96
122
  describe("isPathWithinDirectory", () => {
97
123
  test("returns true when path equals directory", () => {
98
- expect(isPathWithinDirectory("/a/b", "/a/b")).toBe(true);
124
+ expect(isPathWithinDirectory("/a/b", "/a/b", "linux")).toBe(true);
99
125
  });
100
126
 
101
127
  test("returns true when path is a direct child", () => {
102
- expect(isPathWithinDirectory("/a/b/c", "/a/b")).toBe(true);
128
+ expect(isPathWithinDirectory("/a/b/c", "/a/b", "linux")).toBe(true);
103
129
  });
104
130
 
105
131
  test("returns true when path is a deep descendant", () => {
106
- expect(isPathWithinDirectory("/a/b/c/d/e", "/a/b")).toBe(true);
132
+ expect(isPathWithinDirectory("/a/b/c/d/e", "/a/b", "linux")).toBe(true);
107
133
  });
108
134
 
109
135
  test("returns false when path is a sibling directory", () => {
110
- expect(isPathWithinDirectory("/a/bc", "/a/b")).toBe(false);
136
+ expect(isPathWithinDirectory("/a/bc", "/a/b", "linux")).toBe(false);
111
137
  });
112
138
 
113
139
  test("returns false when path is outside the directory", () => {
114
- expect(isPathWithinDirectory("/other/path", "/a/b")).toBe(false);
140
+ expect(isPathWithinDirectory("/other/path", "/a/b", "linux")).toBe(false);
115
141
  });
116
142
 
117
143
  test("returns false for empty path", () => {
118
- expect(isPathWithinDirectory("", "/a/b")).toBe(false);
144
+ expect(isPathWithinDirectory("", "/a/b", "linux")).toBe(false);
119
145
  });
120
146
 
121
147
  test("returns false for empty directory", () => {
122
- expect(isPathWithinDirectory("/a/b", "")).toBe(false);
148
+ expect(isPathWithinDirectory("/a/b", "", "linux")).toBe(false);
123
149
  });
124
150
 
125
151
  // ── platform-aware containment (Windows is case-insensitive) ────────────
@@ -319,49 +345,65 @@ describe("isPathOutsideWorkingDirectory", () => {
319
345
  });
320
346
 
321
347
  test("returns false when path is inside cwd", () => {
322
- expect(isPathOutsideWorkingDirectory("/projects/my-app/src", cwd)).toBe(
323
- false,
324
- );
348
+ expect(
349
+ isPathOutsideWorkingDirectory("/projects/my-app/src", cwd, "linux"),
350
+ ).toBe(false);
325
351
  });
326
352
 
327
353
  test("returns false when path equals cwd", () => {
328
- expect(isPathOutsideWorkingDirectory("/projects/my-app", cwd)).toBe(false);
354
+ expect(
355
+ isPathOutsideWorkingDirectory("/projects/my-app", cwd, "linux"),
356
+ ).toBe(false);
329
357
  });
330
358
 
331
359
  test("returns true when path is outside cwd", () => {
332
- expect(isPathOutsideWorkingDirectory("/etc/passwd", cwd)).toBe(true);
360
+ expect(isPathOutsideWorkingDirectory("/etc/passwd", cwd, "linux")).toBe(
361
+ true,
362
+ );
333
363
  });
334
364
 
335
365
  test("returns true for home directory when outside cwd", () => {
336
- expect(isPathOutsideWorkingDirectory("~/secrets", cwd)).toBe(true);
366
+ expect(isPathOutsideWorkingDirectory("~/secrets", cwd, "linux")).toBe(true);
337
367
  });
338
368
 
339
369
  test("returns false for relative path resolving inside cwd", () => {
340
- expect(isPathOutsideWorkingDirectory("src/index.ts", cwd)).toBe(false);
370
+ expect(isPathOutsideWorkingDirectory("src/index.ts", cwd, "linux")).toBe(
371
+ false,
372
+ );
341
373
  });
342
374
 
343
375
  test("returns false for empty path (normalizes to empty string)", () => {
344
- expect(isPathOutsideWorkingDirectory("", cwd)).toBe(false);
376
+ expect(isPathOutsideWorkingDirectory("", cwd, "linux")).toBe(false);
345
377
  });
346
378
 
347
379
  test("returns false for /dev/null regardless of cwd", () => {
348
- expect(isPathOutsideWorkingDirectory("/dev/null", cwd)).toBe(false);
380
+ expect(isPathOutsideWorkingDirectory("/dev/null", cwd, "linux")).toBe(
381
+ false,
382
+ );
349
383
  });
350
384
 
351
385
  test("returns false for /dev/stdin regardless of cwd", () => {
352
- expect(isPathOutsideWorkingDirectory("/dev/stdin", cwd)).toBe(false);
386
+ expect(isPathOutsideWorkingDirectory("/dev/stdin", cwd, "linux")).toBe(
387
+ false,
388
+ );
353
389
  });
354
390
 
355
391
  test("returns false for /dev/stdout regardless of cwd", () => {
356
- expect(isPathOutsideWorkingDirectory("/dev/stdout", cwd)).toBe(false);
392
+ expect(isPathOutsideWorkingDirectory("/dev/stdout", cwd, "linux")).toBe(
393
+ false,
394
+ );
357
395
  });
358
396
 
359
397
  test("returns false for /dev/stderr regardless of cwd", () => {
360
- expect(isPathOutsideWorkingDirectory("/dev/stderr", cwd)).toBe(false);
398
+ expect(isPathOutsideWorkingDirectory("/dev/stderr", cwd, "linux")).toBe(
399
+ false,
400
+ );
361
401
  });
362
402
 
363
403
  test("returns true for /dev/null/subdir (not a safe path)", () => {
364
- expect(isPathOutsideWorkingDirectory("/dev/null/subdir", cwd)).toBe(true);
404
+ expect(
405
+ isPathOutsideWorkingDirectory("/dev/null/subdir", cwd, "linux"),
406
+ ).toBe(true);
365
407
  });
366
408
 
367
409
  test("returns true for in-cwd symlink that resolves to external path", () => {
@@ -370,7 +412,9 @@ describe("isPathOutsideWorkingDirectory", () => {
370
412
  if (p === "/projects/my-app/link/hosts") return "/etc/hosts";
371
413
  return p;
372
414
  });
373
- expect(isPathOutsideWorkingDirectory("./link/hosts", cwd)).toBe(true);
415
+ expect(isPathOutsideWorkingDirectory("./link/hosts", cwd, "linux")).toBe(
416
+ true,
417
+ );
374
418
  });
375
419
 
376
420
  test("returns false for path inside a symlinked cwd", () => {
@@ -382,7 +426,11 @@ describe("isPathOutsideWorkingDirectory", () => {
382
426
  return p;
383
427
  });
384
428
  expect(
385
- isPathOutsideWorkingDirectory("/tmp/workspace/file.ts", symlinkCwd),
429
+ isPathOutsideWorkingDirectory(
430
+ "/tmp/workspace/file.ts",
431
+ symlinkCwd,
432
+ "linux",
433
+ ),
386
434
  ).toBe(false);
387
435
  });
388
436
  });
@@ -400,20 +448,34 @@ describe("canonicalNormalizePathForComparison", () => {
400
448
  if (p === "/projects/link") return "/real/projects/app";
401
449
  return p;
402
450
  });
403
- expect(canonicalNormalizePathForComparison("/projects/link", cwd)).toBe(
404
- "/real/projects/app",
405
- );
451
+ expect(
452
+ canonicalNormalizePathForComparison("/projects/link", cwd, "linux"),
453
+ ).toBe("/real/projects/app");
406
454
  });
407
455
 
408
456
  test("returns empty string for empty input", () => {
409
- expect(canonicalNormalizePathForComparison("", cwd)).toBe("");
457
+ expect(canonicalNormalizePathForComparison("", cwd, "linux")).toBe("");
410
458
  });
411
459
 
412
460
  test("returns lexical form when no symlinks (identity realpathSync)", () => {
413
461
  expect(
414
- canonicalNormalizePathForComparison("/projects/my-app/src/index.ts", cwd),
462
+ canonicalNormalizePathForComparison(
463
+ "/projects/my-app/src/index.ts",
464
+ cwd,
465
+ "linux",
466
+ ),
415
467
  ).toBe("/projects/my-app/src/index.ts");
416
468
  });
469
+
470
+ test("win32: lowercases the canonical form", () => {
471
+ expect(
472
+ canonicalNormalizePathForComparison(
473
+ "C:\\Projects\\App\\Src",
474
+ "C:\\Projects\\App",
475
+ "win32",
476
+ ),
477
+ ).toBe("c:\\projects\\app\\src");
478
+ });
417
479
  });
418
480
 
419
481
  describe("isPiInfrastructureRead", () => {
@@ -427,6 +489,7 @@ describe("isPiInfrastructureRead", () => {
427
489
  "/mock/home/.pi/agent/config.json",
428
490
  infraDirs,
429
491
  cwd,
492
+ "linux",
430
493
  ),
431
494
  ).toBe(true);
432
495
  });
@@ -438,6 +501,7 @@ describe("isPiInfrastructureRead", () => {
438
501
  "/mock/home/.pi/agent/config.json",
439
502
  infraDirs,
440
503
  cwd,
504
+ "linux",
441
505
  ),
442
506
  ).toBe(false);
443
507
  });
@@ -449,6 +513,7 @@ describe("isPiInfrastructureRead", () => {
449
513
  "/projects/my-app/.pi/npm/package.json",
450
514
  [],
451
515
  cwd,
516
+ "linux",
452
517
  ),
453
518
  ).toBe(true);
454
519
  });
@@ -460,14 +525,15 @@ describe("isPiInfrastructureRead", () => {
460
525
  "/projects/my-app/.pi/git/some-file",
461
526
  [],
462
527
  cwd,
528
+ "linux",
463
529
  ),
464
530
  ).toBe(true);
465
531
  });
466
532
 
467
533
  test("returns false for path outside all infra dirs and project dirs", () => {
468
- expect(isPiInfrastructureRead("read", "/etc/passwd", infraDirs, cwd)).toBe(
469
- false,
470
- );
534
+ expect(
535
+ isPiInfrastructureRead("read", "/etc/passwd", infraDirs, cwd, "linux"),
536
+ ).toBe(false);
471
537
  });
472
538
 
473
539
  // ── glob patterns ─────────────────────────────────────────────────
@@ -479,6 +545,7 @@ describe("isPiInfrastructureRead", () => {
479
545
  "/opt/homebrew/Cellar/pi-coding-agent/0.74.0/libexec/lib/node_modules/@earendil-works/pi-coding-agent/SKILL.md",
480
546
  ["/opt/homebrew/**/@earendil-works/pi-coding-agent/**"],
481
547
  cwd,
548
+ "linux",
482
549
  ),
483
550
  ).toBe(true);
484
551
  });
@@ -490,6 +557,7 @@ describe("isPiInfrastructureRead", () => {
490
557
  "/etc/passwd",
491
558
  ["/opt/homebrew/**/@earendil-works/pi-coding-agent/**"],
492
559
  cwd,
560
+ "linux",
493
561
  ),
494
562
  ).toBe(false);
495
563
  });
@@ -502,6 +570,7 @@ describe("isPiInfrastructureRead", () => {
502
570
  "/mock/home/.pi/agent/config.json",
503
571
  ["~/.pi/agent"],
504
572
  cwd,
573
+ "linux",
505
574
  ),
506
575
  ).toBe(true);
507
576
  });
@@ -582,35 +651,45 @@ describe("getPathPolicyValues", () => {
582
651
  const cwd = "/projects/my-app";
583
652
 
584
653
  test("returns only the literal when no base is available", () => {
585
- expect(getPathPolicyValues("src/foo.ts")).toEqual(["src/foo.ts"]);
586
- expect(getPathPolicyValues("src/foo.ts", {})).toEqual(["src/foo.ts"]);
654
+ expect(getPathPolicyValues("src/foo.ts", {}, "linux")).toEqual([
655
+ "src/foo.ts",
656
+ ]);
657
+ expect(getPathPolicyValues("src/foo.ts", {}, "linux")).toEqual([
658
+ "src/foo.ts",
659
+ ]);
587
660
  });
588
661
 
589
662
  test("adds absolute and project-relative aliases for a relative token", () => {
590
- expect(getPathPolicyValues("src/foo.ts", { cwd })).toEqual([
663
+ expect(getPathPolicyValues("src/foo.ts", { cwd }, "linux")).toEqual([
591
664
  "/projects/my-app/src/foo.ts",
592
665
  "src/foo.ts",
593
666
  ]);
594
667
  });
595
668
 
596
669
  test("omits the relative alias for a token outside cwd", () => {
597
- expect(getPathPolicyValues("/etc/hosts", { cwd })).toEqual(["/etc/hosts"]);
670
+ expect(getPathPolicyValues("/etc/hosts", { cwd }, "linux")).toEqual([
671
+ "/etc/hosts",
672
+ ]);
598
673
  });
599
674
 
600
675
  test("resolves against resolveBase while aliasing relative to cwd", () => {
601
676
  expect(
602
- getPathPolicyValues("foo.txt", {
603
- cwd,
604
- resolveBase: "/projects/my-app/nested",
605
- }),
677
+ getPathPolicyValues(
678
+ "foo.txt",
679
+ {
680
+ cwd,
681
+ resolveBase: "/projects/my-app/nested",
682
+ },
683
+ "linux",
684
+ ),
606
685
  ).toEqual(["/projects/my-app/nested/foo.txt", "nested/foo.txt", "foo.txt"]);
607
686
  });
608
687
 
609
688
  test("preserves the surface catch-all", () => {
610
- expect(getPathPolicyValues("*", { cwd })).toEqual(["*"]);
689
+ expect(getPathPolicyValues("*", { cwd }, "linux")).toEqual(["*"]);
611
690
  });
612
691
 
613
692
  test("returns empty for blank input", () => {
614
- expect(getPathPolicyValues(" ", { cwd })).toEqual([]);
693
+ expect(getPathPolicyValues(" ", { cwd }, "linux")).toEqual([]);
615
694
  });
616
695
  });
@@ -18,6 +18,7 @@ function makeDeps(
18
18
  return {
19
19
  forwardingDir: "/tmp/forwarding",
20
20
  subagentSessionsDir: "/tmp/subagents",
21
+ platform: "linux",
21
22
  logger: { review: vi.fn(), debug: vi.fn() },
22
23
  requestPermissionDecisionFromUi: vi
23
24
  .fn()
@@ -71,6 +71,46 @@ const sessionAllow = (surface: string, pattern: string): Rule => ({
71
71
  origin: "session",
72
72
  });
73
73
 
74
+ describe("PermissionManager — injected platform (#510)", () => {
75
+ const winAllow: Ruleset = [
76
+ sessionAllow("external_directory", "C:\\Users\\Foo\\pi\\*"),
77
+ ];
78
+
79
+ it("win32 manager folds case for path-surface matching", () => {
80
+ const manager = new PermissionManager({
81
+ globalConfigPath: "/nonexistent/config.json",
82
+ agentsDir: "/nonexistent/agents",
83
+ platform: "win32",
84
+ });
85
+ const result = manager.check(
86
+ {
87
+ kind: "path-values",
88
+ surface: "external_directory",
89
+ values: ["c:\\users\\foo\\pi\\docs\\readme.md"],
90
+ },
91
+ winAllow,
92
+ );
93
+ expect(result.state).toBe("allow");
94
+ });
95
+
96
+ it("posix manager keeps path-surface matching case-sensitive", () => {
97
+ const manager = new PermissionManager({
98
+ globalConfigPath: "/nonexistent/config.json",
99
+ agentsDir: "/nonexistent/agents",
100
+ platform: "linux",
101
+ });
102
+ const result = manager.check(
103
+ {
104
+ kind: "path-values",
105
+ surface: "external_directory",
106
+ values: ["c:\\users\\foo\\pi\\docs\\readme.md"],
107
+ },
108
+ winAllow,
109
+ );
110
+ expect(result.state).not.toBe("allow");
111
+ });
112
+ });
113
+
74
114
  // Adapters that build an AccessIntent and call the unified `check` entry point,
75
115
  // so these tests exercise the single resolution path (#478) without a
76
116
  // production-class wrapper used only by tests.
@@ -132,7 +132,10 @@ describe("PermissionResolver", () => {
132
132
  resolver.resolve({
133
133
  kind: "access-path",
134
134
  surface: "path",
135
- path: AccessPath.forPath("src/a.ts", { cwd: "/proj" }),
135
+ path: AccessPath.forPath("src/a.ts", {
136
+ cwd: "/proj",
137
+ platform: "linux",
138
+ }),
136
139
  });
137
140
 
138
141
  const passedRules = vi.mocked(pm.check).mock.calls[0][1];
@@ -148,7 +151,10 @@ describe("PermissionResolver", () => {
148
151
  describe("resolve — access-path intent", () => {
149
152
  it("unwraps the AccessPath via matchValues() into a path-values intent", () => {
150
153
  const { resolver, permissionManager } = makeResolver();
151
- const accessPath = AccessPath.forPath("/tmp/x", { cwd: "/workspace" });
154
+ const accessPath = AccessPath.forPath("/tmp/x", {
155
+ cwd: "/workspace",
156
+ platform: "linux",
157
+ });
152
158
 
153
159
  resolver.resolve({
154
160
  kind: "access-path",
@@ -178,7 +184,10 @@ describe("PermissionResolver", () => {
178
184
  matchedPattern: "/tmp/*",
179
185
  });
180
186
  const { resolver } = makeResolver(pm);
181
- const accessPath = AccessPath.forPath("/tmp/x", { cwd: "/workspace" });
187
+ const accessPath = AccessPath.forPath("/tmp/x", {
188
+ cwd: "/workspace",
189
+ platform: "linux",
190
+ });
182
191
 
183
192
  const result = resolver.resolve({
184
193
  kind: "access-path",
@@ -135,6 +135,47 @@ describe("PermissionSession", () => {
135
135
  });
136
136
  });
137
137
 
138
+ describe("getPathNormalizer", () => {
139
+ it("returns a normalizer bound to the reset session cwd", () => {
140
+ const { session } = createSession();
141
+ session.resetForNewSession(makeCtx({ cwd: "/projects/app" }));
142
+
143
+ const ap = session.getPathNormalizer().forPath("src/foo.ts");
144
+
145
+ expect(ap.value()).toBe("/projects/app/src/foo.ts");
146
+ });
147
+
148
+ it("rebinds the normalizer cwd on a subsequent reset", () => {
149
+ const { session } = createSession();
150
+ session.resetForNewSession(makeCtx({ cwd: "/projects/app" }));
151
+ session.resetForNewSession(makeCtx({ cwd: "/projects/other" }));
152
+
153
+ expect(session.getPathNormalizer().forPath("a.ts").value()).toBe(
154
+ "/projects/other/a.ts",
155
+ );
156
+ });
157
+
158
+ it("binds the normalizer on activate, before any reset (no fail-open)", () => {
159
+ const { session } = createSession();
160
+ // A tool call can activate the session before session_start resets it;
161
+ // the normalizer must still track the active ctx cwd.
162
+ session.activate(makeCtx({ cwd: "/projects/activated" }));
163
+
164
+ expect(session.getPathNormalizer().forPath("a.ts").value()).toBe(
165
+ "/projects/activated/a.ts",
166
+ );
167
+ });
168
+
169
+ it("builds a win32 normalizer when constructed with the win32 platform", () => {
170
+ const { session } = createSession({ platform: "win32" });
171
+ session.resetForNewSession(makeCtx({ cwd: "C:\\Projects\\App" }));
172
+
173
+ expect(session.getPathNormalizer().forPath("src\\foo.ts").value()).toBe(
174
+ "c:\\projects\\app\\src\\foo.ts",
175
+ );
176
+ });
177
+ });
178
+
138
179
  describe("shutdown", () => {
139
180
  it("clears session rules", () => {
140
181
  const { session, sessionRules } = createSession();