@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
@@ -18,54 +18,64 @@ afterEach(() => {
18
18
  describe("normalizeInput — non-MCP surfaces", () => {
19
19
  describe("special / path", () => {
20
20
  it("uses path from input as the lookup value", () => {
21
- const result = normalizeInput("path", { path: ".env" }, []);
21
+ const result = normalizeInput("path", { path: ".env" }, [], "linux");
22
22
  expect(result.surface).toBe("path");
23
23
  expect(result.values).toEqual([".env"]);
24
24
  expect(result.resultExtras).toEqual({});
25
25
  });
26
26
 
27
27
  it("falls back to '*' when path is missing", () => {
28
- const result = normalizeInput("path", {}, []);
28
+ const result = normalizeInput("path", {}, [], "linux");
29
29
  expect(result.values).toEqual(["*"]);
30
30
  });
31
31
 
32
32
  it("falls back to '*' when path is not a string", () => {
33
- const result = normalizeInput("path", { path: 42 }, []);
33
+ const result = normalizeInput("path", { path: 42 }, [], "linux");
34
34
  expect(result.values).toEqual(["*"]);
35
35
  });
36
36
 
37
37
  it("falls back to '*' when path is an empty string", () => {
38
- const result = normalizeInput("path", { path: "" }, []);
38
+ const result = normalizeInput("path", { path: "" }, [], "linux");
39
39
  expect(result.values).toEqual(["*"]);
40
40
  });
41
41
 
42
42
  it("falls back to '*' when path is whitespace-only", () => {
43
- const result = normalizeInput("path", { path: " " }, []);
43
+ const result = normalizeInput("path", { path: " " }, [], "linux");
44
44
  expect(result.values).toEqual(["*"]);
45
45
  });
46
46
 
47
47
  it("handles null input", () => {
48
- const result = normalizeInput("path", null, []);
48
+ const result = normalizeInput("path", null, [], "linux");
49
49
  expect(result.values).toEqual(["*"]);
50
50
  });
51
51
 
52
52
  it("expands ~/... path value to absolute home path", () => {
53
- const result = normalizeInput("path", { path: "~/.ssh/config" }, []);
53
+ const result = normalizeInput(
54
+ "path",
55
+ { path: "~/.ssh/config" },
56
+ [],
57
+ "linux",
58
+ );
54
59
  expect(result.values).toEqual([join("/mock/home", ".ssh/config")]);
55
60
  });
56
61
 
57
62
  it("expands $HOME/... path value to absolute home path", () => {
58
- const result = normalizeInput("path", { path: "$HOME/.ssh/config" }, []);
63
+ const result = normalizeInput(
64
+ "path",
65
+ { path: "$HOME/.ssh/config" },
66
+ [],
67
+ "linux",
68
+ );
59
69
  expect(result.values).toEqual([join("/mock/home", ".ssh/config")]);
60
70
  });
61
71
 
62
72
  it("does not expand non-home values", () => {
63
- const result = normalizeInput("path", { path: ".env" }, []);
73
+ const result = normalizeInput("path", { path: ".env" }, [], "linux");
64
74
  expect(result.values).toEqual([".env"]);
65
75
  });
66
76
 
67
77
  it("does not expand the '*' fallback", () => {
68
- const result = normalizeInput("path", {}, []);
78
+ const result = normalizeInput("path", {}, [], "linux");
69
79
  expect(result.values).toEqual(["*"]);
70
80
  });
71
81
 
@@ -74,6 +84,7 @@ describe("normalizeInput — non-MCP surfaces", () => {
74
84
  "path",
75
85
  { path: "src/App.jsx" },
76
86
  [],
87
+ "linux",
77
88
  "/workspace/project",
78
89
  );
79
90
  expect(result.values).toEqual([
@@ -87,6 +98,7 @@ describe("normalizeInput — non-MCP surfaces", () => {
87
98
  "path",
88
99
  { path: "src/App.jsx", pathPolicyValues: ["/etc/shadow"] },
89
100
  [],
101
+ "linux",
90
102
  "/workspace/project",
91
103
  );
92
104
  expect(result.values).toEqual([
@@ -102,6 +114,7 @@ describe("normalizeInput — non-MCP surfaces", () => {
102
114
  "external_directory",
103
115
  { path: "/other/project" },
104
116
  [],
117
+ "linux",
105
118
  );
106
119
  expect(result.surface).toBe("external_directory");
107
120
  expect(result.values).toEqual(["/other/project"]);
@@ -109,22 +122,32 @@ describe("normalizeInput — non-MCP surfaces", () => {
109
122
  });
110
123
 
111
124
  it("falls back to '*' when path is missing", () => {
112
- const result = normalizeInput("external_directory", {}, []);
125
+ const result = normalizeInput("external_directory", {}, [], "linux");
113
126
  expect(result.values).toEqual(["*"]);
114
127
  });
115
128
 
116
129
  it("falls back to '*' when path is not a string", () => {
117
- const result = normalizeInput("external_directory", { path: 42 }, []);
130
+ const result = normalizeInput(
131
+ "external_directory",
132
+ { path: 42 },
133
+ [],
134
+ "linux",
135
+ );
118
136
  expect(result.values).toEqual(["*"]);
119
137
  });
120
138
 
121
139
  it("falls back to '*' when path is an empty string", () => {
122
- const result = normalizeInput("external_directory", { path: "" }, []);
140
+ const result = normalizeInput(
141
+ "external_directory",
142
+ { path: "" },
143
+ [],
144
+ "linux",
145
+ );
123
146
  expect(result.values).toEqual(["*"]);
124
147
  });
125
148
 
126
149
  it("handles null input", () => {
127
- const result = normalizeInput("external_directory", null, []);
150
+ const result = normalizeInput("external_directory", null, [], "linux");
128
151
  expect(result.values).toEqual(["*"]);
129
152
  });
130
153
 
@@ -133,6 +156,7 @@ describe("normalizeInput — non-MCP surfaces", () => {
133
156
  "external_directory",
134
157
  { path: "~/dev/project" },
135
158
  [],
159
+ "linux",
136
160
  );
137
161
  expect(result.values).toEqual([join("/mock/home", "dev/project")]);
138
162
  });
@@ -142,6 +166,7 @@ describe("normalizeInput — non-MCP surfaces", () => {
142
166
  "external_directory",
143
167
  { path: "$HOME/dev/project" },
144
168
  [],
169
+ "linux",
145
170
  );
146
171
  expect(result.values).toEqual([join("/mock/home", "dev/project")]);
147
172
  });
@@ -151,6 +176,7 @@ describe("normalizeInput — non-MCP surfaces", () => {
151
176
  "external_directory",
152
177
  { path: "src/App.jsx" },
153
178
  [],
179
+ "linux",
154
180
  "/workspace/project",
155
181
  );
156
182
  expect(result.values).toEqual([
@@ -162,53 +188,63 @@ describe("normalizeInput — non-MCP surfaces", () => {
162
188
 
163
189
  describe("skill", () => {
164
190
  it("uses skill name from input.name", () => {
165
- const result = normalizeInput("skill", { name: "librarian" }, []);
191
+ const result = normalizeInput(
192
+ "skill",
193
+ { name: "librarian" },
194
+ [],
195
+ "linux",
196
+ );
166
197
  expect(result.surface).toBe("skill");
167
198
  expect(result.values).toEqual(["librarian"]);
168
199
  expect(result.resultExtras).toEqual({});
169
200
  });
170
201
 
171
202
  it("falls back to '*' when name is missing", () => {
172
- const result = normalizeInput("skill", {}, []);
203
+ const result = normalizeInput("skill", {}, [], "linux");
173
204
  expect(result.values).toEqual(["*"]);
174
205
  });
175
206
 
176
207
  it("falls back to '*' when name is not a string", () => {
177
- const result = normalizeInput("skill", { name: 99 }, []);
208
+ const result = normalizeInput("skill", { name: 99 }, [], "linux");
178
209
  expect(result.values).toEqual(["*"]);
179
210
  });
180
211
  });
181
212
 
182
213
  describe("bash", () => {
183
214
  it("uses command from input.command", () => {
184
- const result = normalizeInput("bash", { command: "git status" }, []);
215
+ const result = normalizeInput(
216
+ "bash",
217
+ { command: "git status" },
218
+ [],
219
+ "linux",
220
+ );
185
221
  expect(result.surface).toBe("bash");
186
222
  expect(result.values).toEqual(["git status"]);
187
223
  expect(result.resultExtras).toEqual({ command: "git status" });
188
224
  });
189
225
 
190
226
  it("uses empty string when command is missing", () => {
191
- const result = normalizeInput("bash", {}, []);
227
+ const result = normalizeInput("bash", {}, [], "linux");
192
228
  expect(result.values).toEqual([""]);
193
229
  expect(result.resultExtras).toEqual({ command: "" });
194
230
  });
195
231
 
196
232
  it("uses empty string when command is not a string", () => {
197
- const result = normalizeInput("bash", { command: 42 }, []);
233
+ const result = normalizeInput("bash", { command: 42 }, [], "linux");
198
234
  expect(result.values).toEqual([""]);
199
235
  expect(result.resultExtras).toEqual({ command: "" });
200
236
  });
201
237
 
202
238
  it("strips leading comment lines from values but keeps original in resultExtras", () => {
203
239
  const cmd = "# Check debug logs\nfind /home -path '*debug*' -type f";
204
- const result = normalizeInput("bash", { command: cmd }, []);
240
+ const result = normalizeInput("bash", { command: cmd }, [], "linux");
205
241
  expect(result.values).toEqual(["find /home -path '*debug*' -type f"]);
206
242
  expect(result.resultExtras).toEqual({ command: cmd });
207
243
  });
208
244
 
209
245
  it("strips multiple comment lines", () => {
210
246
  const cmd = "# Step 1\n# Step 2\ngit status --short";
211
- const result = normalizeInput("bash", { command: cmd }, []);
247
+ const result = normalizeInput("bash", { command: cmd }, [], "linux");
212
248
  expect(result.values).toEqual(["git status --short"]);
213
249
  });
214
250
 
@@ -217,13 +253,14 @@ describe("normalizeInput — non-MCP surfaces", () => {
217
253
  "bash",
218
254
  { command: "grep -rn foo src/" },
219
255
  [],
256
+ "linux",
220
257
  );
221
258
  expect(result.values).toEqual(["grep -rn foo src/"]);
222
259
  });
223
260
 
224
261
  it("falls back to original when all lines are comments", () => {
225
262
  const cmd = "# just a comment";
226
- const result = normalizeInput("bash", { command: cmd }, []);
263
+ const result = normalizeInput("bash", { command: cmd }, [], "linux");
227
264
  expect(result.values).toEqual(["# just a comment"]);
228
265
  });
229
266
  });
@@ -235,6 +272,7 @@ describe("normalizeInput — non-MCP surfaces", () => {
235
272
  tool,
236
273
  { path: "/project/src/main.ts" },
237
274
  [],
275
+ "linux",
238
276
  );
239
277
  expect(result.surface).toBe(tool);
240
278
  expect(result.values).toEqual(["/project/src/main.ts"]);
@@ -244,33 +282,43 @@ describe("normalizeInput — non-MCP surfaces", () => {
244
282
 
245
283
  it("falls back to '*' when input.path is missing", () => {
246
284
  for (const tool of ["read", "write", "edit", "grep", "find", "ls"]) {
247
- const result = normalizeInput(tool, {}, []);
285
+ const result = normalizeInput(tool, {}, [], "linux");
248
286
  expect(result.values).toEqual(["*"]);
249
287
  }
250
288
  });
251
289
 
252
290
  it("falls back to '*' when input.path is empty string", () => {
253
- const result = normalizeInput("read", { path: "" }, []);
291
+ const result = normalizeInput("read", { path: "" }, [], "linux");
254
292
  expect(result.values).toEqual(["*"]);
255
293
  });
256
294
 
257
295
  it("falls back to '*' when input.path is not a string", () => {
258
- const result = normalizeInput("write", { path: 42 }, []);
296
+ const result = normalizeInput("write", { path: 42 }, [], "linux");
259
297
  expect(result.values).toEqual(["*"]);
260
298
  });
261
299
 
262
300
  it("falls back to '*' when input is null", () => {
263
- const result = normalizeInput("edit", null, []);
301
+ const result = normalizeInput("edit", null, [], "linux");
264
302
  expect(result.values).toEqual(["*"]);
265
303
  });
266
304
 
267
305
  it("expands ~/... path value to absolute home path", () => {
268
- const result = normalizeInput("read", { path: "~/.ssh/config" }, []);
306
+ const result = normalizeInput(
307
+ "read",
308
+ { path: "~/.ssh/config" },
309
+ [],
310
+ "linux",
311
+ );
269
312
  expect(result.values).toEqual([join("/mock/home", ".ssh/config")]);
270
313
  });
271
314
 
272
315
  it("expands $HOME/... path value to absolute home path", () => {
273
- const result = normalizeInput("write", { path: "$HOME/.ssh/config" }, []);
316
+ const result = normalizeInput(
317
+ "write",
318
+ { path: "$HOME/.ssh/config" },
319
+ [],
320
+ "linux",
321
+ );
274
322
  expect(result.values).toEqual([join("/mock/home", ".ssh/config")]);
275
323
  });
276
324
 
@@ -279,6 +327,7 @@ describe("normalizeInput — non-MCP surfaces", () => {
279
327
  "read",
280
328
  { path: "src/App.jsx" },
281
329
  [],
330
+ "linux",
282
331
  "/workspace/project",
283
332
  );
284
333
  expect(result.values).toEqual([
@@ -290,7 +339,12 @@ describe("normalizeInput — non-MCP surfaces", () => {
290
339
 
291
340
  describe("extension tools (non-path-bearing)", () => {
292
341
  it("uses '*' as the lookup value for extension tools", () => {
293
- const result = normalizeInput("my_extension_tool", { some: "input" }, []);
342
+ const result = normalizeInput(
343
+ "my_extension_tool",
344
+ { some: "input" },
345
+ [],
346
+ "linux",
347
+ );
294
348
  expect(result.surface).toBe("my_extension_tool");
295
349
  expect(result.values).toEqual(["*"]);
296
350
  expect(result.resultExtras).toEqual({});
@@ -301,6 +355,7 @@ describe("normalizeInput — non-MCP surfaces", () => {
301
355
  "my_extension_tool",
302
356
  { path: "/some/path" },
303
357
  [],
358
+ "linux",
304
359
  );
305
360
  expect(result.values).toEqual(["*"]);
306
361
  });
@@ -309,17 +364,17 @@ describe("normalizeInput — non-MCP surfaces", () => {
309
364
 
310
365
  describe("normalizeInput — MCP surface", () => {
311
366
  it("surface is 'mcp'", () => {
312
- const result = normalizeInput("mcp", { tool: "exa:search" }, []);
367
+ const result = normalizeInput("mcp", { tool: "exa:search" }, [], "linux");
313
368
  expect(result.surface).toBe("mcp");
314
369
  });
315
370
 
316
371
  it("values end with the catch-all 'mcp' target", () => {
317
- const result = normalizeInput("mcp", { tool: "exa:search" }, []);
372
+ const result = normalizeInput("mcp", { tool: "exa:search" }, [], "linux");
318
373
  expect(result.values.at(-1)).toBe("mcp");
319
374
  });
320
375
 
321
376
  it("values include specific targets before the catch-all for a qualified tool call", () => {
322
- const result = normalizeInput("mcp", { tool: "exa:search" }, []);
377
+ const result = normalizeInput("mcp", { tool: "exa:search" }, [], "linux");
323
378
  expect(result.values).toContain("exa_search");
324
379
  expect(result.values).toContain("exa:search");
325
380
  expect(result.values).toContain("exa");
@@ -332,34 +387,44 @@ describe("normalizeInput — MCP surface", () => {
332
387
  const rawTargets = createMcpPermissionTargets({ tool: "exa:search" }, [
333
388
  "exa",
334
389
  ]);
335
- const result = normalizeInput("mcp", { tool: "exa:search" }, ["exa"]);
390
+ const result = normalizeInput(
391
+ "mcp",
392
+ { tool: "exa:search" },
393
+ ["exa"],
394
+ "linux",
395
+ );
336
396
  expect(result.values).toEqual([...rawTargets, "mcp"]);
337
397
  });
338
398
 
339
399
  it("resultExtras.target is the first specific target (most-specific)", () => {
340
- const result = normalizeInput("mcp", { tool: "exa:search" }, []);
400
+ const result = normalizeInput("mcp", { tool: "exa:search" }, [], "linux");
341
401
  expect(result.resultExtras.target).toBe(result.values[0]);
342
402
  });
343
403
 
344
404
  it("resultExtras.target is 'mcp' when no specific targets are derived", () => {
345
405
  // Empty input → only mcp_status then mcp appended
346
- const result = normalizeInput("mcp", {}, []);
406
+ const result = normalizeInput("mcp", {}, [], "linux");
347
407
  expect(result.resultExtras.target).toBe("mcp_status");
348
408
  });
349
409
 
350
410
  it("values contain no duplicates", () => {
351
- const result = normalizeInput("mcp", { tool: "exa:search" }, ["exa"]);
411
+ const result = normalizeInput(
412
+ "mcp",
413
+ { tool: "exa:search" },
414
+ ["exa"],
415
+ "linux",
416
+ );
352
417
  const unique = [...new Set(result.values)];
353
418
  expect(result.values).toEqual(unique);
354
419
  });
355
420
 
356
421
  it("produces mcp_status + mcp for status input", () => {
357
- const result = normalizeInput("mcp", {}, []);
422
+ const result = normalizeInput("mcp", {}, [], "linux");
358
423
  expect(result.values).toEqual(["mcp_status", "mcp"]);
359
424
  });
360
425
 
361
426
  it("produces connect targets + mcp for connect input", () => {
362
- const result = normalizeInput("mcp", { connect: "exa" }, []);
427
+ const result = normalizeInput("mcp", { connect: "exa" }, [], "linux");
363
428
  expect(result.values).toContain("mcp_connect_exa");
364
429
  expect(result.values).toContain("mcp_connect");
365
430
  expect(result.values.at(-1)).toBe("mcp");
@@ -0,0 +1,123 @@
1
+ import { beforeEach, describe, expect, test, vi } from "vitest";
2
+
3
+ // Mock node:os so tilde-expansion is deterministic across platforms.
4
+ vi.mock("node:os", () => {
5
+ const homedir = vi.fn(() => "/mock/home");
6
+ return {
7
+ homedir,
8
+ default: { homedir },
9
+ };
10
+ });
11
+
12
+ // Mock node:fs so realpathSync (used by canonicalizePath) is controllable.
13
+ // Default implementation is identity — lexical assertions are unaffected.
14
+ const realpathSync = vi.hoisted(() =>
15
+ vi.fn<(path: string) => string>((p) => p),
16
+ );
17
+ vi.mock("node:fs", () => ({
18
+ realpathSync,
19
+ default: { realpathSync },
20
+ }));
21
+
22
+ import { PathNormalizer } from "#src/path-normalizer";
23
+
24
+ describe("PathNormalizer", () => {
25
+ beforeEach(() => {
26
+ realpathSync.mockReset();
27
+ realpathSync.mockImplementation((p: string) => p);
28
+ });
29
+
30
+ describe("posix flavor", () => {
31
+ const normalizer = new PathNormalizer("linux", "/projects/my-app");
32
+
33
+ test("forPath builds an AccessPath resolved against the baked cwd", () => {
34
+ const ap = normalizer.forPath("src/foo.ts");
35
+ expect(ap.value()).toBe("/projects/my-app/src/foo.ts");
36
+ expect(ap.matchValues()).toEqual([
37
+ "/projects/my-app/src/foo.ts",
38
+ "src/foo.ts",
39
+ ]);
40
+ });
41
+
42
+ test("forPath honors an explicit resolveBase", () => {
43
+ const ap = normalizer.forPath("foo.ts", {
44
+ resolveBase: "/projects/my-app/sub",
45
+ });
46
+ expect(ap.value()).toBe("/projects/my-app/sub/foo.ts");
47
+ });
48
+
49
+ test("forLiteral builds a literal-only AccessPath", () => {
50
+ const ap = normalizer.forLiteral("foo.ts");
51
+ expect(ap.matchValues()).toEqual(["foo.ts"]);
52
+ expect(ap.boundaryValue()).toBe("");
53
+ });
54
+
55
+ test("isAbsolute uses posix rules", () => {
56
+ expect(normalizer.isAbsolute("/etc/hosts")).toBe(true);
57
+ expect(normalizer.isAbsolute("rel/path")).toBe(false);
58
+ });
59
+
60
+ test("resolveBase resolves an offset against the baked cwd", () => {
61
+ expect(normalizer.resolveBase("sub")).toBe("/projects/my-app/sub");
62
+ expect(normalizer.resolveBase("/abs")).toBe("/abs");
63
+ });
64
+
65
+ test("joinBase joins an offset with a relative target", () => {
66
+ expect(normalizer.joinBase("sub", "nested")).toBe("sub/nested");
67
+ });
68
+
69
+ test("isWithinDirectory decides containment", () => {
70
+ expect(normalizer.isWithinDirectory("/a/b/c", "/a/b")).toBe(true);
71
+ expect(normalizer.isWithinDirectory("/a/x", "/a/b")).toBe(false);
72
+ });
73
+
74
+ test("isOutsideWorkingDirectory tests against the baked cwd", () => {
75
+ expect(normalizer.isOutsideWorkingDirectory("/projects/my-app/src")).toBe(
76
+ false,
77
+ );
78
+ expect(normalizer.isOutsideWorkingDirectory("/etc/hosts")).toBe(true);
79
+ });
80
+ });
81
+
82
+ describe("win32 flavor", () => {
83
+ const normalizer = new PathNormalizer("win32", "C:\\Projects\\App");
84
+
85
+ test("forPath builds a case-folded AccessPath with win32 rules", () => {
86
+ const ap = normalizer.forPath("src\\foo.ts");
87
+ expect(ap.value()).toBe("c:\\projects\\app\\src\\foo.ts");
88
+ expect(ap.matchValues()).toEqual([
89
+ "c:\\projects\\app\\src\\foo.ts",
90
+ "src\\foo.ts",
91
+ ]);
92
+ });
93
+
94
+ test("isAbsolute uses win32 rules", () => {
95
+ expect(normalizer.isAbsolute("C:\\Users\\foo")).toBe(true);
96
+ expect(normalizer.isAbsolute("rel\\path")).toBe(false);
97
+ });
98
+
99
+ test("resolveBase resolves an offset against the baked cwd", () => {
100
+ expect(normalizer.resolveBase("sub")).toBe("C:\\Projects\\App\\sub");
101
+ });
102
+
103
+ test("joinBase joins an offset with a relative target", () => {
104
+ expect(normalizer.joinBase("sub", "nested")).toBe("sub\\nested");
105
+ });
106
+
107
+ test("isWithinDirectory folds case", () => {
108
+ expect(
109
+ normalizer.isWithinDirectory(
110
+ "c:\\users\\foo\\dir\\sub",
111
+ "C:\\Users\\Foo\\dir",
112
+ ),
113
+ ).toBe(true);
114
+ });
115
+
116
+ test("isOutsideWorkingDirectory case-folds against the baked cwd", () => {
117
+ expect(
118
+ normalizer.isOutsideWorkingDirectory("c:\\projects\\app\\src"),
119
+ ).toBe(false);
120
+ expect(normalizer.isOutsideWorkingDirectory("C:\\Other\\dir")).toBe(true);
121
+ });
122
+ });
123
+ });