@gotgenes/pi-permission-system 16.1.0 → 16.2.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 +13 -0
- package/package.json +1 -1
- package/src/access-intent/access-intent.ts +57 -0
- package/src/handlers/gates/bash-command.ts +18 -3
- package/src/handlers/gates/bash-path.ts +6 -4
- package/src/handlers/gates/external-directory-policy.ts +9 -8
- package/src/handlers/gates/path.ts +6 -5
- package/src/handlers/gates/runner.ts +6 -5
- package/src/handlers/gates/tool-call-gate-pipeline.ts +6 -5
- package/src/permission-event-rpc.ts +4 -6
- package/src/permission-manager.ts +39 -55
- package/src/permission-resolver.ts +45 -57
- package/src/permissions-service.ts +2 -4
- package/src/skill-prompt-sanitizer.ts +2 -2
- package/test/handlers/before-agent-start.test.ts +6 -9
- package/test/handlers/external-directory-session-dedup.test.ts +16 -40
- package/test/handlers/gates/bash-command-metamorphic.test.ts +5 -3
- package/test/handlers/gates/bash-command.test.ts +26 -23
- package/test/handlers/gates/bash-external-directory.test.ts +36 -32
- package/test/handlers/gates/bash-path.test.ts +12 -8
- package/test/handlers/gates/external-directory-policy.test.ts +34 -22
- package/test/handlers/gates/external-directory.test.ts +11 -5
- package/test/handlers/gates/path.test.ts +30 -25
- package/test/handlers/gates/runner.test.ts +6 -1
- package/test/handlers/gates/tool-call-gate-pipeline.test.ts +2 -2
- package/test/handlers/input.test.ts +1 -1
- package/test/helpers/gate-fixtures.ts +12 -24
- package/test/helpers/handler-fixtures.ts +21 -15
- package/test/helpers/session-fixtures.ts +3 -18
- package/test/permission-event-rpc.test.ts +24 -20
- package/test/permission-manager-unified.test.ts +445 -209
- package/test/permission-resolver.test.ts +112 -89
- package/test/permissions-service.test.ts +10 -7
- package/test/skill-prompt-sanitizer.test.ts +30 -7
|
@@ -41,7 +41,7 @@ vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
|
|
|
41
41
|
* Build a fully wired PermissionGateHandler for external-directory dedup
|
|
42
42
|
* tests.
|
|
43
43
|
*
|
|
44
|
-
* `permissionManager.
|
|
44
|
+
* `permissionManager.check` is configured so that:
|
|
45
45
|
* - `external_directory` surface returns "ask" on first call
|
|
46
46
|
* - On subsequent calls it checks the shared `sessionRules` store; if a
|
|
47
47
|
* matching rule was recorded by the runner, it returns "allow" with
|
|
@@ -56,14 +56,16 @@ function makeDeduplicatingHandler(prompter?: GatePrompter): {
|
|
|
56
56
|
makeRealSession();
|
|
57
57
|
const { resolver } = makeRealResolver(permissionManager, sessionRules);
|
|
58
58
|
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
vi.mocked(permissionManager.
|
|
62
|
-
(
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
59
|
+
// Unified check(intent) mock: "ask" for external_directory unless a session
|
|
60
|
+
// rule covers it; "allow" for everything else.
|
|
61
|
+
vi.mocked(permissionManager.check).mockImplementation(
|
|
62
|
+
(intent, rules): PermissionCheckResult => {
|
|
63
|
+
const { surface } = intent;
|
|
64
|
+
// Derive a representative path value from whichever intent kind arrived.
|
|
65
|
+
const pathValue =
|
|
66
|
+
intent.kind === "path-values" ? (intent.values[0] ?? null) : null;
|
|
66
67
|
|
|
68
|
+
if (surface === "external_directory") {
|
|
67
69
|
if (pathValue && rules && rules.length > 0) {
|
|
68
70
|
const match = rules.findLast(
|
|
69
71
|
(r) =>
|
|
@@ -80,7 +82,6 @@ function makeDeduplicatingHandler(prompter?: GatePrompter): {
|
|
|
80
82
|
};
|
|
81
83
|
}
|
|
82
84
|
}
|
|
83
|
-
|
|
84
85
|
return {
|
|
85
86
|
state: "ask",
|
|
86
87
|
toolName: surface,
|
|
@@ -98,19 +99,6 @@ function makeDeduplicatingHandler(prompter?: GatePrompter): {
|
|
|
98
99
|
},
|
|
99
100
|
);
|
|
100
101
|
|
|
101
|
-
// The external-directory gates resolve through checkPathPolicy (#418); route
|
|
102
|
-
// it through the same configured checkPermission so session-approval dedup
|
|
103
|
-
// applies to the typed path alias.
|
|
104
|
-
vi.mocked(permissionManager.checkPathPolicy).mockImplementation(
|
|
105
|
-
(values, agentName, rules, surface = "path") =>
|
|
106
|
-
permissionManager.checkPermission(
|
|
107
|
-
surface,
|
|
108
|
-
{ path: values[0] ?? "*" },
|
|
109
|
-
agentName,
|
|
110
|
-
rules,
|
|
111
|
-
),
|
|
112
|
-
);
|
|
113
|
-
|
|
114
102
|
const events = makeEvents();
|
|
115
103
|
const reporter = new GateDecisionReporter(logger, events);
|
|
116
104
|
const resolvedPrompter: GatePrompter = prompter ?? {
|
|
@@ -325,13 +313,13 @@ describe("session shutdown clears external-directory approvals", () => {
|
|
|
325
313
|
makeRealSession();
|
|
326
314
|
const { resolver } = makeRealResolver(permissionManager, sessionRules);
|
|
327
315
|
|
|
328
|
-
//
|
|
329
|
-
vi.mocked(permissionManager.
|
|
330
|
-
(
|
|
316
|
+
// Unified check(intent): "ask" for external_directory unless session-covered.
|
|
317
|
+
vi.mocked(permissionManager.check).mockImplementation(
|
|
318
|
+
(intent, rules): PermissionCheckResult => {
|
|
319
|
+
const { surface } = intent;
|
|
320
|
+
const pathValue =
|
|
321
|
+
intent.kind === "path-values" ? (intent.values[0] ?? null) : null;
|
|
331
322
|
if (surface === "external_directory") {
|
|
332
|
-
const record = (input ?? {}) as Record<string, unknown>;
|
|
333
|
-
const pathValue =
|
|
334
|
-
typeof record.path === "string" ? record.path : null;
|
|
335
323
|
if (pathValue && rules && rules.length > 0) {
|
|
336
324
|
const match = rules.findLast(
|
|
337
325
|
(r) =>
|
|
@@ -364,18 +352,6 @@ describe("session shutdown clears external-directory approvals", () => {
|
|
|
364
352
|
},
|
|
365
353
|
);
|
|
366
354
|
|
|
367
|
-
// The external-directory tool gate resolves through checkPathPolicy (#418);
|
|
368
|
-
// route it through the same configured checkPermission.
|
|
369
|
-
vi.mocked(permissionManager.checkPathPolicy).mockImplementation(
|
|
370
|
-
(values, agentName, rules, surface = "path") =>
|
|
371
|
-
permissionManager.checkPermission(
|
|
372
|
-
surface,
|
|
373
|
-
{ path: values[0] ?? "*" },
|
|
374
|
-
agentName,
|
|
375
|
-
rules,
|
|
376
|
-
),
|
|
377
|
-
);
|
|
378
|
-
|
|
379
355
|
const events = makeEvents();
|
|
380
356
|
const reporter = new GateDecisionReporter(logger, events);
|
|
381
357
|
const prompter: GatePrompter = {
|
|
@@ -32,13 +32,15 @@ function makeKeyedResolver(
|
|
|
32
32
|
rules: { match: string; state: PermissionState }[],
|
|
33
33
|
): ScopedPermissionResolver {
|
|
34
34
|
return {
|
|
35
|
-
resolve: (
|
|
36
|
-
const command =
|
|
35
|
+
resolve: (intent) => {
|
|
36
|
+
const command =
|
|
37
|
+
intent.kind === "tool"
|
|
38
|
+
? ((intent.input as { command?: string }).command ?? "")
|
|
39
|
+
: "";
|
|
37
40
|
const rule = rules.find((r) => command.includes(r.match));
|
|
38
41
|
const state: PermissionState = rule?.state ?? "allow";
|
|
39
42
|
return makeCheckResult({ state, source: "bash", command });
|
|
40
43
|
},
|
|
41
|
-
resolvePathPolicy: () => makeCheckResult(),
|
|
42
44
|
};
|
|
43
45
|
}
|
|
44
46
|
|
|
@@ -30,17 +30,18 @@ describe("resolveBashCommandCheck", () => {
|
|
|
30
30
|
|
|
31
31
|
expect(result.state).toBe("allow");
|
|
32
32
|
expect(resolver.resolve).toHaveBeenCalledTimes(1);
|
|
33
|
-
expect(resolver.resolve).toHaveBeenCalledWith(
|
|
34
|
-
"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
33
|
+
expect(resolver.resolve).toHaveBeenCalledWith({
|
|
34
|
+
kind: "tool",
|
|
35
|
+
surface: "bash",
|
|
36
|
+
input: { command: "npm install pkg" },
|
|
37
|
+
agentName: undefined,
|
|
38
|
+
});
|
|
38
39
|
});
|
|
39
40
|
|
|
40
41
|
it("denies the chain when any sub-command is denied, reporting that command's pattern", () => {
|
|
41
42
|
const resolver = makeResolver();
|
|
42
|
-
resolver.resolve.mockImplementation((
|
|
43
|
-
const command = (
|
|
43
|
+
resolver.resolve.mockImplementation((intent) => {
|
|
44
|
+
const command = (intent as { input: { command: string } }).input.command;
|
|
44
45
|
return command.startsWith("npm")
|
|
45
46
|
? bashResult("deny", command, "npm *")
|
|
46
47
|
: bashResult("allow", command, "cd *");
|
|
@@ -60,8 +61,8 @@ describe("resolveBashCommandCheck", () => {
|
|
|
60
61
|
|
|
61
62
|
it("asks when a sub-command asks and none denies", () => {
|
|
62
63
|
const resolver = makeResolver();
|
|
63
|
-
resolver.resolve.mockImplementation((
|
|
64
|
-
const command = (
|
|
64
|
+
resolver.resolve.mockImplementation((intent) => {
|
|
65
|
+
const command = (intent as { input: { command: string } }).input.command;
|
|
65
66
|
return command.startsWith("git")
|
|
66
67
|
? bashResult("ask", command, "git *")
|
|
67
68
|
: bashResult("allow", command, "cd *");
|
|
@@ -81,8 +82,8 @@ describe("resolveBashCommandCheck", () => {
|
|
|
81
82
|
|
|
82
83
|
it("returns the first allow result when every sub-command is allowed", () => {
|
|
83
84
|
const resolver = makeResolver();
|
|
84
|
-
resolver.resolve.mockImplementation((
|
|
85
|
-
const command = (
|
|
85
|
+
resolver.resolve.mockImplementation((intent) => {
|
|
86
|
+
const command = (intent as { input: { command: string } }).input.command;
|
|
86
87
|
return bashResult("allow", command, `${command} *`);
|
|
87
88
|
});
|
|
88
89
|
|
|
@@ -109,11 +110,12 @@ describe("resolveBashCommandCheck", () => {
|
|
|
109
110
|
|
|
110
111
|
expect(result.state).toBe("allow");
|
|
111
112
|
expect(resolver.resolve).toHaveBeenCalledTimes(1);
|
|
112
|
-
expect(resolver.resolve).toHaveBeenCalledWith(
|
|
113
|
-
"
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
113
|
+
expect(resolver.resolve).toHaveBeenCalledWith({
|
|
114
|
+
kind: "tool",
|
|
115
|
+
surface: "bash",
|
|
116
|
+
input: { command: "# just a comment" },
|
|
117
|
+
agentName: undefined,
|
|
118
|
+
});
|
|
117
119
|
});
|
|
118
120
|
|
|
119
121
|
it("falls back to the whole command for an empty/whitespace-only command", () => {
|
|
@@ -144,17 +146,18 @@ describe("resolveBashCommandCheck", () => {
|
|
|
144
146
|
|
|
145
147
|
resolveBashCommandCheck("npm i", [{ text: "npm i" }], "agent-x", resolver);
|
|
146
148
|
|
|
147
|
-
expect(resolver.resolve).toHaveBeenCalledWith(
|
|
148
|
-
"
|
|
149
|
-
|
|
150
|
-
"
|
|
151
|
-
|
|
149
|
+
expect(resolver.resolve).toHaveBeenCalledWith({
|
|
150
|
+
kind: "tool",
|
|
151
|
+
surface: "bash",
|
|
152
|
+
input: { command: "npm i" },
|
|
153
|
+
agentName: "agent-x",
|
|
154
|
+
});
|
|
152
155
|
});
|
|
153
156
|
|
|
154
157
|
it("tags the winning result with the offending command's execution context", () => {
|
|
155
158
|
const resolver = makeResolver();
|
|
156
|
-
resolver.resolve.mockImplementation((
|
|
157
|
-
const command = (
|
|
159
|
+
resolver.resolve.mockImplementation((intent) => {
|
|
160
|
+
const command = (intent as { input: { command: string } }).input.command;
|
|
158
161
|
return command.startsWith("rm")
|
|
159
162
|
? bashResult("deny", command, "rm *")
|
|
160
163
|
: bashResult("allow", command, "echo *");
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { AccessIntent } from "#src/access-intent/access-intent";
|
|
2
3
|
import { BashProgram } from "#src/access-intent/bash/program";
|
|
3
4
|
import { getNonEmptyString, toRecord } from "#src/common";
|
|
4
5
|
import { describeBashExternalDirectoryGate } from "#src/handlers/gates/bash-external-directory";
|
|
@@ -40,6 +41,13 @@ function makeCheckResult(
|
|
|
40
41
|
};
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
/** Extract the policy match values a resolve(intent) call carries. */
|
|
45
|
+
function intentValues(intent: AccessIntent): readonly string[] {
|
|
46
|
+
if (intent.kind === "access-path") return intent.path.matchValues();
|
|
47
|
+
if (intent.kind === "path-values") return intent.values;
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
|
|
43
51
|
/**
|
|
44
52
|
* Mirror the handler's parse-once derivation: parse the bash command into a
|
|
45
53
|
* shared `BashProgram` and inject it, exactly as `permission-gate-handler.ts`
|
|
@@ -76,18 +84,19 @@ describe("describeBashExternalDirectoryGate", () => {
|
|
|
76
84
|
expect(result).toBeNull();
|
|
77
85
|
});
|
|
78
86
|
|
|
79
|
-
it("resolves each external path on the external_directory surface via
|
|
87
|
+
it("resolves each external path on the external_directory surface via an access-path intent (#418)", async () => {
|
|
80
88
|
const resolver = makeResolver(makeCheckResult("ask"));
|
|
81
89
|
await describeGate(
|
|
82
90
|
makeTcc({ input: { command: "cat /outside/a.ts" } }),
|
|
83
91
|
resolver,
|
|
84
92
|
);
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
"external_directory",
|
|
89
|
-
|
|
90
|
-
|
|
93
|
+
const intent = resolver.resolve.mock.calls[0][0];
|
|
94
|
+
expect(intent).toMatchObject({
|
|
95
|
+
kind: "access-path",
|
|
96
|
+
surface: "external_directory",
|
|
97
|
+
agentName: undefined,
|
|
98
|
+
});
|
|
99
|
+
expect(intentValues(intent)).toEqual(["/outside/a.ts"]);
|
|
91
100
|
});
|
|
92
101
|
|
|
93
102
|
it("returns GateBypass when all external paths are session-covered", async () => {
|
|
@@ -122,11 +131,10 @@ describe("describeBashExternalDirectoryGate", () => {
|
|
|
122
131
|
// not just session-level allow. This was the bug: source !== "session"
|
|
123
132
|
// kept config-allowed paths in the uncovered set.
|
|
124
133
|
const resolver = makeResolver();
|
|
125
|
-
resolver.
|
|
126
|
-
(
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
: makeCheckResult("ask"),
|
|
134
|
+
resolver.resolve.mockImplementation((intent) =>
|
|
135
|
+
intentValues(intent).length > 0
|
|
136
|
+
? makeCheckResult("allow", { source: "special" })
|
|
137
|
+
: makeCheckResult("ask"),
|
|
130
138
|
);
|
|
131
139
|
const result = await describeGate(makeTcc(), resolver);
|
|
132
140
|
expect(result).not.toBeNull();
|
|
@@ -138,11 +146,10 @@ describe("describeBashExternalDirectoryGate", () => {
|
|
|
138
146
|
// silently downgrading a config-level deny to ask. After the fix, the
|
|
139
147
|
// descriptor's preCheck is derived from the actual path check result.
|
|
140
148
|
const resolver = makeResolver();
|
|
141
|
-
resolver.
|
|
142
|
-
(
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
: makeCheckResult("ask"),
|
|
149
|
+
resolver.resolve.mockImplementation((intent) =>
|
|
150
|
+
intentValues(intent).length > 0
|
|
151
|
+
? makeCheckResult("deny", { source: "special" })
|
|
152
|
+
: makeCheckResult("ask"),
|
|
146
153
|
);
|
|
147
154
|
const result = await describeGate(makeTcc(), resolver);
|
|
148
155
|
expect(isGateDescriptor(result)).toBe(true);
|
|
@@ -199,11 +206,10 @@ describe("describeBashExternalDirectoryGate", () => {
|
|
|
199
206
|
it("config-allowed path is excluded; remaining ask path produces a descriptor", async () => {
|
|
200
207
|
// One path config-allowed, one config-ask → descriptor with only the ask path.
|
|
201
208
|
const resolver = makeResolver();
|
|
202
|
-
resolver.
|
|
203
|
-
(
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
: makeCheckResult("ask"),
|
|
209
|
+
resolver.resolve.mockImplementation((intent) =>
|
|
210
|
+
intentValues(intent).includes("/outside/a.ts")
|
|
211
|
+
? makeCheckResult("allow", { source: "special" })
|
|
212
|
+
: makeCheckResult("ask"),
|
|
207
213
|
);
|
|
208
214
|
const result = await describeGate(
|
|
209
215
|
makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
|
|
@@ -220,11 +226,10 @@ describe("describeBashExternalDirectoryGate", () => {
|
|
|
220
226
|
it("config-denied path makes worstCheck deny even when another path is ask", async () => {
|
|
221
227
|
// One path config-denied, one config-ask → descriptor with preCheck.state === "deny".
|
|
222
228
|
const resolver = makeResolver();
|
|
223
|
-
resolver.
|
|
224
|
-
(
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
: makeCheckResult("ask"),
|
|
229
|
+
resolver.resolve.mockImplementation((intent) =>
|
|
230
|
+
intentValues(intent).includes("/outside/a.ts")
|
|
231
|
+
? makeCheckResult("deny", { source: "special" })
|
|
232
|
+
: makeCheckResult("ask"),
|
|
228
233
|
);
|
|
229
234
|
const result = await describeGate(
|
|
230
235
|
makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
|
|
@@ -241,11 +246,10 @@ describe("describeBashExternalDirectoryGate", () => {
|
|
|
241
246
|
|
|
242
247
|
it("only includes uncovered paths when some are session-covered", async () => {
|
|
243
248
|
const resolver = makeResolver();
|
|
244
|
-
resolver.
|
|
245
|
-
(
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
: makeCheckResult("ask"),
|
|
249
|
+
resolver.resolve.mockImplementation((intent) =>
|
|
250
|
+
intentValues(intent).includes("/outside/a.ts")
|
|
251
|
+
? makeCheckResult("allow", { source: "session" })
|
|
252
|
+
: makeCheckResult("ask"),
|
|
249
253
|
);
|
|
250
254
|
const result = await describeGate(
|
|
251
255
|
makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
|
|
@@ -228,14 +228,16 @@ describe("describeBashPathGate", () => {
|
|
|
228
228
|
resolver,
|
|
229
229
|
)) as GateDescriptor;
|
|
230
230
|
|
|
231
|
-
expect(resolver.
|
|
232
|
-
|
|
231
|
+
expect(resolver.resolve).toHaveBeenCalledWith({
|
|
232
|
+
kind: "path-values",
|
|
233
|
+
surface: "path",
|
|
234
|
+
values: [
|
|
233
235
|
"/test/project/nested/src/file.txt",
|
|
234
236
|
"nested/src/file.txt",
|
|
235
237
|
"src/file.txt",
|
|
236
238
|
],
|
|
237
|
-
undefined,
|
|
238
|
-
);
|
|
239
|
+
agentName: undefined,
|
|
240
|
+
});
|
|
239
241
|
// The raw token drives the prompt, denial context, and session approval.
|
|
240
242
|
expect(result.denialContext).toMatchObject({ pathValue: "src/file.txt" });
|
|
241
243
|
expect(result.decision.value).toBe("src/file.txt");
|
|
@@ -253,10 +255,12 @@ describe("describeBashPathGate", () => {
|
|
|
253
255
|
resolver,
|
|
254
256
|
);
|
|
255
257
|
|
|
256
|
-
expect(resolver.
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
258
|
+
expect(resolver.resolve).toHaveBeenCalledWith({
|
|
259
|
+
kind: "path-values",
|
|
260
|
+
surface: "path",
|
|
261
|
+
values: ["src/foo.ts"],
|
|
262
|
+
agentName: undefined,
|
|
263
|
+
});
|
|
260
264
|
});
|
|
261
265
|
|
|
262
266
|
it("binds a current-directory token's session approval to the cwd subtree", async () => {
|
|
@@ -30,11 +30,12 @@ describe("resolveExternalDirectoryPolicy", () => {
|
|
|
30
30
|
|
|
31
31
|
const result = resolveExternalDirectoryPolicy(path, resolver, undefined);
|
|
32
32
|
|
|
33
|
-
expect(resolver.
|
|
34
|
-
path
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
33
|
+
expect(resolver.resolve).toHaveBeenCalledWith({
|
|
34
|
+
kind: "access-path",
|
|
35
|
+
surface: "external_directory",
|
|
36
|
+
path,
|
|
37
|
+
agentName: undefined,
|
|
38
|
+
});
|
|
38
39
|
expect(result).toEqual(makeCheckResult("ask"));
|
|
39
40
|
});
|
|
40
41
|
|
|
@@ -44,11 +45,12 @@ describe("resolveExternalDirectoryPolicy", () => {
|
|
|
44
45
|
|
|
45
46
|
resolveExternalDirectoryPolicy(path, resolver, "reviewer");
|
|
46
47
|
|
|
47
|
-
expect(resolver.
|
|
48
|
-
path
|
|
49
|
-
"
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
expect(resolver.resolve).toHaveBeenCalledWith({
|
|
49
|
+
kind: "access-path",
|
|
50
|
+
surface: "external_directory",
|
|
51
|
+
path,
|
|
52
|
+
agentName: "reviewer",
|
|
53
|
+
});
|
|
52
54
|
});
|
|
53
55
|
});
|
|
54
56
|
|
|
@@ -74,12 +76,17 @@ describe("selectUncoveredExternalPaths", () => {
|
|
|
74
76
|
const allowed = AccessPath.forExternalDirectory("/outside/ok.ts", cwd);
|
|
75
77
|
const asked = AccessPath.forExternalDirectory("/outside/ask.ts", cwd);
|
|
76
78
|
const resolver = makeResolver();
|
|
77
|
-
resolver.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
?
|
|
81
|
-
:
|
|
82
|
-
|
|
79
|
+
resolver.resolve.mockImplementation((intent) => {
|
|
80
|
+
const values =
|
|
81
|
+
intent.kind === "access-path"
|
|
82
|
+
? intent.path.matchValues()
|
|
83
|
+
: intent.kind === "path-values"
|
|
84
|
+
? intent.values
|
|
85
|
+
: [];
|
|
86
|
+
return values.includes("/outside/ok.ts")
|
|
87
|
+
? makeCheckResult("allow")
|
|
88
|
+
: makeCheckResult("ask");
|
|
89
|
+
});
|
|
83
90
|
|
|
84
91
|
const { uncovered } = selectUncoveredExternalPaths(
|
|
85
92
|
[allowed, asked],
|
|
@@ -94,12 +101,17 @@ describe("selectUncoveredExternalPaths", () => {
|
|
|
94
101
|
const asked = AccessPath.forExternalDirectory("/outside/ask.ts", cwd);
|
|
95
102
|
const denied = AccessPath.forExternalDirectory("/outside/deny.ts", cwd);
|
|
96
103
|
const resolver = makeResolver();
|
|
97
|
-
resolver.
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
?
|
|
101
|
-
:
|
|
102
|
-
|
|
104
|
+
resolver.resolve.mockImplementation((intent) => {
|
|
105
|
+
const values =
|
|
106
|
+
intent.kind === "access-path"
|
|
107
|
+
? intent.path.matchValues()
|
|
108
|
+
: intent.kind === "path-values"
|
|
109
|
+
? intent.values
|
|
110
|
+
: [];
|
|
111
|
+
return values.includes("/outside/deny.ts")
|
|
112
|
+
? makeCheckResult("deny")
|
|
113
|
+
: makeCheckResult("ask");
|
|
114
|
+
});
|
|
103
115
|
|
|
104
116
|
const { worstCheck } = selectUncoveredExternalPaths(
|
|
105
117
|
[asked, denied],
|
|
@@ -144,12 +144,18 @@ describe("describeExternalDirectoryGate", () => {
|
|
|
144
144
|
undefined,
|
|
145
145
|
resolver,
|
|
146
146
|
);
|
|
147
|
-
expect(resolver.
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
147
|
+
expect(resolver.resolve).toHaveBeenCalledWith(
|
|
148
|
+
expect.objectContaining({
|
|
149
|
+
kind: "access-path",
|
|
150
|
+
surface: "external_directory",
|
|
151
|
+
agentName: undefined,
|
|
152
|
+
}),
|
|
151
153
|
);
|
|
152
|
-
|
|
154
|
+
const intent = resolver.resolve.mock.calls[0][0];
|
|
155
|
+
expect(intent.kind).toBe("access-path");
|
|
156
|
+
if (intent.kind === "access-path") {
|
|
157
|
+
expect(intent.path.matchValues()).toEqual(["/outside/project/file.ts"]);
|
|
158
|
+
}
|
|
153
159
|
});
|
|
154
160
|
|
|
155
161
|
it("sessionApproval uses deriveApprovalPattern", () => {
|
|
@@ -155,11 +155,12 @@ describe("describePathGate", () => {
|
|
|
155
155
|
it("resolves the path surface with the file path and agent name", () => {
|
|
156
156
|
const resolver = makeResolver(makeCheckResult({ state: "allow" }));
|
|
157
157
|
describePathGate(makeTcc({ agentName: "my-agent" }), resolver);
|
|
158
|
-
expect(resolver.resolve).toHaveBeenCalledWith(
|
|
159
|
-
"
|
|
160
|
-
|
|
161
|
-
"
|
|
162
|
-
|
|
158
|
+
expect(resolver.resolve).toHaveBeenCalledWith({
|
|
159
|
+
kind: "tool",
|
|
160
|
+
surface: "path",
|
|
161
|
+
input: { path: ".env" },
|
|
162
|
+
agentName: "my-agent",
|
|
163
|
+
});
|
|
163
164
|
});
|
|
164
165
|
});
|
|
165
166
|
|
|
@@ -187,11 +188,12 @@ describe("describePathGate — home-relative paths", () => {
|
|
|
187
188
|
toolName: "read",
|
|
188
189
|
pathValue: "~/.ssh/config",
|
|
189
190
|
});
|
|
190
|
-
expect(resolver.resolve).toHaveBeenCalledWith(
|
|
191
|
-
"
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
191
|
+
expect(resolver.resolve).toHaveBeenCalledWith({
|
|
192
|
+
kind: "tool",
|
|
193
|
+
surface: "path",
|
|
194
|
+
input: { path: "~/.ssh/config" },
|
|
195
|
+
agentName: undefined,
|
|
196
|
+
});
|
|
195
197
|
});
|
|
196
198
|
|
|
197
199
|
it("passes raw $HOME/... path to resolver and builds descriptor on deny", () => {
|
|
@@ -243,11 +245,12 @@ describe("describePathGate — extension and MCP tools (#352)", () => {
|
|
|
243
245
|
resolver,
|
|
244
246
|
);
|
|
245
247
|
expect(isGateDescriptor(result)).toBe(true);
|
|
246
|
-
expect(resolver.resolve).toHaveBeenCalledWith(
|
|
247
|
-
"
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
248
|
+
expect(resolver.resolve).toHaveBeenCalledWith({
|
|
249
|
+
kind: "tool",
|
|
250
|
+
surface: "path",
|
|
251
|
+
input: { path: ".env" },
|
|
252
|
+
agentName: undefined,
|
|
253
|
+
});
|
|
251
254
|
});
|
|
252
255
|
|
|
253
256
|
it("gates an MCP tool via arguments.path", () => {
|
|
@@ -259,11 +262,12 @@ describe("describePathGate — extension and MCP tools (#352)", () => {
|
|
|
259
262
|
resolver,
|
|
260
263
|
);
|
|
261
264
|
expect(isGateDescriptor(result)).toBe(true);
|
|
262
|
-
expect(resolver.resolve).toHaveBeenCalledWith(
|
|
263
|
-
"
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
265
|
+
expect(resolver.resolve).toHaveBeenCalledWith({
|
|
266
|
+
kind: "tool",
|
|
267
|
+
surface: "path",
|
|
268
|
+
input: { path: ".env" },
|
|
269
|
+
agentName: undefined,
|
|
270
|
+
});
|
|
267
271
|
});
|
|
268
272
|
|
|
269
273
|
it("uses a registered extractor's path for a custom-shaped tool", () => {
|
|
@@ -275,11 +279,12 @@ describe("describePathGate — extension and MCP tools (#352)", () => {
|
|
|
275
279
|
resolver,
|
|
276
280
|
extractorLookup("ffgrep", "target"),
|
|
277
281
|
);
|
|
278
|
-
expect(resolver.resolve).toHaveBeenCalledWith(
|
|
279
|
-
"
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
282
|
+
expect(resolver.resolve).toHaveBeenCalledWith({
|
|
283
|
+
kind: "tool",
|
|
284
|
+
surface: "path",
|
|
285
|
+
input: { path: "/etc/passwd" },
|
|
286
|
+
agentName: undefined,
|
|
287
|
+
});
|
|
283
288
|
});
|
|
284
289
|
|
|
285
290
|
it("returns null for an extension tool without a path", () => {
|
|
@@ -215,7 +215,12 @@ describe("GateRunner — descriptor path", () => {
|
|
|
215
215
|
const { runner, deps } = makeGateRunner();
|
|
216
216
|
const result = await runner.run(makeDescriptor(), "test-agent", "tc-1");
|
|
217
217
|
expect(result).toEqual({ action: "allow" });
|
|
218
|
-
expect(deps.resolve).toHaveBeenCalledWith(
|
|
218
|
+
expect(deps.resolve).toHaveBeenCalledWith({
|
|
219
|
+
kind: "tool",
|
|
220
|
+
surface: "read",
|
|
221
|
+
input: {},
|
|
222
|
+
agentName: "test-agent",
|
|
223
|
+
});
|
|
219
224
|
expect(deps.reporter.emitDecision).toHaveBeenCalledWith(
|
|
220
225
|
expect.objectContaining({
|
|
221
226
|
agentName: "test-agent",
|
|
@@ -198,8 +198,8 @@ describe("ToolCallGatePipeline", () => {
|
|
|
198
198
|
// block can only come from the path gate seeing the extracted path.
|
|
199
199
|
function pathDenyingResolver() {
|
|
200
200
|
const resolver = makeResolver();
|
|
201
|
-
resolver.resolve.mockImplementation((
|
|
202
|
-
surface === "path"
|
|
201
|
+
resolver.resolve.mockImplementation((intent) =>
|
|
202
|
+
intent.surface === "path"
|
|
203
203
|
? makeCheckResult({ state: "deny", matchedPattern: "*" })
|
|
204
204
|
: makeCheckResult(),
|
|
205
205
|
);
|
|
@@ -67,7 +67,7 @@ describe("handleInput", () => {
|
|
|
67
67
|
it("does not check permissions for non-skill input", async () => {
|
|
68
68
|
const { handler, permissionManager } = makeHandler();
|
|
69
69
|
await handler.handleInput(makeInputEvent("just a message"), makeCtx());
|
|
70
|
-
expect(permissionManager.
|
|
70
|
+
expect(permissionManager.check).not.toHaveBeenCalled();
|
|
71
71
|
});
|
|
72
72
|
|
|
73
73
|
it("returns continue when skill is allowed", async () => {
|