@gotgenes/pi-permission-system 10.1.0 → 10.3.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 +14 -0
- package/package.json +1 -1
- package/src/config-modal.ts +7 -10
- package/src/config-store.ts +243 -0
- package/src/index.ts +11 -15
- package/src/permission-manager.ts +69 -3
- package/src/permission-prompter.ts +3 -3
- package/src/permission-session.ts +13 -28
- package/src/runtime.ts +34 -203
- package/test/config-modal.test.ts +15 -10
- package/test/config-store.test.ts +452 -0
- package/test/handlers/external-directory-integration.test.ts +81 -176
- package/test/handlers/gates/bash-path.test.ts +26 -44
- package/test/handlers/gates/runner.test.ts +27 -119
- package/test/handlers/tool-call.test.ts +44 -153
- package/test/helpers/gate-fixtures.ts +66 -2
- package/test/helpers/handler-fixtures.ts +83 -2
- package/test/permission-manager-unified.test.ts +159 -1
- package/test/permission-prompter.test.ts +12 -5
- package/test/permission-session.test.ts +111 -120
- package/test/runtime.test.ts +11 -275
|
@@ -9,26 +9,17 @@
|
|
|
9
9
|
* ensures the test file fails to load if any helper is removed.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
13
12
|
import { describe, expect, it, vi } from "vitest";
|
|
14
13
|
|
|
15
|
-
import { GateDecisionReporter } from "#src/decision-reporter";
|
|
16
14
|
import { EXTENSION_TAG } from "#src/denial-messages";
|
|
17
|
-
import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
|
|
18
15
|
import { formatExternalDirectoryAskPrompt } from "#src/handlers/gates/external-directory-messages";
|
|
19
|
-
import {
|
|
20
|
-
import { SkillInputGatePipeline } from "#src/handlers/gates/skill-input-gate-pipeline";
|
|
21
|
-
import { ToolCallGatePipeline } from "#src/handlers/gates/tool-call-gate-pipeline";
|
|
22
|
-
import { PermissionGateHandler } from "#src/handlers/permission-gate-handler";
|
|
23
|
-
import { resolveToolPreviewLimits } from "#src/tool-preview-formatter";
|
|
24
|
-
import type { ToolRegistry } from "#src/tool-registry";
|
|
25
|
-
import type { PermissionCheckResult, PermissionState } from "#src/types";
|
|
16
|
+
import type { PermissionCheckResult } from "#src/types";
|
|
26
17
|
|
|
27
18
|
import {
|
|
28
19
|
getDecisionEvents,
|
|
29
|
-
type MockGateHandlerSession,
|
|
30
20
|
makeCtx,
|
|
31
|
-
|
|
21
|
+
makeHandler,
|
|
22
|
+
makeSurfaceCheck,
|
|
32
23
|
makeToolCallEvent,
|
|
33
24
|
} from "#test/helpers/handler-fixtures";
|
|
34
25
|
|
|
@@ -44,150 +35,35 @@ vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
|
|
|
44
35
|
const CWD = "/test/project";
|
|
45
36
|
const EXTERNAL_PATH = "/outside/project/file.ts";
|
|
46
37
|
|
|
47
|
-
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
48
|
-
|
|
49
|
-
function makeCheckPermission(
|
|
50
|
-
externalDirectoryState: PermissionState,
|
|
51
|
-
toolState: PermissionState = "allow",
|
|
52
|
-
) {
|
|
53
|
-
return vi
|
|
54
|
-
.fn()
|
|
55
|
-
.mockImplementation((surface: string): PermissionCheckResult => {
|
|
56
|
-
if (surface === "external_directory") {
|
|
57
|
-
return {
|
|
58
|
-
state: externalDirectoryState,
|
|
59
|
-
toolName: surface,
|
|
60
|
-
source: "tool",
|
|
61
|
-
origin: "builtin",
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
// The cross-cutting path gate runs before ext-dir; keep it transparent.
|
|
65
|
-
if (surface === "path") {
|
|
66
|
-
return {
|
|
67
|
-
state: "allow",
|
|
68
|
-
toolName: surface,
|
|
69
|
-
source: "special",
|
|
70
|
-
origin: "builtin",
|
|
71
|
-
};
|
|
72
|
-
}
|
|
73
|
-
return {
|
|
74
|
-
state: toolState,
|
|
75
|
-
toolName: surface,
|
|
76
|
-
source: "tool",
|
|
77
|
-
origin: "builtin",
|
|
78
|
-
};
|
|
79
|
-
});
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function makeSession(
|
|
83
|
-
overrides: Partial<MockGateHandlerSession> = {},
|
|
84
|
-
): MockGateHandlerSession {
|
|
85
|
-
const session: MockGateHandlerSession = {
|
|
86
|
-
logger: overrides.logger ?? {
|
|
87
|
-
debug: vi.fn(),
|
|
88
|
-
review: vi.fn(),
|
|
89
|
-
warn: vi.fn(),
|
|
90
|
-
},
|
|
91
|
-
activate: overrides.activate ?? vi.fn<MockGateHandlerSession["activate"]>(),
|
|
92
|
-
resolveAgentName:
|
|
93
|
-
overrides.resolveAgentName ??
|
|
94
|
-
vi.fn<MockGateHandlerSession["resolveAgentName"]>().mockReturnValue(null),
|
|
95
|
-
checkPermission: overrides.checkPermission ?? makeCheckPermission("deny"),
|
|
96
|
-
getSessionRuleset:
|
|
97
|
-
overrides.getSessionRuleset ??
|
|
98
|
-
vi.fn<MockGateHandlerSession["getSessionRuleset"]>().mockReturnValue([]),
|
|
99
|
-
recordSessionApproval:
|
|
100
|
-
overrides.recordSessionApproval ??
|
|
101
|
-
vi.fn<MockGateHandlerSession["recordSessionApproval"]>(),
|
|
102
|
-
getActiveSkillEntries:
|
|
103
|
-
overrides.getActiveSkillEntries ??
|
|
104
|
-
vi
|
|
105
|
-
.fn<MockGateHandlerSession["getActiveSkillEntries"]>()
|
|
106
|
-
.mockReturnValue([]),
|
|
107
|
-
getInfrastructureReadDirs:
|
|
108
|
-
overrides.getInfrastructureReadDirs ??
|
|
109
|
-
vi
|
|
110
|
-
.fn<MockGateHandlerSession["getInfrastructureReadDirs"]>()
|
|
111
|
-
.mockReturnValue([]),
|
|
112
|
-
getToolPreviewLimits:
|
|
113
|
-
overrides.getToolPreviewLimits ??
|
|
114
|
-
vi
|
|
115
|
-
.fn<MockGateHandlerSession["getToolPreviewLimits"]>()
|
|
116
|
-
.mockReturnValue(resolveToolPreviewLimits(DEFAULT_EXTENSION_CONFIG)),
|
|
117
|
-
canPrompt:
|
|
118
|
-
overrides.canPrompt ??
|
|
119
|
-
vi.fn<MockGateHandlerSession["canPrompt"]>().mockReturnValue(true),
|
|
120
|
-
prompt:
|
|
121
|
-
overrides.prompt ??
|
|
122
|
-
vi
|
|
123
|
-
.fn<MockGateHandlerSession["prompt"]>()
|
|
124
|
-
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
125
|
-
// Delegations — closures read `session` at call time so overrides win.
|
|
126
|
-
resolve:
|
|
127
|
-
overrides.resolve ??
|
|
128
|
-
vi.fn<MockGateHandlerSession["resolve"]>((surface, input, agentName) =>
|
|
129
|
-
session.checkPermission(
|
|
130
|
-
surface,
|
|
131
|
-
input,
|
|
132
|
-
agentName,
|
|
133
|
-
session.getSessionRuleset(),
|
|
134
|
-
),
|
|
135
|
-
),
|
|
136
|
-
canConfirm:
|
|
137
|
-
overrides.canConfirm ??
|
|
138
|
-
vi.fn<MockGateHandlerSession["canConfirm"]>(() =>
|
|
139
|
-
session.canPrompt(undefined as unknown as ExtensionContext),
|
|
140
|
-
),
|
|
141
|
-
promptPermission:
|
|
142
|
-
overrides.promptPermission ??
|
|
143
|
-
vi.fn<MockGateHandlerSession["promptPermission"]>((details) =>
|
|
144
|
-
session.prompt(undefined as unknown as ExtensionContext, details),
|
|
145
|
-
),
|
|
146
|
-
};
|
|
147
|
-
return session;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
38
|
/** All PATH_BEARING_TOOLS members. */
|
|
151
39
|
const ALL_PATH_BEARING_TOOLS = ["read", "write", "edit", "find", "grep", "ls"];
|
|
152
40
|
|
|
153
41
|
/** Tools where path is optional. */
|
|
154
42
|
const OPTIONAL_PATH_TOOLS = ["find", "grep", "ls"];
|
|
155
43
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
getAll: vi
|
|
159
|
-
.fn()
|
|
160
|
-
.mockReturnValue(
|
|
161
|
-
[...ALL_PATH_BEARING_TOOLS, "bash"].map((name) => ({ name })),
|
|
162
|
-
),
|
|
163
|
-
setActive: vi.fn(),
|
|
164
|
-
...overrides,
|
|
165
|
-
};
|
|
166
|
-
}
|
|
44
|
+
/** Full tool set used as the default registry in ext-dir tests. */
|
|
45
|
+
const ALL_TOOLS = [...ALL_PATH_BEARING_TOOLS, "bash"];
|
|
167
46
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
skillInputPipeline,
|
|
188
|
-
runner,
|
|
47
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Builds a `checkPermission` mock for external-directory integration tests.
|
|
51
|
+
*
|
|
52
|
+
* Routes `external_directory` to `externalDirectoryState`, `path` to allow
|
|
53
|
+
* with `source: "special"` (so the cross-cutting path gate is transparent),
|
|
54
|
+
* and every other surface to `toolState` (default: allow).
|
|
55
|
+
*/
|
|
56
|
+
function makeExtDirCheck(
|
|
57
|
+
externalDirectoryState: "allow" | "deny" | "ask",
|
|
58
|
+
toolState: "allow" | "deny" | "ask" = "allow",
|
|
59
|
+
) {
|
|
60
|
+
return makeSurfaceCheck(
|
|
61
|
+
{
|
|
62
|
+
external_directory: { state: externalDirectoryState },
|
|
63
|
+
path: { state: "allow", source: "special" },
|
|
64
|
+
},
|
|
65
|
+
{ state: toolState },
|
|
189
66
|
);
|
|
190
|
-
return { handler, events, session };
|
|
191
67
|
}
|
|
192
68
|
|
|
193
69
|
// ── Regression guard: helper presence ──────────────────────────────────────
|
|
@@ -214,20 +90,22 @@ describe("external_directory helper regression guard", () => {
|
|
|
214
90
|
describe("external_directory path scope", () => {
|
|
215
91
|
it("skips external_directory check when path is inside CWD", async () => {
|
|
216
92
|
const { handler } = makeHandler({
|
|
217
|
-
session: { checkPermission:
|
|
93
|
+
session: { checkPermission: makeExtDirCheck("deny") },
|
|
94
|
+
tools: ALL_TOOLS,
|
|
218
95
|
});
|
|
219
96
|
const event = makeToolCallEvent("read", {
|
|
220
97
|
input: { path: `${CWD}/src/index.ts` },
|
|
221
98
|
});
|
|
222
99
|
const result = await handler.handleToolCall(event, makeCtx());
|
|
223
100
|
// Should not be blocked — the external_directory gate is skipped,
|
|
224
|
-
// and the tool gate sees "allow" (default toolState in
|
|
101
|
+
// and the tool gate sees "allow" (default toolState in makeExtDirCheck)
|
|
225
102
|
expect(result).toEqual({});
|
|
226
103
|
});
|
|
227
104
|
|
|
228
105
|
it("fires external_directory check when path is outside CWD", async () => {
|
|
229
106
|
const { handler } = makeHandler({
|
|
230
|
-
session: { checkPermission:
|
|
107
|
+
session: { checkPermission: makeExtDirCheck("deny") },
|
|
108
|
+
tools: ALL_TOOLS,
|
|
231
109
|
});
|
|
232
110
|
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
|
233
111
|
const result = await handler.handleToolCall(event, makeCtx());
|
|
@@ -236,7 +114,8 @@ describe("external_directory path scope", () => {
|
|
|
236
114
|
|
|
237
115
|
it("skips external_directory check for non-path-bearing tool (bash)", async () => {
|
|
238
116
|
const { handler } = makeHandler({
|
|
239
|
-
session: { checkPermission:
|
|
117
|
+
session: { checkPermission: makeExtDirCheck("deny", "allow") },
|
|
118
|
+
tools: ALL_TOOLS,
|
|
240
119
|
});
|
|
241
120
|
const event = makeToolCallEvent("bash", {
|
|
242
121
|
input: { command: `cat ${EXTERNAL_PATH}` },
|
|
@@ -255,7 +134,8 @@ describe("external_directory path scope", () => {
|
|
|
255
134
|
ALL_PATH_BEARING_TOOLS,
|
|
256
135
|
)("blocks %s with an out-of-cwd path when external_directory is deny", async (toolName) => {
|
|
257
136
|
const { handler } = makeHandler({
|
|
258
|
-
session: { checkPermission:
|
|
137
|
+
session: { checkPermission: makeExtDirCheck("deny") },
|
|
138
|
+
tools: ALL_TOOLS,
|
|
259
139
|
});
|
|
260
140
|
const event = makeToolCallEvent(toolName, {
|
|
261
141
|
input: { path: EXTERNAL_PATH },
|
|
@@ -268,7 +148,8 @@ describe("external_directory path scope", () => {
|
|
|
268
148
|
OPTIONAL_PATH_TOOLS,
|
|
269
149
|
)("skips external_directory check for %s when path is omitted", async (toolName) => {
|
|
270
150
|
const { handler } = makeHandler({
|
|
271
|
-
session: { checkPermission:
|
|
151
|
+
session: { checkPermission: makeExtDirCheck("deny") },
|
|
152
|
+
tools: ALL_TOOLS,
|
|
272
153
|
});
|
|
273
154
|
// No path in input — external_directory gate should not fire
|
|
274
155
|
const event = makeToolCallEvent(toolName);
|
|
@@ -282,7 +163,8 @@ describe("external_directory path scope", () => {
|
|
|
282
163
|
describe("external_directory policy state — allow", () => {
|
|
283
164
|
it("falls through to tool gate when external_directory is allow", async () => {
|
|
284
165
|
const { handler } = makeHandler({
|
|
285
|
-
session: { checkPermission:
|
|
166
|
+
session: { checkPermission: makeExtDirCheck("allow") },
|
|
167
|
+
tools: ALL_TOOLS,
|
|
286
168
|
});
|
|
287
169
|
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
|
288
170
|
const result = await handler.handleToolCall(event, makeCtx());
|
|
@@ -291,7 +173,8 @@ describe("external_directory policy state — allow", () => {
|
|
|
291
173
|
|
|
292
174
|
it("emits decision event with policy_allow on external_directory surface", async () => {
|
|
293
175
|
const { handler, events } = makeHandler({
|
|
294
|
-
session: { checkPermission:
|
|
176
|
+
session: { checkPermission: makeExtDirCheck("allow") },
|
|
177
|
+
tools: ALL_TOOLS,
|
|
295
178
|
});
|
|
296
179
|
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
|
297
180
|
await handler.handleToolCall(event, makeCtx());
|
|
@@ -308,7 +191,8 @@ describe("external_directory policy state — allow", () => {
|
|
|
308
191
|
|
|
309
192
|
it("does not write a block review-log entry when external_directory is allow", async () => {
|
|
310
193
|
const { handler, session } = makeHandler({
|
|
311
|
-
session: { checkPermission:
|
|
194
|
+
session: { checkPermission: makeExtDirCheck("allow") },
|
|
195
|
+
tools: ALL_TOOLS,
|
|
312
196
|
});
|
|
313
197
|
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
|
314
198
|
await handler.handleToolCall(event, makeCtx());
|
|
@@ -325,7 +209,8 @@ describe("external_directory policy state — allow", () => {
|
|
|
325
209
|
describe("external_directory — allow external reads, gate external writes (#144)", () => {
|
|
326
210
|
it("allows read of external path when external_directory and read are both allow", async () => {
|
|
327
211
|
const { handler } = makeHandler({
|
|
328
|
-
session: { checkPermission:
|
|
212
|
+
session: { checkPermission: makeExtDirCheck("allow", "allow") },
|
|
213
|
+
tools: ALL_TOOLS,
|
|
329
214
|
});
|
|
330
215
|
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
|
331
216
|
const result = await handler.handleToolCall(event, makeCtx());
|
|
@@ -338,9 +223,10 @@ describe("external_directory — allow external reads, gate external writes (#14
|
|
|
338
223
|
.mockResolvedValue({ approved: true, state: "approved" });
|
|
339
224
|
const { handler } = makeHandler({
|
|
340
225
|
session: {
|
|
341
|
-
checkPermission:
|
|
226
|
+
checkPermission: makeExtDirCheck("allow", "ask"),
|
|
342
227
|
prompt,
|
|
343
228
|
},
|
|
229
|
+
tools: ALL_TOOLS,
|
|
344
230
|
});
|
|
345
231
|
const event = makeToolCallEvent("write", {
|
|
346
232
|
input: { path: EXTERNAL_PATH },
|
|
@@ -353,7 +239,8 @@ describe("external_directory — allow external reads, gate external writes (#14
|
|
|
353
239
|
|
|
354
240
|
it("blocks write to external path when external_directory allows but write is deny", async () => {
|
|
355
241
|
const { handler } = makeHandler({
|
|
356
|
-
session: { checkPermission:
|
|
242
|
+
session: { checkPermission: makeExtDirCheck("allow", "deny") },
|
|
243
|
+
tools: ALL_TOOLS,
|
|
357
244
|
});
|
|
358
245
|
const event = makeToolCallEvent("write", {
|
|
359
246
|
input: { path: EXTERNAL_PATH },
|
|
@@ -364,7 +251,8 @@ describe("external_directory — allow external reads, gate external writes (#14
|
|
|
364
251
|
|
|
365
252
|
it("emits separate decision events for external_directory and write surfaces", async () => {
|
|
366
253
|
const { handler, events } = makeHandler({
|
|
367
|
-
session: { checkPermission:
|
|
254
|
+
session: { checkPermission: makeExtDirCheck("allow", "deny") },
|
|
255
|
+
tools: ALL_TOOLS,
|
|
368
256
|
});
|
|
369
257
|
const event = makeToolCallEvent("write", {
|
|
370
258
|
input: { path: EXTERNAL_PATH },
|
|
@@ -391,7 +279,8 @@ describe("external_directory — allow external reads, gate external writes (#14
|
|
|
391
279
|
describe("external_directory policy state — deny", () => {
|
|
392
280
|
it("blocks with reason containing the external path", async () => {
|
|
393
281
|
const { handler } = makeHandler({
|
|
394
|
-
session: { checkPermission:
|
|
282
|
+
session: { checkPermission: makeExtDirCheck("deny") },
|
|
283
|
+
tools: ALL_TOOLS,
|
|
395
284
|
});
|
|
396
285
|
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
|
397
286
|
const result = await handler.handleToolCall(event, makeCtx());
|
|
@@ -401,7 +290,8 @@ describe("external_directory policy state — deny", () => {
|
|
|
401
290
|
|
|
402
291
|
it("block reason contains extension attribution", async () => {
|
|
403
292
|
const { handler } = makeHandler({
|
|
404
|
-
session: { checkPermission:
|
|
293
|
+
session: { checkPermission: makeExtDirCheck("deny") },
|
|
294
|
+
tools: ALL_TOOLS,
|
|
405
295
|
});
|
|
406
296
|
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
|
407
297
|
const result = await handler.handleToolCall(event, makeCtx());
|
|
@@ -411,7 +301,8 @@ describe("external_directory policy state — deny", () => {
|
|
|
411
301
|
|
|
412
302
|
it("writes review-log entry with resolution policy_denied", async () => {
|
|
413
303
|
const { handler, session } = makeHandler({
|
|
414
|
-
session: { checkPermission:
|
|
304
|
+
session: { checkPermission: makeExtDirCheck("deny") },
|
|
305
|
+
tools: ALL_TOOLS,
|
|
415
306
|
});
|
|
416
307
|
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
|
417
308
|
await handler.handleToolCall(event, makeCtx());
|
|
@@ -428,7 +319,8 @@ describe("external_directory policy state — deny", () => {
|
|
|
428
319
|
|
|
429
320
|
it("emits decision event with policy_deny on external_directory surface", async () => {
|
|
430
321
|
const { handler, events } = makeHandler({
|
|
431
|
-
session: { checkPermission:
|
|
322
|
+
session: { checkPermission: makeExtDirCheck("deny") },
|
|
323
|
+
tools: ALL_TOOLS,
|
|
432
324
|
});
|
|
433
325
|
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
|
434
326
|
await handler.handleToolCall(event, makeCtx());
|
|
@@ -450,11 +342,12 @@ describe("external_directory policy state — ask", () => {
|
|
|
450
342
|
it("does not block when user approves", async () => {
|
|
451
343
|
const { handler } = makeHandler({
|
|
452
344
|
session: {
|
|
453
|
-
checkPermission:
|
|
345
|
+
checkPermission: makeExtDirCheck("ask"),
|
|
454
346
|
prompt: vi
|
|
455
347
|
.fn()
|
|
456
348
|
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
457
349
|
},
|
|
350
|
+
tools: ALL_TOOLS,
|
|
458
351
|
});
|
|
459
352
|
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
|
460
353
|
const result = await handler.handleToolCall(event, makeCtx());
|
|
@@ -464,11 +357,12 @@ describe("external_directory policy state — ask", () => {
|
|
|
464
357
|
it("emits user_approved decision when user approves", async () => {
|
|
465
358
|
const { handler, events } = makeHandler({
|
|
466
359
|
session: {
|
|
467
|
-
checkPermission:
|
|
360
|
+
checkPermission: makeExtDirCheck("ask"),
|
|
468
361
|
prompt: vi
|
|
469
362
|
.fn()
|
|
470
363
|
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
471
364
|
},
|
|
365
|
+
tools: ALL_TOOLS,
|
|
472
366
|
});
|
|
473
367
|
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
|
474
368
|
await handler.handleToolCall(event, makeCtx());
|
|
@@ -486,9 +380,10 @@ describe("external_directory policy state — ask", () => {
|
|
|
486
380
|
it("blocks when user denies", async () => {
|
|
487
381
|
const { handler } = makeHandler({
|
|
488
382
|
session: {
|
|
489
|
-
checkPermission:
|
|
383
|
+
checkPermission: makeExtDirCheck("ask"),
|
|
490
384
|
prompt: vi.fn().mockResolvedValue({ approved: false, state: "denied" }),
|
|
491
385
|
},
|
|
386
|
+
tools: ALL_TOOLS,
|
|
492
387
|
});
|
|
493
388
|
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
|
494
389
|
const result = await handler.handleToolCall(event, makeCtx());
|
|
@@ -498,9 +393,10 @@ describe("external_directory policy state — ask", () => {
|
|
|
498
393
|
it("emits user_denied decision when user denies", async () => {
|
|
499
394
|
const { handler, events } = makeHandler({
|
|
500
395
|
session: {
|
|
501
|
-
checkPermission:
|
|
396
|
+
checkPermission: makeExtDirCheck("ask"),
|
|
502
397
|
prompt: vi.fn().mockResolvedValue({ approved: false, state: "denied" }),
|
|
503
398
|
},
|
|
399
|
+
tools: ALL_TOOLS,
|
|
504
400
|
});
|
|
505
401
|
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
|
506
402
|
await handler.handleToolCall(event, makeCtx());
|
|
@@ -518,13 +414,14 @@ describe("external_directory policy state — ask", () => {
|
|
|
518
414
|
it("block reason includes denialReason when user provides one", async () => {
|
|
519
415
|
const { handler } = makeHandler({
|
|
520
416
|
session: {
|
|
521
|
-
checkPermission:
|
|
417
|
+
checkPermission: makeExtDirCheck("ask"),
|
|
522
418
|
prompt: vi.fn().mockResolvedValue({
|
|
523
419
|
approved: false,
|
|
524
420
|
state: "denied",
|
|
525
421
|
denialReason: "not needed",
|
|
526
422
|
}),
|
|
527
423
|
},
|
|
424
|
+
tools: ALL_TOOLS,
|
|
528
425
|
});
|
|
529
426
|
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
|
530
427
|
const result = await handler.handleToolCall(event, makeCtx());
|
|
@@ -535,9 +432,10 @@ describe("external_directory policy state — ask", () => {
|
|
|
535
432
|
it("blocks with confirmation_unavailable when no UI is available", async () => {
|
|
536
433
|
const { handler } = makeHandler({
|
|
537
434
|
session: {
|
|
538
|
-
checkPermission:
|
|
435
|
+
checkPermission: makeExtDirCheck("ask"),
|
|
539
436
|
canPrompt: vi.fn().mockReturnValue(false),
|
|
540
437
|
},
|
|
438
|
+
tools: ALL_TOOLS,
|
|
541
439
|
});
|
|
542
440
|
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
|
543
441
|
const result = await handler.handleToolCall(
|
|
@@ -551,9 +449,10 @@ describe("external_directory policy state — ask", () => {
|
|
|
551
449
|
it("writes review-log entry with confirmation_unavailable when no UI", async () => {
|
|
552
450
|
const { handler, session } = makeHandler({
|
|
553
451
|
session: {
|
|
554
|
-
checkPermission:
|
|
452
|
+
checkPermission: makeExtDirCheck("ask"),
|
|
555
453
|
canPrompt: vi.fn().mockReturnValue(false),
|
|
556
454
|
},
|
|
455
|
+
tools: ALL_TOOLS,
|
|
557
456
|
});
|
|
558
457
|
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
|
559
458
|
await handler.handleToolCall(event, makeCtx({ hasUI: false }));
|
|
@@ -571,9 +470,10 @@ describe("external_directory policy state — ask", () => {
|
|
|
571
470
|
it("emits confirmation_unavailable decision when no UI", async () => {
|
|
572
471
|
const { handler, events } = makeHandler({
|
|
573
472
|
session: {
|
|
574
|
-
checkPermission:
|
|
473
|
+
checkPermission: makeExtDirCheck("ask"),
|
|
575
474
|
canPrompt: vi.fn().mockReturnValue(false),
|
|
576
475
|
},
|
|
476
|
+
tools: ALL_TOOLS,
|
|
577
477
|
});
|
|
578
478
|
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
|
579
479
|
await handler.handleToolCall(event, makeCtx({ hasUI: false }));
|
|
@@ -627,6 +527,7 @@ describe("external_directory per-agent override", () => {
|
|
|
627
527
|
checkPermission: agentAwareCheck,
|
|
628
528
|
resolveAgentName: vi.fn().mockReturnValue("special-agent"),
|
|
629
529
|
},
|
|
530
|
+
tools: ALL_TOOLS,
|
|
630
531
|
});
|
|
631
532
|
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
|
632
533
|
const result1 = await handler1.handleToolCall(event, makeCtx());
|
|
@@ -646,6 +547,7 @@ describe("external_directory per-agent override", () => {
|
|
|
646
547
|
checkPermission: agentAwareCheck,
|
|
647
548
|
resolveAgentName: vi.fn().mockReturnValue(null),
|
|
648
549
|
},
|
|
550
|
+
tools: ALL_TOOLS,
|
|
649
551
|
});
|
|
650
552
|
const result2 = await handler2.handleToolCall(event, makeCtx());
|
|
651
553
|
expect(result2).toMatchObject({ block: true });
|
|
@@ -657,7 +559,8 @@ describe("external_directory per-agent override", () => {
|
|
|
657
559
|
describe("external_directory decision event fields", () => {
|
|
658
560
|
it("decision event value is the external path", async () => {
|
|
659
561
|
const { handler, events } = makeHandler({
|
|
660
|
-
session: { checkPermission:
|
|
562
|
+
session: { checkPermission: makeExtDirCheck("deny") },
|
|
563
|
+
tools: ALL_TOOLS,
|
|
661
564
|
});
|
|
662
565
|
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
|
663
566
|
await handler.handleToolCall(event, makeCtx());
|
|
@@ -672,9 +575,10 @@ describe("external_directory decision event fields", () => {
|
|
|
672
575
|
it("decision event includes agentName when present", async () => {
|
|
673
576
|
const { handler, events } = makeHandler({
|
|
674
577
|
session: {
|
|
675
|
-
checkPermission:
|
|
578
|
+
checkPermission: makeExtDirCheck("allow"),
|
|
676
579
|
resolveAgentName: vi.fn().mockReturnValue("my-agent"),
|
|
677
580
|
},
|
|
581
|
+
tools: ALL_TOOLS,
|
|
678
582
|
});
|
|
679
583
|
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
|
680
584
|
await handler.handleToolCall(event, makeCtx());
|
|
@@ -689,7 +593,8 @@ describe("external_directory decision event fields", () => {
|
|
|
689
593
|
|
|
690
594
|
it("decision event agentName is null when no agent", async () => {
|
|
691
595
|
const { handler, events } = makeHandler({
|
|
692
|
-
session: { checkPermission:
|
|
596
|
+
session: { checkPermission: makeExtDirCheck("allow") },
|
|
597
|
+
tools: ALL_TOOLS,
|
|
693
598
|
});
|
|
694
599
|
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
|
695
600
|
await handler.handleToolCall(event, makeCtx());
|
|
@@ -23,6 +23,7 @@ import type { PermissionResolver } from "#src/permission-resolver";
|
|
|
23
23
|
|
|
24
24
|
import {
|
|
25
25
|
makeGateCheckResult as makeCheckResult,
|
|
26
|
+
makePathDispatchResolver,
|
|
26
27
|
makeResolver,
|
|
27
28
|
makeTcc,
|
|
28
29
|
} from "#test/helpers/gate-fixtures";
|
|
@@ -69,7 +70,7 @@ describe("describeBashPathGate", () => {
|
|
|
69
70
|
|
|
70
71
|
it("returns null when all tokens evaluate to allow", async () => {
|
|
71
72
|
const result = await describeGate(
|
|
72
|
-
makeTcc(
|
|
73
|
+
makeTcc(),
|
|
73
74
|
makeResolver(makeCheckResult({ state: "allow" })),
|
|
74
75
|
);
|
|
75
76
|
expect(result).toBeNull();
|
|
@@ -77,7 +78,7 @@ describe("describeBashPathGate", () => {
|
|
|
77
78
|
|
|
78
79
|
it("returns GateDescriptor when a token evaluates to deny", async () => {
|
|
79
80
|
const result = await describeGate(
|
|
80
|
-
makeTcc(
|
|
81
|
+
makeTcc(),
|
|
81
82
|
makeResolver(makeCheckResult({ state: "deny", matchedPattern: "*.env" })),
|
|
82
83
|
);
|
|
83
84
|
expect(result).not.toBeNull();
|
|
@@ -89,7 +90,7 @@ describe("describeBashPathGate", () => {
|
|
|
89
90
|
|
|
90
91
|
it("returns GateDescriptor when a token evaluates to ask", async () => {
|
|
91
92
|
const result = await describeGate(
|
|
92
|
-
makeTcc(
|
|
93
|
+
makeTcc(),
|
|
93
94
|
makeResolver(makeCheckResult({ state: "ask", matchedPattern: "*" })),
|
|
94
95
|
);
|
|
95
96
|
expect(result).not.toBeNull();
|
|
@@ -100,7 +101,7 @@ describe("describeBashPathGate", () => {
|
|
|
100
101
|
|
|
101
102
|
it("descriptor includes triggering token in prompt message", async () => {
|
|
102
103
|
const result = (await describeGate(
|
|
103
|
-
makeTcc(
|
|
104
|
+
makeTcc(),
|
|
104
105
|
makeResolver(makeCheckResult({ state: "deny", matchedPattern: "*.env" })),
|
|
105
106
|
)) as GateDescriptor;
|
|
106
107
|
expect(result.denialContext).toMatchObject({
|
|
@@ -113,7 +114,7 @@ describe("describeBashPathGate", () => {
|
|
|
113
114
|
|
|
114
115
|
it("descriptor decision uses surface 'path'", async () => {
|
|
115
116
|
const result = (await describeGate(
|
|
116
|
-
makeTcc(
|
|
117
|
+
makeTcc(),
|
|
117
118
|
makeResolver(makeCheckResult({ state: "deny", matchedPattern: "*.env" })),
|
|
118
119
|
)) as GateDescriptor;
|
|
119
120
|
expect(result.decision.surface).toBe("path");
|
|
@@ -121,7 +122,7 @@ describe("describeBashPathGate", () => {
|
|
|
121
122
|
|
|
122
123
|
it("returns GateBypass when session rule covers the path", async () => {
|
|
123
124
|
const result = await describeGate(
|
|
124
|
-
makeTcc(
|
|
125
|
+
makeTcc(),
|
|
125
126
|
makeResolver(makeCheckResult({ state: "allow", source: "session" })),
|
|
126
127
|
);
|
|
127
128
|
expect(result).not.toBeNull();
|
|
@@ -135,14 +136,10 @@ describe("describeBashPathGate", () => {
|
|
|
135
136
|
});
|
|
136
137
|
|
|
137
138
|
it("evaluates most restrictive across multiple tokens", async () => {
|
|
138
|
-
const resolver =
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
return makeCheckResult({ state: "allow" });
|
|
143
|
-
}
|
|
144
|
-
return makeCheckResult({ state: "deny", matchedPattern: "*.env" });
|
|
145
|
-
});
|
|
139
|
+
const resolver = makePathDispatchResolver(
|
|
140
|
+
{ "src/foo.ts": makeCheckResult({ state: "allow" }) },
|
|
141
|
+
makeCheckResult({ state: "deny", matchedPattern: "*.env" }),
|
|
142
|
+
);
|
|
146
143
|
const result = await describeGate(
|
|
147
144
|
makeTcc({ input: { command: "cat src/foo.ts .env" } }),
|
|
148
145
|
resolver,
|
|
@@ -153,14 +150,10 @@ describe("describeBashPathGate", () => {
|
|
|
153
150
|
});
|
|
154
151
|
|
|
155
152
|
it("deny wins in multi-token: cp .env README.md", async () => {
|
|
156
|
-
const resolver =
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
return makeCheckResult({ state: "deny", matchedPattern: "*.env" });
|
|
161
|
-
}
|
|
162
|
-
return makeCheckResult({ state: "allow" });
|
|
163
|
-
});
|
|
153
|
+
const resolver = makePathDispatchResolver(
|
|
154
|
+
{ ".env": makeCheckResult({ state: "deny", matchedPattern: "*.env" }) },
|
|
155
|
+
makeCheckResult({ state: "allow" }),
|
|
156
|
+
);
|
|
164
157
|
const result = await describeGate(
|
|
165
158
|
makeTcc({ input: { command: "cp .env README.md" } }),
|
|
166
159
|
resolver,
|
|
@@ -173,14 +166,10 @@ describe("describeBashPathGate", () => {
|
|
|
173
166
|
});
|
|
174
167
|
|
|
175
168
|
it("extracts redirect target: echo test > .env triggers deny", async () => {
|
|
176
|
-
const resolver =
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
return makeCheckResult({ state: "deny", matchedPattern: "*.env" });
|
|
181
|
-
}
|
|
182
|
-
return makeCheckResult({ state: "allow" });
|
|
183
|
-
});
|
|
169
|
+
const resolver = makePathDispatchResolver(
|
|
170
|
+
{ ".env": makeCheckResult({ state: "deny", matchedPattern: "*.env" }) },
|
|
171
|
+
makeCheckResult({ state: "allow" }),
|
|
172
|
+
);
|
|
184
173
|
const result = await describeGate(
|
|
185
174
|
makeTcc({ input: { command: "echo test > .env" } }),
|
|
186
175
|
resolver,
|
|
@@ -192,7 +181,7 @@ describe("describeBashPathGate", () => {
|
|
|
192
181
|
|
|
193
182
|
it("returns null when all tokens match only the universal default", async () => {
|
|
194
183
|
const result = await describeGate(
|
|
195
|
-
makeTcc(
|
|
184
|
+
makeTcc(),
|
|
196
185
|
makeResolver(
|
|
197
186
|
makeCheckResult({
|
|
198
187
|
state: "ask",
|
|
@@ -206,23 +195,16 @@ describe("describeBashPathGate", () => {
|
|
|
206
195
|
});
|
|
207
196
|
|
|
208
197
|
it("ignores tokens matching universal default but fires for explicit rule matches", async () => {
|
|
209
|
-
const resolver =
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
return makeCheckResult({
|
|
214
|
-
state: "deny",
|
|
215
|
-
matchedPattern: "*.env",
|
|
216
|
-
});
|
|
217
|
-
}
|
|
218
|
-
// Other tokens match only the universal default
|
|
219
|
-
return makeCheckResult({
|
|
198
|
+
const resolver = makePathDispatchResolver(
|
|
199
|
+
{ ".env": makeCheckResult({ state: "deny", matchedPattern: "*.env" }) },
|
|
200
|
+
// Other tokens match only the universal default (no matchedPattern)
|
|
201
|
+
makeCheckResult({
|
|
220
202
|
state: "ask",
|
|
221
203
|
matchedPattern: undefined,
|
|
222
204
|
source: "special",
|
|
223
205
|
origin: "builtin",
|
|
224
|
-
})
|
|
225
|
-
|
|
206
|
+
}),
|
|
207
|
+
);
|
|
226
208
|
const result = await describeGate(
|
|
227
209
|
makeTcc({ input: { command: "cat src/foo.ts .env" } }),
|
|
228
210
|
resolver,
|