@gotgenes/pi-permission-system 17.1.0 → 18.0.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 (38) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +1 -0
  3. package/package.json +1 -1
  4. package/src/access-intent/access-intent.ts +6 -3
  5. package/src/access-intent/bash/bash-path-resolver.ts +16 -12
  6. package/src/access-intent/bash/program.ts +0 -4
  7. package/src/access-intent/bash/token-classification.ts +23 -0
  8. package/src/handlers/before-agent-start.ts +1 -2
  9. package/src/handlers/gates/external-directory.ts +2 -12
  10. package/src/handlers/gates/skill-read.ts +4 -8
  11. package/src/handlers/gates/tool-call-gate-pipeline.ts +62 -24
  12. package/src/handlers/gates/tool.ts +10 -14
  13. package/src/index.ts +8 -6
  14. package/src/input-normalizer.ts +54 -56
  15. package/src/path-normalizer.ts +30 -0
  16. package/src/permission-event-rpc.ts +21 -15
  17. package/src/permission-manager.ts +3 -6
  18. package/src/permission-session.ts +0 -5
  19. package/src/permissions-service.ts +33 -12
  20. package/src/skill-prompt-sanitizer.ts +8 -21
  21. package/test/access-intent/bash/program.test.ts +1 -1
  22. package/test/access-intent/bash/token-classification.test.ts +75 -0
  23. package/test/bash-external-directory.test.ts +38 -0
  24. package/test/composition-root.test.ts +22 -0
  25. package/test/handlers/external-directory-symlink-acceptance.test.ts +0 -3
  26. package/test/handlers/gates/external-directory.test.ts +0 -1
  27. package/test/handlers/gates/skill-read.test.ts +16 -12
  28. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +73 -0
  29. package/test/handlers/gates/tool.test.ts +25 -16
  30. package/test/helpers/gate-fixtures.ts +0 -3
  31. package/test/input-normalizer.test.ts +163 -270
  32. package/test/path-normalizer.test.ts +43 -0
  33. package/test/path-utils.test.ts +1 -1
  34. package/test/permission-event-rpc.test.ts +80 -65
  35. package/test/permission-manager-unified.test.ts +134 -145
  36. package/test/permissions-service.test.ts +84 -72
  37. package/test/service.test.ts +56 -103
  38. package/test/skill-prompt-sanitizer.test.ts +31 -65
@@ -1,4 +1,3 @@
1
- import { join } from "node:path";
2
1
  import { afterEach, describe, expect, it, vi } from "vitest";
3
2
 
4
3
  const mockHomedir = vi.hoisted(() => vi.fn(() => "/mock/home"));
@@ -8,243 +7,128 @@ vi.mock("node:os", () => ({
8
7
  default: { homedir: mockHomedir },
9
8
  }));
10
9
 
11
- import { normalizeInput } from "#src/input-normalizer";
10
+ // Mock node:fs so realpathSync (used by the canonical alias) is controllable.
11
+ // Default implementation is identity — lexical tests are unaffected.
12
+ const realpathSync = vi.hoisted(() =>
13
+ vi.fn<(path: string) => string>((p) => p),
14
+ );
15
+ vi.mock("node:fs", () => ({
16
+ realpathSync,
17
+ default: { realpathSync },
18
+ }));
19
+
20
+ import {
21
+ buildAccessIntentForSurface,
22
+ normalizeInput,
23
+ } from "#src/input-normalizer";
12
24
  import { createMcpPermissionTargets } from "#src/mcp-targets";
25
+ import { PathNormalizer } from "#src/path-normalizer";
13
26
 
14
27
  afterEach(() => {
15
28
  mockHomedir.mockClear();
29
+ realpathSync.mockReset();
30
+ realpathSync.mockImplementation((p: string) => p);
16
31
  });
17
32
 
18
33
  describe("normalizeInput — non-MCP surfaces", () => {
19
- describe("special / path", () => {
20
- it("uses path from input as the lookup value", () => {
21
- const result = normalizeInput("path", { path: ".env" }, [], "linux");
34
+ // Path-bearing and special surfaces no longer derive path lookup values
35
+ // through normalizeInput that is now done by the access-path gate (#502)
36
+ // and the service/RPC builder (#503). normalizeInput's tool branch collapses
37
+ // every path-bearing or special surface to the catch-all ["*"] exactly as it
38
+ // does for any unrecognised extension tool.
39
+ describe("path-bearing and special surfaces collapse to '*'", () => {
40
+ it("path surface ignores input.path and returns ['*']", () => {
41
+ // After #504 removal: path no longer has a special branch.
42
+ const result = normalizeInput("path", { path: ".env" }, []);
22
43
  expect(result.surface).toBe("path");
23
- expect(result.values).toEqual([".env"]);
24
- expect(result.resultExtras).toEqual({});
25
- });
26
-
27
- it("falls back to '*' when path is missing", () => {
28
- const result = normalizeInput("path", {}, [], "linux");
29
- expect(result.values).toEqual(["*"]);
30
- });
31
-
32
- it("falls back to '*' when path is not a string", () => {
33
- const result = normalizeInput("path", { path: 42 }, [], "linux");
34
- expect(result.values).toEqual(["*"]);
35
- });
36
-
37
- it("falls back to '*' when path is an empty string", () => {
38
- const result = normalizeInput("path", { path: "" }, [], "linux");
39
- expect(result.values).toEqual(["*"]);
40
- });
41
-
42
- it("falls back to '*' when path is whitespace-only", () => {
43
- const result = normalizeInput("path", { path: " " }, [], "linux");
44
- expect(result.values).toEqual(["*"]);
45
- });
46
-
47
- it("handles null input", () => {
48
- const result = normalizeInput("path", null, [], "linux");
49
- expect(result.values).toEqual(["*"]);
50
- });
51
-
52
- it("expands ~/... path value to absolute home path", () => {
53
- const result = normalizeInput(
54
- "path",
55
- { path: "~/.ssh/config" },
56
- [],
57
- "linux",
58
- );
59
- expect(result.values).toEqual([join("/mock/home", ".ssh/config")]);
60
- });
61
-
62
- it("expands $HOME/... path value to absolute home path", () => {
63
- const result = normalizeInput(
64
- "path",
65
- { path: "$HOME/.ssh/config" },
66
- [],
67
- "linux",
68
- );
69
- expect(result.values).toEqual([join("/mock/home", ".ssh/config")]);
70
- });
71
-
72
- it("does not expand non-home values", () => {
73
- const result = normalizeInput("path", { path: ".env" }, [], "linux");
74
- expect(result.values).toEqual([".env"]);
75
- });
76
-
77
- it("does not expand the '*' fallback", () => {
78
- const result = normalizeInput("path", {}, [], "linux");
79
44
  expect(result.values).toEqual(["*"]);
45
+ expect(result.resultExtras).toEqual({});
80
46
  });
81
47
 
82
- it("adds cwd-normalized and relative aliases when cwd is provided", () => {
83
- const result = normalizeInput(
84
- "path",
85
- { path: "src/App.jsx" },
86
- [],
87
- "linux",
88
- "/workspace/project",
89
- );
90
- expect(result.values).toEqual([
91
- "/workspace/project/src/App.jsx",
92
- "src/App.jsx",
93
- ]);
94
- });
95
-
96
- it("ignores a user-supplied string pathPolicyValues field", () => {
97
- const result = normalizeInput(
98
- "path",
99
- { path: "src/App.jsx", pathPolicyValues: ["/etc/shadow"] },
100
- [],
101
- "linux",
102
- "/workspace/project",
103
- );
104
- expect(result.values).toEqual([
105
- "/workspace/project/src/App.jsx",
106
- "src/App.jsx",
107
- ]);
108
- });
109
- });
110
-
111
- describe("special / external_directory", () => {
112
- it("uses path from input as the lookup value", () => {
48
+ it("external_directory surface ignores input.path and returns ['*']", () => {
113
49
  const result = normalizeInput(
114
50
  "external_directory",
115
51
  { path: "/other/project" },
116
52
  [],
117
- "linux",
118
53
  );
119
54
  expect(result.surface).toBe("external_directory");
120
- expect(result.values).toEqual(["/other/project"]);
121
- expect(result.resultExtras).toEqual({});
122
- });
123
-
124
- it("falls back to '*' when path is missing", () => {
125
- const result = normalizeInput("external_directory", {}, [], "linux");
126
- expect(result.values).toEqual(["*"]);
127
- });
128
-
129
- it("falls back to '*' when path is not a string", () => {
130
- const result = normalizeInput(
131
- "external_directory",
132
- { path: 42 },
133
- [],
134
- "linux",
135
- );
136
- expect(result.values).toEqual(["*"]);
137
- });
138
-
139
- it("falls back to '*' when path is an empty string", () => {
140
- const result = normalizeInput(
141
- "external_directory",
142
- { path: "" },
143
- [],
144
- "linux",
145
- );
146
55
  expect(result.values).toEqual(["*"]);
56
+ expect(result.resultExtras).toEqual({});
147
57
  });
148
58
 
149
- it("handles null input", () => {
150
- const result = normalizeInput("external_directory", null, [], "linux");
59
+ it("read surface ignores input.path and returns ['*']", () => {
60
+ const result = normalizeInput("read", { path: ".env" }, []);
61
+ expect(result.surface).toBe("read");
151
62
  expect(result.values).toEqual(["*"]);
63
+ expect(result.resultExtras).toEqual({});
152
64
  });
153
65
 
154
- it("expands ~/... path value to absolute home path", () => {
155
- const result = normalizeInput(
156
- "external_directory",
157
- { path: "~/dev/project" },
158
- [],
159
- "linux",
160
- );
161
- expect(result.values).toEqual([join("/mock/home", "dev/project")]);
162
- });
163
-
164
- it("expands $HOME/... path value to absolute home path", () => {
165
- const result = normalizeInput(
166
- "external_directory",
167
- { path: "$HOME/dev/project" },
168
- [],
169
- "linux",
170
- );
171
- expect(result.values).toEqual([join("/mock/home", "dev/project")]);
172
- });
173
-
174
- it("adds cwd-normalized and relative aliases when cwd is provided", () => {
175
- const result = normalizeInput(
66
+ it("missing path also returns ['*'] (unchanged fallback)", () => {
67
+ for (const surface of [
68
+ "path",
176
69
  "external_directory",
177
- { path: "src/App.jsx" },
178
- [],
179
- "linux",
180
- "/workspace/project",
181
- );
182
- expect(result.values).toEqual([
183
- "/workspace/project/src/App.jsx",
184
- "src/App.jsx",
185
- ]);
70
+ "read",
71
+ "write",
72
+ "edit",
73
+ "grep",
74
+ "find",
75
+ "ls",
76
+ ]) {
77
+ const result = normalizeInput(surface, {}, []);
78
+ expect(result.values).toEqual(["*"]);
79
+ }
186
80
  });
187
81
  });
188
82
 
189
83
  describe("skill", () => {
190
84
  it("uses skill name from input.name", () => {
191
- const result = normalizeInput(
192
- "skill",
193
- { name: "librarian" },
194
- [],
195
- "linux",
196
- );
85
+ const result = normalizeInput("skill", { name: "librarian" }, []);
197
86
  expect(result.surface).toBe("skill");
198
87
  expect(result.values).toEqual(["librarian"]);
199
88
  expect(result.resultExtras).toEqual({});
200
89
  });
201
90
 
202
91
  it("falls back to '*' when name is missing", () => {
203
- const result = normalizeInput("skill", {}, [], "linux");
92
+ const result = normalizeInput("skill", {}, []);
204
93
  expect(result.values).toEqual(["*"]);
205
94
  });
206
95
 
207
96
  it("falls back to '*' when name is not a string", () => {
208
- const result = normalizeInput("skill", { name: 99 }, [], "linux");
97
+ const result = normalizeInput("skill", { name: 99 }, []);
209
98
  expect(result.values).toEqual(["*"]);
210
99
  });
211
100
  });
212
101
 
213
102
  describe("bash", () => {
214
103
  it("uses command from input.command", () => {
215
- const result = normalizeInput(
216
- "bash",
217
- { command: "git status" },
218
- [],
219
- "linux",
220
- );
104
+ const result = normalizeInput("bash", { command: "git status" }, []);
221
105
  expect(result.surface).toBe("bash");
222
106
  expect(result.values).toEqual(["git status"]);
223
107
  expect(result.resultExtras).toEqual({ command: "git status" });
224
108
  });
225
109
 
226
110
  it("uses empty string when command is missing", () => {
227
- const result = normalizeInput("bash", {}, [], "linux");
111
+ const result = normalizeInput("bash", {}, []);
228
112
  expect(result.values).toEqual([""]);
229
113
  expect(result.resultExtras).toEqual({ command: "" });
230
114
  });
231
115
 
232
116
  it("uses empty string when command is not a string", () => {
233
- const result = normalizeInput("bash", { command: 42 }, [], "linux");
117
+ const result = normalizeInput("bash", { command: 42 }, []);
234
118
  expect(result.values).toEqual([""]);
235
119
  expect(result.resultExtras).toEqual({ command: "" });
236
120
  });
237
121
 
238
122
  it("strips leading comment lines from values but keeps original in resultExtras", () => {
239
123
  const cmd = "# Check debug logs\nfind /home -path '*debug*' -type f";
240
- const result = normalizeInput("bash", { command: cmd }, [], "linux");
124
+ const result = normalizeInput("bash", { command: cmd }, []);
241
125
  expect(result.values).toEqual(["find /home -path '*debug*' -type f"]);
242
126
  expect(result.resultExtras).toEqual({ command: cmd });
243
127
  });
244
128
 
245
129
  it("strips multiple comment lines", () => {
246
130
  const cmd = "# Step 1\n# Step 2\ngit status --short";
247
- const result = normalizeInput("bash", { command: cmd }, [], "linux");
131
+ const result = normalizeInput("bash", { command: cmd }, []);
248
132
  expect(result.values).toEqual(["git status --short"]);
249
133
  });
250
134
 
@@ -253,98 +137,20 @@ describe("normalizeInput — non-MCP surfaces", () => {
253
137
  "bash",
254
138
  { command: "grep -rn foo src/" },
255
139
  [],
256
- "linux",
257
140
  );
258
141
  expect(result.values).toEqual(["grep -rn foo src/"]);
259
142
  });
260
143
 
261
144
  it("falls back to original when all lines are comments", () => {
262
145
  const cmd = "# just a comment";
263
- const result = normalizeInput("bash", { command: cmd }, [], "linux");
146
+ const result = normalizeInput("bash", { command: cmd }, []);
264
147
  expect(result.values).toEqual(["# just a comment"]);
265
148
  });
266
149
  });
267
150
 
268
- describe("path-bearing tools (read, write, edit, grep, find, ls)", () => {
269
- it("uses input.path as the lookup value when path is present", () => {
270
- for (const tool of ["read", "write", "edit", "grep", "find", "ls"]) {
271
- const result = normalizeInput(
272
- tool,
273
- { path: "/project/src/main.ts" },
274
- [],
275
- "linux",
276
- );
277
- expect(result.surface).toBe(tool);
278
- expect(result.values).toEqual(["/project/src/main.ts"]);
279
- expect(result.resultExtras).toEqual({});
280
- }
281
- });
282
-
283
- it("falls back to '*' when input.path is missing", () => {
284
- for (const tool of ["read", "write", "edit", "grep", "find", "ls"]) {
285
- const result = normalizeInput(tool, {}, [], "linux");
286
- expect(result.values).toEqual(["*"]);
287
- }
288
- });
289
-
290
- it("falls back to '*' when input.path is empty string", () => {
291
- const result = normalizeInput("read", { path: "" }, [], "linux");
292
- expect(result.values).toEqual(["*"]);
293
- });
294
-
295
- it("falls back to '*' when input.path is not a string", () => {
296
- const result = normalizeInput("write", { path: 42 }, [], "linux");
297
- expect(result.values).toEqual(["*"]);
298
- });
299
-
300
- it("falls back to '*' when input is null", () => {
301
- const result = normalizeInput("edit", null, [], "linux");
302
- expect(result.values).toEqual(["*"]);
303
- });
304
-
305
- it("expands ~/... path value to absolute home path", () => {
306
- const result = normalizeInput(
307
- "read",
308
- { path: "~/.ssh/config" },
309
- [],
310
- "linux",
311
- );
312
- expect(result.values).toEqual([join("/mock/home", ".ssh/config")]);
313
- });
314
-
315
- it("expands $HOME/... path value to absolute home path", () => {
316
- const result = normalizeInput(
317
- "write",
318
- { path: "$HOME/.ssh/config" },
319
- [],
320
- "linux",
321
- );
322
- expect(result.values).toEqual([join("/mock/home", ".ssh/config")]);
323
- });
324
-
325
- it("adds cwd-normalized and relative aliases when cwd is provided", () => {
326
- const result = normalizeInput(
327
- "read",
328
- { path: "src/App.jsx" },
329
- [],
330
- "linux",
331
- "/workspace/project",
332
- );
333
- expect(result.values).toEqual([
334
- "/workspace/project/src/App.jsx",
335
- "src/App.jsx",
336
- ]);
337
- });
338
- });
339
-
340
151
  describe("extension tools (non-path-bearing)", () => {
341
152
  it("uses '*' as the lookup value for extension tools", () => {
342
- const result = normalizeInput(
343
- "my_extension_tool",
344
- { some: "input" },
345
- [],
346
- "linux",
347
- );
153
+ const result = normalizeInput("my_extension_tool", { some: "input" }, []);
348
154
  expect(result.surface).toBe("my_extension_tool");
349
155
  expect(result.values).toEqual(["*"]);
350
156
  expect(result.resultExtras).toEqual({});
@@ -355,7 +161,6 @@ describe("normalizeInput — non-MCP surfaces", () => {
355
161
  "my_extension_tool",
356
162
  { path: "/some/path" },
357
163
  [],
358
- "linux",
359
164
  );
360
165
  expect(result.values).toEqual(["*"]);
361
166
  });
@@ -364,17 +169,17 @@ describe("normalizeInput — non-MCP surfaces", () => {
364
169
 
365
170
  describe("normalizeInput — MCP surface", () => {
366
171
  it("surface is 'mcp'", () => {
367
- const result = normalizeInput("mcp", { tool: "exa:search" }, [], "linux");
172
+ const result = normalizeInput("mcp", { tool: "exa:search" }, []);
368
173
  expect(result.surface).toBe("mcp");
369
174
  });
370
175
 
371
176
  it("values end with the catch-all 'mcp' target", () => {
372
- const result = normalizeInput("mcp", { tool: "exa:search" }, [], "linux");
177
+ const result = normalizeInput("mcp", { tool: "exa:search" }, []);
373
178
  expect(result.values.at(-1)).toBe("mcp");
374
179
  });
375
180
 
376
181
  it("values include specific targets before the catch-all for a qualified tool call", () => {
377
- const result = normalizeInput("mcp", { tool: "exa:search" }, [], "linux");
182
+ const result = normalizeInput("mcp", { tool: "exa:search" }, []);
378
183
  expect(result.values).toContain("exa_search");
379
184
  expect(result.values).toContain("exa:search");
380
185
  expect(result.values).toContain("exa");
@@ -387,46 +192,134 @@ describe("normalizeInput — MCP surface", () => {
387
192
  const rawTargets = createMcpPermissionTargets({ tool: "exa:search" }, [
388
193
  "exa",
389
194
  ]);
390
- const result = normalizeInput(
391
- "mcp",
392
- { tool: "exa:search" },
393
- ["exa"],
394
- "linux",
395
- );
195
+ const result = normalizeInput("mcp", { tool: "exa:search" }, ["exa"]);
396
196
  expect(result.values).toEqual([...rawTargets, "mcp"]);
397
197
  });
398
198
 
399
199
  it("resultExtras.target is the first specific target (most-specific)", () => {
400
- const result = normalizeInput("mcp", { tool: "exa:search" }, [], "linux");
200
+ const result = normalizeInput("mcp", { tool: "exa:search" }, []);
401
201
  expect(result.resultExtras.target).toBe(result.values[0]);
402
202
  });
403
203
 
404
204
  it("resultExtras.target is 'mcp' when no specific targets are derived", () => {
405
205
  // Empty input → only mcp_status then mcp appended
406
- const result = normalizeInput("mcp", {}, [], "linux");
206
+ const result = normalizeInput("mcp", {}, []);
407
207
  expect(result.resultExtras.target).toBe("mcp_status");
408
208
  });
409
209
 
410
210
  it("values contain no duplicates", () => {
411
- const result = normalizeInput(
412
- "mcp",
413
- { tool: "exa:search" },
414
- ["exa"],
415
- "linux",
416
- );
211
+ const result = normalizeInput("mcp", { tool: "exa:search" }, ["exa"]);
417
212
  const unique = [...new Set(result.values)];
418
213
  expect(result.values).toEqual(unique);
419
214
  });
420
215
 
421
216
  it("produces mcp_status + mcp for status input", () => {
422
- const result = normalizeInput("mcp", {}, [], "linux");
217
+ const result = normalizeInput("mcp", {}, []);
423
218
  expect(result.values).toEqual(["mcp_status", "mcp"]);
424
219
  });
425
220
 
426
221
  it("produces connect targets + mcp for connect input", () => {
427
- const result = normalizeInput("mcp", { connect: "exa" }, [], "linux");
222
+ const result = normalizeInput("mcp", { connect: "exa" }, []);
428
223
  expect(result.values).toContain("mcp_connect_exa");
429
224
  expect(result.values).toContain("mcp_connect");
430
225
  expect(result.values.at(-1)).toBe("mcp");
431
226
  });
432
227
  });
228
+
229
+ describe("buildAccessIntentForSurface", () => {
230
+ const normalizer = new PathNormalizer("linux", "/test/project");
231
+
232
+ it("emits an access-path intent carrying the canonical alias for the path surface", () => {
233
+ realpathSync.mockImplementation((p: string) =>
234
+ p === "/test/project/link" ? "/test/project/real" : p,
235
+ );
236
+ const intent = buildAccessIntentForSurface(
237
+ "path",
238
+ "link",
239
+ normalizer,
240
+ undefined,
241
+ );
242
+ expect(intent.kind).toBe("access-path");
243
+ if (intent.kind === "access-path") {
244
+ expect(intent.surface).toBe("path");
245
+ expect(intent.path.matchValues()).toContain("/test/project/real");
246
+ expect(intent.path.value()).toBe("/test/project/link");
247
+ }
248
+ });
249
+
250
+ it("emits an access-path intent for the external_directory surface", () => {
251
+ const intent = buildAccessIntentForSurface(
252
+ "external_directory",
253
+ "/outside/dir",
254
+ normalizer,
255
+ undefined,
256
+ );
257
+ expect(intent.kind).toBe("access-path");
258
+ if (intent.kind === "access-path") {
259
+ expect(intent.surface).toBe("external_directory");
260
+ expect(intent.path.value()).toBe("/outside/dir");
261
+ }
262
+ });
263
+
264
+ it("emits an access-path intent for a path-bearing tool surface (read)", () => {
265
+ const intent = buildAccessIntentForSurface(
266
+ "read",
267
+ "/test/project/.env",
268
+ normalizer,
269
+ undefined,
270
+ );
271
+ expect(intent.kind).toBe("access-path");
272
+ if (intent.kind === "access-path") {
273
+ expect(intent.surface).toBe("read");
274
+ expect(intent.path.value()).toBe("/test/project/.env");
275
+ }
276
+ });
277
+
278
+ it("emits a tool intent for a non-path surface (bash)", () => {
279
+ const intent = buildAccessIntentForSurface(
280
+ "bash",
281
+ "echo hi",
282
+ normalizer,
283
+ "my-agent",
284
+ );
285
+ expect(intent).toEqual({
286
+ kind: "tool",
287
+ surface: "bash",
288
+ input: { command: "echo hi" },
289
+ agentName: "my-agent",
290
+ });
291
+ });
292
+
293
+ it("passes agentName through on the access-path branch", () => {
294
+ const intent = buildAccessIntentForSurface(
295
+ "path",
296
+ "/some/file",
297
+ normalizer,
298
+ "Explore",
299
+ );
300
+ expect(intent.agentName).toBe("Explore");
301
+ });
302
+
303
+ it("falls back to a tool intent for a value-less path surface", () => {
304
+ const intent = buildAccessIntentForSurface(
305
+ "path",
306
+ undefined,
307
+ normalizer,
308
+ undefined,
309
+ );
310
+ expect(intent.kind).toBe("tool");
311
+ if (intent.kind === "tool") {
312
+ expect(intent.surface).toBe("path");
313
+ }
314
+ });
315
+
316
+ it("falls back to a tool intent for a whitespace-only path value", () => {
317
+ const intent = buildAccessIntentForSurface(
318
+ "external_directory",
319
+ " ",
320
+ normalizer,
321
+ undefined,
322
+ );
323
+ expect(intent.kind).toBe("tool");
324
+ });
325
+ });
@@ -77,6 +77,13 @@ describe("PathNormalizer", () => {
77
77
  );
78
78
  expect(normalizer.isOutsideWorkingDirectory("/etc/hosts")).toBe(true);
79
79
  });
80
+
81
+ test("comparableValue returns the lexical absolute form (no FS)", () => {
82
+ expect(normalizer.comparableValue("src/foo.ts")).toBe(
83
+ "/projects/my-app/src/foo.ts",
84
+ );
85
+ expect(normalizer.comparableValue("/etc/hosts")).toBe("/etc/hosts");
86
+ });
80
87
  });
81
88
 
82
89
  describe("win32 flavor", () => {
@@ -119,5 +126,41 @@ describe("PathNormalizer", () => {
119
126
  ).toBe(false);
120
127
  expect(normalizer.isOutsideWorkingDirectory("C:\\Other\\dir")).toBe(true);
121
128
  });
129
+
130
+ test("comparableValue case-folds the lexical absolute form", () => {
131
+ expect(normalizer.comparableValue("src\\foo.ts")).toBe(
132
+ "c:\\projects\\app\\src\\foo.ts",
133
+ );
134
+ });
135
+ });
136
+
137
+ describe("isInfrastructureRead", () => {
138
+ const normalizer = new PathNormalizer("linux", "/projects/my-app");
139
+
140
+ test("allows a read-only tool targeting a configured infra dir", () => {
141
+ const ap = normalizer.forPath("/infra/git/pkg/SKILL.md");
142
+ expect(normalizer.isInfrastructureRead("read", ap, ["/infra"])).toBe(
143
+ true,
144
+ );
145
+ });
146
+
147
+ test("does not allow a write tool targeting an infra dir", () => {
148
+ const ap = normalizer.forPath("/infra/git/pkg/file.ts");
149
+ expect(normalizer.isInfrastructureRead("write", ap, ["/infra"])).toBe(
150
+ false,
151
+ );
152
+ });
153
+
154
+ test("does not allow a read-only tool outside any infra dir", () => {
155
+ const ap = normalizer.forPath("/elsewhere/file.ts");
156
+ expect(normalizer.isInfrastructureRead("read", ap, ["/infra"])).toBe(
157
+ false,
158
+ );
159
+ });
160
+
161
+ test("allows a read targeting the project-local .pi/npm dir (from baked cwd)", () => {
162
+ const ap = normalizer.forPath("/projects/my-app/.pi/npm/dep/index.js");
163
+ expect(normalizer.isInfrastructureRead("read", ap, [])).toBe(true);
164
+ });
122
165
  });
123
166
  });
@@ -421,7 +421,7 @@ describe("isPathOutsideWorkingDirectory", () => {
421
421
  // /tmp -> /private/tmp on macOS; cwd reported as /private/tmp.
422
422
  const symlinkCwd = "/private/tmp";
423
423
  realpathSync.mockImplementation((p: string) => {
424
- if (p.startsWith("/tmp/")) return "/private/tmp" + p.slice(4);
424
+ if (p.startsWith("/tmp/")) return `/private/tmp${p.slice(4)}`;
425
425
  if (p === "/tmp") return "/private/tmp";
426
426
  return p;
427
427
  });