@gotgenes/pi-permission-system 17.1.1 → 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.
@@ -479,6 +479,28 @@ describe("single source of truth for session state", () => {
479
479
  });
480
480
  });
481
481
 
482
+ describe("service path queries evaluate the supplied path (#503)", () => {
483
+ // Before #503 the service path query dropped the value (buildInputForSurface
484
+ // returned {} for the `path` surface), so the query collapsed to ["*"] and a
485
+ // path-specific rule never fired. The query now builds an AccessPath, so the
486
+ // supplied path flows through the resolver → manager and matches `path` rules
487
+ // end-to-end.
488
+ it("resolves a path-surface query against a deny rule on the supplied path", async () => {
489
+ const cwd = mkdtempSync(join(tmpdir(), "pi-perm-svc-path-cwd-"));
490
+ const target = join(cwd, "secrets.env");
491
+ writeGlobalConfig({ permission: { path: { [target]: "deny" } } });
492
+
493
+ const pi = makeFakePi({ events: createEventBus() });
494
+ piPermissionSystemExtension(pi as unknown as ExtensionAPI);
495
+ await fireSessionStart(pi, makeChildCtx(cwd, "svc-path-session"));
496
+
497
+ const result = getPermissionsService()!.checkPermission("path", target);
498
+ expect(result.state).toBe("deny");
499
+
500
+ rmSync(cwd, { recursive: true, force: true });
501
+ });
502
+ });
503
+
482
504
  describe("multi-instance global service interplay", () => {
483
505
  // The fix (#302) scopes the process-global service slot to the publishing
484
506
  // instance. The parent publishes at its session_start; an in-process child
@@ -22,6 +22,16 @@ vi.mock("#src/access-intent/bash/program", () => ({
22
22
  BashProgram: { parse: mockBashProgramParse },
23
23
  }));
24
24
 
25
+ // Mock node:fs so realpathSync (used by canonicalizePath) is controllable for
26
+ // the per-tool symlink-resolution test. Default implementation is identity.
27
+ const realpathSync = vi.hoisted(() =>
28
+ vi.fn<(path: string) => string>((p) => p),
29
+ );
30
+ vi.mock("node:fs", () => ({
31
+ realpathSync,
32
+ default: { realpathSync },
33
+ }));
34
+
25
35
  function makeMockBashProgram() {
26
36
  return {
27
37
  commands: vi.fn<() => []>(() => []),
@@ -36,6 +46,8 @@ describe("ToolCallGatePipeline", () => {
36
46
  beforeEach(() => {
37
47
  mockBashProgramParse.mockReset();
38
48
  mockBashProgramParse.mockResolvedValue(makeMockBashProgram());
49
+ realpathSync.mockReset();
50
+ realpathSync.mockImplementation((p: string) => p);
39
51
  });
40
52
 
41
53
  // ── non-bash tools ───────────────────────────────────────────────────────
@@ -254,4 +266,65 @@ describe("ToolCallGatePipeline", () => {
254
266
  expect(result).toEqual({ action: "allow" });
255
267
  });
256
268
  });
269
+
270
+ // ── per-tool path-bearing gate (#502) ────────────────────────────────────
271
+
272
+ describe("evaluate — per-tool path-bearing gate (#502)", () => {
273
+ it("emits an access-path intent on the tool-name surface for a path-bearing tool", async () => {
274
+ const resolver = makeResolver(makeCheckResult());
275
+ const inputs = makeGateInputs();
276
+ const { runner } = makeGateRunner();
277
+ const pipeline = new ToolCallGatePipeline(resolver, inputs);
278
+
279
+ await pipeline.evaluate(
280
+ makeTcc({ toolName: "read", input: { path: "/test/cwd/foo.ts" } }),
281
+ runner,
282
+ );
283
+
284
+ const perTool = resolver.resolve.mock.calls.find(
285
+ ([intent]) => intent.surface === "read",
286
+ );
287
+ expect(perTool?.[0].kind).toBe("access-path");
288
+ });
289
+
290
+ it("keeps a path-bearing tool with no path on the tool intent", async () => {
291
+ const resolver = makeResolver(makeCheckResult());
292
+ const inputs = makeGateInputs();
293
+ const { runner } = makeGateRunner();
294
+ const pipeline = new ToolCallGatePipeline(resolver, inputs);
295
+
296
+ await pipeline.evaluate(makeTcc({ toolName: "read", input: {} }), runner);
297
+
298
+ const perTool = resolver.resolve.mock.calls.find(
299
+ ([intent]) => intent.surface === "read",
300
+ );
301
+ expect(perTool?.[0].kind).toBe("tool");
302
+ });
303
+
304
+ it("blocks when a per-tool rule matches the symlink-resolved form", async () => {
305
+ // /test/cwd/foo.env is a symlink to /vault/foo.env; the per-tool rule is
306
+ // keyed on the resolved target, which is only reachable via matchValues().
307
+ realpathSync.mockImplementation((p: string) =>
308
+ p === "/test/cwd/foo.env" ? "/vault/foo.env" : p,
309
+ );
310
+ const resolver = makeResolver();
311
+ resolver.resolve.mockImplementation((intent) =>
312
+ intent.kind === "access-path" &&
313
+ intent.surface === "read" &&
314
+ intent.path.matchValues().includes("/vault/foo.env")
315
+ ? makeCheckResult({ state: "deny", matchedPattern: "*.env" })
316
+ : makeCheckResult(),
317
+ );
318
+ const inputs = makeGateInputs();
319
+ const { runner } = makeGateRunner();
320
+ const pipeline = new ToolCallGatePipeline(resolver, inputs);
321
+
322
+ const result = await pipeline.evaluate(
323
+ makeTcc({ toolName: "read", input: { path: "/test/cwd/foo.env" } }),
324
+ runner,
325
+ );
326
+
327
+ expect(result).toMatchObject({ action: "block" });
328
+ });
329
+ });
257
330
  });
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
2
2
 
3
3
  import { describeToolGate } from "#src/handlers/gates/tool";
4
4
  import type { ToolCallContext } from "#src/handlers/gates/types";
5
+ import { PathNormalizer } from "#src/path-normalizer";
5
6
  import {
6
7
  TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH,
7
8
  TOOL_INPUT_PREVIEW_MAX_LENGTH,
@@ -45,6 +46,10 @@ function makeCheckResult(
45
46
  };
46
47
  }
47
48
 
49
+ // The per-tool gate now receives the AccessPath the pipeline builds, bound to
50
+ // the makeTcc default cwd; approval values derive from `accessPath.value()`.
51
+ const normalizer = new PathNormalizer("linux", "/test/project");
52
+
48
53
  // ── tests ──────────────────────────────────────────────────────────────────
49
54
 
50
55
  describe("describeToolGate", () => {
@@ -53,7 +58,6 @@ describe("describeToolGate", () => {
53
58
  makeTcc({ toolName: "read" }),
54
59
  makeCheckResult("ask"),
55
60
  makeFormatter(),
56
- "linux",
57
61
  );
58
62
  expect(desc.surface).toBe("read");
59
63
  expect(desc.decision.surface).toBe("read");
@@ -64,7 +68,6 @@ describe("describeToolGate", () => {
64
68
  makeTcc({ toolName: "write" }),
65
69
  makeCheckResult("ask"),
66
70
  makeFormatter(),
67
- "linux",
68
71
  );
69
72
  expect(desc.decision.value).toBe("write");
70
73
  });
@@ -78,7 +81,6 @@ describe("describeToolGate", () => {
78
81
  makeTcc({ toolName: "bash", input: { command: "git status" } }),
79
82
  check,
80
83
  makeFormatter(),
81
- "linux",
82
84
  );
83
85
  expect(desc.surface).toBe("bash");
84
86
  expect(desc.decision.surface).toBe("bash");
@@ -94,7 +96,6 @@ describe("describeToolGate", () => {
94
96
  makeTcc({ toolName: "mcp", input: { tool: "server:tool" } }),
95
97
  check,
96
98
  makeFormatter(),
97
- "linux",
98
99
  );
99
100
  expect(desc.surface).toBe("mcp");
100
101
  expect(desc.decision.surface).toBe("mcp");
@@ -103,7 +104,7 @@ describe("describeToolGate", () => {
103
104
 
104
105
  it("populates denialContext with kind 'tool' and check result", () => {
105
106
  const check = makeCheckResult("deny", { toolName: "read" });
106
- const desc = describeToolGate(makeTcc(), check, makeFormatter(), "linux");
107
+ const desc = describeToolGate(makeTcc(), check, makeFormatter());
107
108
  expect(desc.denialContext).toEqual({
108
109
  kind: "tool",
109
110
  check,
@@ -118,7 +119,6 @@ describe("describeToolGate", () => {
118
119
  makeTcc({ agentName: "my-agent" }),
119
120
  check,
120
121
  makeFormatter(),
121
- "linux",
122
122
  );
123
123
  expect(desc.denialContext.agentName).toBe("my-agent");
124
124
  });
@@ -129,7 +129,6 @@ describe("describeToolGate", () => {
129
129
  makeTcc({ toolName: "bash", input: { command: "ls" } }),
130
130
  check,
131
131
  makeFormatter(),
132
- "linux",
133
132
  );
134
133
  expect(desc.denialContext).toMatchObject({
135
134
  kind: "tool",
@@ -146,7 +145,6 @@ describe("describeToolGate", () => {
146
145
  makeTcc({ toolName: "bash", input: { command: "git status" } }),
147
146
  check,
148
147
  makeFormatter(),
149
- "linux",
150
148
  );
151
149
  expect(desc.sessionApproval).toBeDefined();
152
150
  expect(desc.sessionApproval?.surface).toBe("bash");
@@ -163,16 +161,17 @@ describe("describeToolGate", () => {
163
161
  }),
164
162
  check,
165
163
  makeFormatter(),
166
- "linux",
164
+ normalizer.forPath("index.html"),
167
165
  );
168
166
  expect(desc.sessionApproval?.surface).toBe("edit");
169
167
  expect(desc.sessionApproval?.representativePattern).toBe("/test/project/*");
170
168
  });
171
169
 
172
170
  it("resolves a sub-directory file's session approval to an absolute pattern", () => {
173
- // Resolve-at-gate canonicalizes every path (not just the cwd-root case),
174
- // so sub-directory approvals are absolute too — the deliberate tradeoff
175
- // that keeps the pattern aligned with the policy values it is matched against.
171
+ // The approval value derives from the AccessPath's lexical absolute form
172
+ // (`value()`), so sub-directory approvals are absolute too — the deliberate
173
+ // tradeoff that keeps the pattern aligned with the policy values it is
174
+ // matched against.
176
175
  const check = makeCheckResult("ask", { toolName: "edit" });
177
176
  const desc = describeToolGate(
178
177
  makeTcc({
@@ -182,20 +181,31 @@ describe("describeToolGate", () => {
182
181
  }),
183
182
  check,
184
183
  makeFormatter(),
185
- "linux",
184
+ normalizer.forPath("src/foo.ts"),
186
185
  );
187
186
  expect(desc.sessionApproval?.representativePattern).toBe(
188
187
  "/test/project/src/*",
189
188
  );
190
189
  });
191
190
 
191
+ it("falls back to a wildcard session approval when no AccessPath is given", () => {
192
+ // A path-bearing tool with no `input.path` keeps the `tool` intent and gets
193
+ // no AccessPath, so the suggestion collapses to the catch-all.
194
+ const desc = describeToolGate(
195
+ makeTcc({ toolName: "read", input: {} }),
196
+ makeCheckResult("ask"),
197
+ makeFormatter(),
198
+ );
199
+ expect(desc.sessionApproval?.surface).toBe("read");
200
+ expect(desc.sessionApproval?.representativePattern).toBe("*");
201
+ });
202
+
192
203
  it("populates promptDetails with correct fields", () => {
193
204
  const check = makeCheckResult("ask");
194
205
  const desc = describeToolGate(
195
206
  makeTcc({ toolName: "read", agentName: "my-agent", toolCallId: "tc-42" }),
196
207
  check,
197
208
  makeFormatter(),
198
- "linux",
199
209
  );
200
210
  expect(desc.promptDetails).toMatchObject({
201
211
  source: "tool_call",
@@ -213,7 +223,6 @@ describe("describeToolGate", () => {
213
223
  makeTcc({ toolName: "bash", input: { command: "ls" } }),
214
224
  check,
215
225
  makeFormatter(),
216
- "linux",
217
226
  );
218
227
  expect(desc.logContext).toMatchObject({
219
228
  source: "tool_call",
@@ -227,7 +236,7 @@ describe("describeToolGate", () => {
227
236
  makeTcc({ toolName: "edit", input: { path: "/a.ts" } }),
228
237
  makeCheckResult("ask", { toolName: "edit" }),
229
238
  makeFormatter(),
230
- "linux",
239
+ normalizer.forPath("/a.ts"),
231
240
  );
232
241
  expect(desc.surface).toBe("edit");
233
242
  expect(desc.input).toEqual({ path: "/a.ts" });
@@ -255,7 +255,6 @@ export function makeGateInputs(
255
255
  getInfrastructureReadDirs?: () => string[];
256
256
  getToolPreviewLimits?: () => ToolPreviewFormatterOptions;
257
257
  getPathNormalizer?: () => PathNormalizer;
258
- getPlatform?: () => NodeJS.Platform;
259
258
  } = {},
260
259
  ): ToolCallGateInputs {
261
260
  return {
@@ -276,8 +275,6 @@ export function makeGateInputs(
276
275
  vi.fn<() => PathNormalizer>(
277
276
  () => new PathNormalizer(process.platform, "/test/cwd"),
278
277
  ),
279
- getPlatform:
280
- overrides.getPlatform ?? vi.fn<() => NodeJS.Platform>(() => "linux"),
281
278
  };
282
279
  }
283
280