@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.
- package/CHANGELOG.md +26 -0
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/access-intent/access-intent.ts +6 -3
- package/src/access-intent/bash/program.ts +0 -4
- package/src/handlers/gates/tool-call-gate-pipeline.ts +61 -22
- package/src/handlers/gates/tool.ts +10 -14
- package/src/index.ts +8 -6
- package/src/input-normalizer.ts +54 -56
- package/src/permission-event-rpc.ts +21 -15
- package/src/permission-manager.ts +3 -6
- package/src/permission-session.ts +0 -5
- package/src/permissions-service.ts +33 -12
- package/test/access-intent/bash/program.test.ts +1 -1
- package/test/composition-root.test.ts +22 -0
- package/test/handlers/gates/tool-call-gate-pipeline.test.ts +73 -0
- package/test/handlers/gates/tool.test.ts +25 -16
- package/test/helpers/gate-fixtures.ts +0 -3
- package/test/input-normalizer.test.ts +163 -270
- package/test/path-utils.test.ts +1 -1
- package/test/permission-event-rpc.test.ts +80 -65
- package/test/permission-manager-unified.test.ts +134 -145
- package/test/permissions-service.test.ts +84 -72
- package/test/service.test.ts +56 -103
|
@@ -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()
|
|
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
|
-
"
|
|
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
|
-
//
|
|
174
|
-
// so sub-directory approvals are absolute too — the deliberate
|
|
175
|
-
// that keeps the pattern aligned with the policy values it is
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
|