@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
|
@@ -2,12 +2,13 @@ import { describe, expect, it, vi } from "vitest";
|
|
|
2
2
|
|
|
3
3
|
import type { DenialContext } from "#src/denial-messages";
|
|
4
4
|
import { EXTENSION_TAG } from "#src/denial-messages";
|
|
5
|
-
import type {
|
|
6
|
-
GateBypass,
|
|
7
|
-
GateDescriptor,
|
|
8
|
-
} from "#src/handlers/gates/descriptor";
|
|
5
|
+
import type { GateBypass } from "#src/handlers/gates/descriptor";
|
|
9
6
|
import { SessionApproval } from "#src/session-approval";
|
|
10
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
makeDenialDescriptor,
|
|
9
|
+
makeDescriptor,
|
|
10
|
+
makeGateRunner,
|
|
11
|
+
} from "#test/helpers/gate-fixtures";
|
|
11
12
|
import { makeCheckResult } from "#test/helpers/handler-fixtures";
|
|
12
13
|
|
|
13
14
|
// ── GateRunner — descriptor path ───────────────────────────────────────────
|
|
@@ -29,11 +30,7 @@ describe("GateRunner — descriptor path", () => {
|
|
|
29
30
|
|
|
30
31
|
it("returns block and emits policy_deny when policy is deny", async () => {
|
|
31
32
|
const { runner, deps } = makeGateRunner({
|
|
32
|
-
|
|
33
|
-
.fn()
|
|
34
|
-
.mockReturnValue(
|
|
35
|
-
makeCheckResult({ state: "deny", matchedPattern: "*" }),
|
|
36
|
-
),
|
|
33
|
+
resolveResult: makeCheckResult({ state: "deny", matchedPattern: "*" }),
|
|
37
34
|
});
|
|
38
35
|
const result = await runner.run(makeDescriptor(), null, "tc-1");
|
|
39
36
|
expect(result).toMatchObject({ action: "block" });
|
|
@@ -51,11 +48,10 @@ describe("GateRunner — descriptor path", () => {
|
|
|
51
48
|
|
|
52
49
|
it("returns allow and emits session_approved on session hit", async () => {
|
|
53
50
|
const { runner, deps } = makeGateRunner({
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
),
|
|
51
|
+
resolveResult: makeCheckResult({
|
|
52
|
+
source: "session",
|
|
53
|
+
matchedPattern: "git *",
|
|
54
|
+
}),
|
|
59
55
|
});
|
|
60
56
|
const result = await runner.run(
|
|
61
57
|
makeDescriptor({
|
|
@@ -84,11 +80,7 @@ describe("GateRunner — descriptor path", () => {
|
|
|
84
80
|
|
|
85
81
|
it("returns allow and emits user_approved when ask + user approves", async () => {
|
|
86
82
|
const { runner, deps } = makeGateRunner({
|
|
87
|
-
|
|
88
|
-
.fn()
|
|
89
|
-
.mockReturnValue(
|
|
90
|
-
makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
91
|
-
),
|
|
83
|
+
resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
92
84
|
promptPermission: vi
|
|
93
85
|
.fn()
|
|
94
86
|
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
@@ -105,11 +97,7 @@ describe("GateRunner — descriptor path", () => {
|
|
|
105
97
|
|
|
106
98
|
it("returns allow, emits user_approved_for_session, and records session rule on approved_for_session", async () => {
|
|
107
99
|
const { runner, deps } = makeGateRunner({
|
|
108
|
-
|
|
109
|
-
.fn()
|
|
110
|
-
.mockReturnValue(
|
|
111
|
-
makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
112
|
-
),
|
|
100
|
+
resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
113
101
|
promptPermission: vi
|
|
114
102
|
.fn()
|
|
115
103
|
.mockResolvedValue({ approved: true, state: "approved_for_session" }),
|
|
@@ -131,11 +119,7 @@ describe("GateRunner — descriptor path", () => {
|
|
|
131
119
|
|
|
132
120
|
it("calls recordSessionApproval once with the full SessionApproval when sessionApproval has multiple patterns", async () => {
|
|
133
121
|
const { runner, deps } = makeGateRunner({
|
|
134
|
-
|
|
135
|
-
.fn()
|
|
136
|
-
.mockReturnValue(
|
|
137
|
-
makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
138
|
-
),
|
|
122
|
+
resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
139
123
|
promptPermission: vi
|
|
140
124
|
.fn()
|
|
141
125
|
.mockResolvedValue({ approved: true, state: "approved_for_session" }),
|
|
@@ -153,11 +137,7 @@ describe("GateRunner — descriptor path", () => {
|
|
|
153
137
|
|
|
154
138
|
it("returns block and emits user_denied when ask + user denies", async () => {
|
|
155
139
|
const { runner, deps } = makeGateRunner({
|
|
156
|
-
|
|
157
|
-
.fn()
|
|
158
|
-
.mockReturnValue(
|
|
159
|
-
makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
160
|
-
),
|
|
140
|
+
resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
161
141
|
promptPermission: vi
|
|
162
142
|
.fn()
|
|
163
143
|
.mockResolvedValue({ approved: false, state: "denied" }),
|
|
@@ -174,11 +154,7 @@ describe("GateRunner — descriptor path", () => {
|
|
|
174
154
|
|
|
175
155
|
it("returns block and emits confirmation_unavailable when ask + no UI", async () => {
|
|
176
156
|
const { runner, deps } = makeGateRunner({
|
|
177
|
-
|
|
178
|
-
.fn()
|
|
179
|
-
.mockReturnValue(
|
|
180
|
-
makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
181
|
-
),
|
|
157
|
+
resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
182
158
|
canConfirm: vi.fn().mockReturnValue(false),
|
|
183
159
|
});
|
|
184
160
|
const result = await runner.run(makeDescriptor(), null, "tc-1");
|
|
@@ -193,11 +169,7 @@ describe("GateRunner — descriptor path", () => {
|
|
|
193
169
|
|
|
194
170
|
it("emits auto_approved resolution when decision has autoApproved flag", async () => {
|
|
195
171
|
const { runner, deps } = makeGateRunner({
|
|
196
|
-
|
|
197
|
-
.fn()
|
|
198
|
-
.mockReturnValue(
|
|
199
|
-
makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
200
|
-
),
|
|
172
|
+
resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
201
173
|
promptPermission: vi.fn().mockResolvedValue({
|
|
202
174
|
approved: true,
|
|
203
175
|
state: "approved",
|
|
@@ -257,11 +229,7 @@ describe("GateRunner — descriptor path", () => {
|
|
|
257
229
|
|
|
258
230
|
it("passes requestId from toolCallId to promptPermission", async () => {
|
|
259
231
|
const { runner, deps } = makeGateRunner({
|
|
260
|
-
|
|
261
|
-
.fn()
|
|
262
|
-
.mockReturnValue(
|
|
263
|
-
makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
264
|
-
),
|
|
232
|
+
resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
265
233
|
});
|
|
266
234
|
await runner.run(makeDescriptor(), null, "tc-42");
|
|
267
235
|
expect(deps.promptPermission).toHaveBeenCalledWith(
|
|
@@ -271,11 +239,7 @@ describe("GateRunner — descriptor path", () => {
|
|
|
271
239
|
|
|
272
240
|
it("does not call recordSessionApproval when user approves once (no sessionApproval)", async () => {
|
|
273
241
|
const { runner, deps } = makeGateRunner({
|
|
274
|
-
|
|
275
|
-
.fn()
|
|
276
|
-
.mockReturnValue(
|
|
277
|
-
makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
278
|
-
),
|
|
242
|
+
resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
279
243
|
promptPermission: vi
|
|
280
244
|
.fn()
|
|
281
245
|
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
@@ -307,11 +271,7 @@ describe("GateRunner — descriptor path", () => {
|
|
|
307
271
|
|
|
308
272
|
it("does not call recordSessionApproval when user approves for session but no sessionApproval on descriptor", async () => {
|
|
309
273
|
const { runner, deps } = makeGateRunner({
|
|
310
|
-
|
|
311
|
-
.fn()
|
|
312
|
-
.mockReturnValue(
|
|
313
|
-
makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
314
|
-
),
|
|
274
|
+
resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
315
275
|
promptPermission: vi
|
|
316
276
|
.fn()
|
|
317
277
|
.mockResolvedValue({ approved: true, state: "approved_for_session" }),
|
|
@@ -322,41 +282,9 @@ describe("GateRunner — descriptor path", () => {
|
|
|
322
282
|
});
|
|
323
283
|
|
|
324
284
|
describe("denialContext formatting", () => {
|
|
325
|
-
function makeDenialContextDescriptor(
|
|
326
|
-
denialContext: DenialContext,
|
|
327
|
-
overrides: Partial<GateDescriptor> = {},
|
|
328
|
-
): GateDescriptor {
|
|
329
|
-
return {
|
|
330
|
-
surface: "write",
|
|
331
|
-
input: {},
|
|
332
|
-
denialContext,
|
|
333
|
-
promptDetails: {
|
|
334
|
-
source: "tool_call",
|
|
335
|
-
agentName: null,
|
|
336
|
-
message: "Allow tool 'write'?",
|
|
337
|
-
toolCallId: "tc-1",
|
|
338
|
-
toolName: "write",
|
|
339
|
-
},
|
|
340
|
-
logContext: {
|
|
341
|
-
source: "tool_call",
|
|
342
|
-
toolCallId: "tc-1",
|
|
343
|
-
toolName: "write",
|
|
344
|
-
},
|
|
345
|
-
decision: {
|
|
346
|
-
surface: "write",
|
|
347
|
-
value: "write",
|
|
348
|
-
},
|
|
349
|
-
...overrides,
|
|
350
|
-
};
|
|
351
|
-
}
|
|
352
|
-
|
|
353
285
|
it("uses denialContext to format denyReason with extension tag", async () => {
|
|
354
286
|
const { runner } = makeGateRunner({
|
|
355
|
-
|
|
356
|
-
.fn()
|
|
357
|
-
.mockReturnValue(
|
|
358
|
-
makeCheckResult({ state: "deny", matchedPattern: "*" }),
|
|
359
|
-
),
|
|
287
|
+
resolveResult: makeCheckResult({ state: "deny", matchedPattern: "*" }),
|
|
360
288
|
});
|
|
361
289
|
const ctx: DenialContext = {
|
|
362
290
|
kind: "tool",
|
|
@@ -364,7 +292,7 @@ describe("GateRunner — descriptor path", () => {
|
|
|
364
292
|
agentName: "test-agent",
|
|
365
293
|
};
|
|
366
294
|
const result = await runner.run(
|
|
367
|
-
|
|
295
|
+
makeDenialDescriptor(ctx),
|
|
368
296
|
"test-agent",
|
|
369
297
|
"tc-1",
|
|
370
298
|
);
|
|
@@ -377,22 +305,14 @@ describe("GateRunner — descriptor path", () => {
|
|
|
377
305
|
|
|
378
306
|
it("uses denialContext to format unavailableReason with extension tag", async () => {
|
|
379
307
|
const { runner } = makeGateRunner({
|
|
380
|
-
|
|
381
|
-
.fn()
|
|
382
|
-
.mockReturnValue(
|
|
383
|
-
makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
384
|
-
),
|
|
308
|
+
resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
385
309
|
canConfirm: vi.fn().mockReturnValue(false),
|
|
386
310
|
});
|
|
387
311
|
const ctx: DenialContext = {
|
|
388
312
|
kind: "tool",
|
|
389
313
|
check: makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
390
314
|
};
|
|
391
|
-
const result = await runner.run(
|
|
392
|
-
makeDenialContextDescriptor(ctx),
|
|
393
|
-
null,
|
|
394
|
-
"tc-1",
|
|
395
|
-
);
|
|
315
|
+
const result = await runner.run(makeDenialDescriptor(ctx), null, "tc-1");
|
|
396
316
|
expect(result.action).toBe("block");
|
|
397
317
|
if (result.action === "block") {
|
|
398
318
|
expect(result.reason).toContain(EXTENSION_TAG);
|
|
@@ -402,11 +322,7 @@ describe("GateRunner — descriptor path", () => {
|
|
|
402
322
|
|
|
403
323
|
it("uses denialContext to format userDeniedReason with extension tag", async () => {
|
|
404
324
|
const { runner } = makeGateRunner({
|
|
405
|
-
|
|
406
|
-
.fn()
|
|
407
|
-
.mockReturnValue(
|
|
408
|
-
makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
409
|
-
),
|
|
325
|
+
resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
410
326
|
promptPermission: vi.fn().mockResolvedValue({
|
|
411
327
|
approved: false,
|
|
412
328
|
state: "denied",
|
|
@@ -417,11 +333,7 @@ describe("GateRunner — descriptor path", () => {
|
|
|
417
333
|
kind: "tool",
|
|
418
334
|
check: makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
419
335
|
};
|
|
420
|
-
const result = await runner.run(
|
|
421
|
-
makeDenialContextDescriptor(ctx),
|
|
422
|
-
null,
|
|
423
|
-
"tc-1",
|
|
424
|
-
);
|
|
336
|
+
const result = await runner.run(makeDenialDescriptor(ctx), null, "tc-1");
|
|
425
337
|
expect(result.action).toBe("block");
|
|
426
338
|
if (result.action === "block") {
|
|
427
339
|
expect(result.reason).toContain(EXTENSION_TAG);
|
|
@@ -489,11 +401,7 @@ describe("GateRunner.run — null and bypass dispatch", () => {
|
|
|
489
401
|
|
|
490
402
|
it("routes a descriptor to the gate check logic and returns block", async () => {
|
|
491
403
|
const { runner } = makeGateRunner({
|
|
492
|
-
|
|
493
|
-
.fn()
|
|
494
|
-
.mockReturnValue(
|
|
495
|
-
makeCheckResult({ state: "deny", matchedPattern: "*" }),
|
|
496
|
-
),
|
|
404
|
+
resolveResult: makeCheckResult({ state: "deny", matchedPattern: "*" }),
|
|
497
405
|
});
|
|
498
406
|
const result = await runner.run(makeDescriptor(), null, "tc-1");
|
|
499
407
|
expect(result).toMatchObject({ action: "block" });
|
|
@@ -3,9 +3,11 @@ import { describe, expect, it, vi } from "vitest";
|
|
|
3
3
|
import { getEventInput } from "#src/handlers/permission-gate-handler";
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
|
+
makeBashCommandCheck,
|
|
6
7
|
makeCheckResult,
|
|
7
8
|
makeCtx,
|
|
8
9
|
makeHandler,
|
|
10
|
+
makeSurfaceCheck,
|
|
9
11
|
makeToolCallEvent,
|
|
10
12
|
} from "#test/helpers/handler-fixtures";
|
|
11
13
|
|
|
@@ -65,11 +67,7 @@ describe("handleToolCall", () => {
|
|
|
65
67
|
});
|
|
66
68
|
|
|
67
69
|
it("blocks when tool is not registered", async () => {
|
|
68
|
-
const { handler } = makeHandler({
|
|
69
|
-
toolRegistry: {
|
|
70
|
-
getAll: vi.fn().mockReturnValue([{ name: "read" }]),
|
|
71
|
-
},
|
|
72
|
-
});
|
|
70
|
+
const { handler } = makeHandler({ tools: ["read"] });
|
|
73
71
|
const result = await handler.handleToolCall(
|
|
74
72
|
makeToolCallEvent("unknown-tool"),
|
|
75
73
|
makeCtx(),
|
|
@@ -170,16 +168,11 @@ describe("handleToolCall — external-directory gate", () => {
|
|
|
170
168
|
.fn()
|
|
171
169
|
.mockReturnValue(makeCheckResult({ state: "deny" })),
|
|
172
170
|
},
|
|
173
|
-
|
|
174
|
-
getAll: vi.fn().mockReturnValue([{ name: "read" }]),
|
|
175
|
-
},
|
|
171
|
+
tools: ["read"],
|
|
176
172
|
});
|
|
177
|
-
const event = {
|
|
178
|
-
type: "tool_call",
|
|
179
|
-
toolCallId: "tc-ext",
|
|
180
|
-
name: "read",
|
|
173
|
+
const event = makeToolCallEvent("read", {
|
|
181
174
|
input: { path: "/outside/project/file.ts" },
|
|
182
|
-
};
|
|
175
|
+
});
|
|
183
176
|
const result = await handler.handleToolCall(event, makeCtx());
|
|
184
177
|
expect(result).toMatchObject({ block: true });
|
|
185
178
|
});
|
|
@@ -195,16 +188,11 @@ describe("handleToolCall — bash external-directory gate", () => {
|
|
|
195
188
|
.fn()
|
|
196
189
|
.mockReturnValue(makeCheckResult({ state: "deny" })),
|
|
197
190
|
},
|
|
198
|
-
|
|
199
|
-
getAll: vi.fn().mockReturnValue([{ name: "bash" }]),
|
|
200
|
-
},
|
|
191
|
+
tools: ["bash"],
|
|
201
192
|
});
|
|
202
|
-
const event = {
|
|
203
|
-
type: "tool_call",
|
|
204
|
-
toolCallId: "tc-bash-ext",
|
|
205
|
-
name: "bash",
|
|
193
|
+
const event = makeToolCallEvent("bash", {
|
|
206
194
|
input: { command: "cat /outside/project/file.ts" },
|
|
207
|
-
};
|
|
195
|
+
});
|
|
208
196
|
const result = await handler.handleToolCall(event, makeCtx());
|
|
209
197
|
expect(result).toMatchObject({ block: true });
|
|
210
198
|
});
|
|
@@ -214,44 +202,24 @@ describe("handleToolCall — bash external-directory gate", () => {
|
|
|
214
202
|
|
|
215
203
|
describe("handleToolCall — path gate (tools)", () => {
|
|
216
204
|
it("blocks a read of .env when path surface denies *.env", async () => {
|
|
217
|
-
const checkPermission = vi
|
|
218
|
-
.fn()
|
|
219
|
-
.mockImplementation(
|
|
220
|
-
(surface: string, _input: unknown, _agentName?: string) => {
|
|
221
|
-
if (surface === "path") {
|
|
222
|
-
return makeCheckResult({ state: "deny", matchedPattern: "*.env" });
|
|
223
|
-
}
|
|
224
|
-
return makeCheckResult();
|
|
225
|
-
},
|
|
226
|
-
);
|
|
227
205
|
const { handler } = makeHandler({
|
|
228
|
-
session: {
|
|
229
|
-
|
|
230
|
-
|
|
206
|
+
session: {
|
|
207
|
+
checkPermission: makeSurfaceCheck({
|
|
208
|
+
path: { state: "deny", matchedPattern: "*.env" },
|
|
209
|
+
}),
|
|
231
210
|
},
|
|
211
|
+
tools: ["read"],
|
|
232
212
|
});
|
|
233
|
-
const event = {
|
|
234
|
-
type: "tool_call",
|
|
235
|
-
toolCallId: "tc-path",
|
|
236
|
-
name: "read",
|
|
237
|
-
input: { path: ".env" },
|
|
238
|
-
};
|
|
213
|
+
const event = makeToolCallEvent("read", { input: { path: ".env" } });
|
|
239
214
|
const result = await handler.handleToolCall(event, makeCtx());
|
|
240
215
|
expect(result).toMatchObject({ block: true });
|
|
241
216
|
});
|
|
242
217
|
|
|
243
218
|
it("allows a read when path surface allows", async () => {
|
|
244
|
-
const { handler } = makeHandler({
|
|
245
|
-
|
|
246
|
-
getAll: vi.fn().mockReturnValue([{ name: "read" }]),
|
|
247
|
-
},
|
|
248
|
-
});
|
|
249
|
-
const event = {
|
|
250
|
-
type: "tool_call",
|
|
251
|
-
toolCallId: "tc-path-ok",
|
|
252
|
-
name: "read",
|
|
219
|
+
const { handler } = makeHandler({ tools: ["read"] });
|
|
220
|
+
const event = makeToolCallEvent("read", {
|
|
253
221
|
input: { path: "src/index.ts" },
|
|
254
|
-
};
|
|
222
|
+
});
|
|
255
223
|
const result = await handler.handleToolCall(event, makeCtx());
|
|
256
224
|
expect(result).toEqual({});
|
|
257
225
|
});
|
|
@@ -261,28 +229,15 @@ describe("handleToolCall — path gate (tools)", () => {
|
|
|
261
229
|
|
|
262
230
|
describe("handleToolCall — bash path gate", () => {
|
|
263
231
|
it("blocks a bash command accessing .env when path surface denies", async () => {
|
|
264
|
-
const checkPermission = vi
|
|
265
|
-
.fn()
|
|
266
|
-
.mockImplementation(
|
|
267
|
-
(surface: string, _input: unknown, _agentName?: string) => {
|
|
268
|
-
if (surface === "path") {
|
|
269
|
-
return makeCheckResult({ state: "deny", matchedPattern: "*.env" });
|
|
270
|
-
}
|
|
271
|
-
return makeCheckResult();
|
|
272
|
-
},
|
|
273
|
-
);
|
|
274
232
|
const { handler } = makeHandler({
|
|
275
|
-
session: {
|
|
276
|
-
|
|
277
|
-
|
|
233
|
+
session: {
|
|
234
|
+
checkPermission: makeSurfaceCheck({
|
|
235
|
+
path: { state: "deny", matchedPattern: "*.env" },
|
|
236
|
+
}),
|
|
278
237
|
},
|
|
238
|
+
tools: ["bash"],
|
|
279
239
|
});
|
|
280
|
-
const event = {
|
|
281
|
-
type: "tool_call",
|
|
282
|
-
toolCallId: "tc-bash-path",
|
|
283
|
-
name: "bash",
|
|
284
|
-
input: { command: "cat .env" },
|
|
285
|
-
};
|
|
240
|
+
const event = makeToolCallEvent("bash", { input: { command: "cat .env" } });
|
|
286
241
|
const result = await handler.handleToolCall(event, makeCtx());
|
|
287
242
|
expect(result).toMatchObject({ block: true });
|
|
288
243
|
});
|
|
@@ -292,108 +247,44 @@ describe("handleToolCall — bash path gate", () => {
|
|
|
292
247
|
|
|
293
248
|
describe("handleToolCall — bash command chain gate", () => {
|
|
294
249
|
it("blocks a chain when a later sub-command is denied (#301)", async () => {
|
|
295
|
-
const checkPermission = vi
|
|
296
|
-
.fn()
|
|
297
|
-
.mockImplementation((surface: string, input: unknown) => {
|
|
298
|
-
if (surface === "bash") {
|
|
299
|
-
const command = (input as { command?: string }).command ?? "";
|
|
300
|
-
return /^npm\b/.test(command)
|
|
301
|
-
? makeCheckResult({
|
|
302
|
-
state: "deny",
|
|
303
|
-
source: "bash",
|
|
304
|
-
command,
|
|
305
|
-
matchedPattern: "npm *",
|
|
306
|
-
})
|
|
307
|
-
: makeCheckResult({
|
|
308
|
-
state: "allow",
|
|
309
|
-
source: "bash",
|
|
310
|
-
command,
|
|
311
|
-
matchedPattern: "echo *",
|
|
312
|
-
});
|
|
313
|
-
}
|
|
314
|
-
return makeCheckResult({ state: "allow" });
|
|
315
|
-
});
|
|
316
250
|
const { handler } = makeHandler({
|
|
317
|
-
session: {
|
|
318
|
-
|
|
319
|
-
|
|
251
|
+
session: {
|
|
252
|
+
checkPermission: makeBashCommandCheck({
|
|
253
|
+
deny: /^npm\b/,
|
|
254
|
+
denyMatched: "npm *",
|
|
255
|
+
allowMatched: "echo *",
|
|
256
|
+
}),
|
|
320
257
|
},
|
|
258
|
+
tools: ["bash"],
|
|
321
259
|
});
|
|
322
|
-
const event = {
|
|
323
|
-
type: "tool_call",
|
|
324
|
-
toolCallId: "tc-bash-chain",
|
|
325
|
-
name: "bash",
|
|
260
|
+
const event = makeToolCallEvent("bash", {
|
|
326
261
|
input: { command: "echo start && npm install compromised-package" },
|
|
327
|
-
};
|
|
262
|
+
});
|
|
328
263
|
const result = await handler.handleToolCall(event, makeCtx());
|
|
329
264
|
expect(result).toMatchObject({ block: true });
|
|
330
265
|
});
|
|
331
266
|
|
|
332
267
|
it("blocks a command nested inside command substitution (#306)", async () => {
|
|
333
|
-
const checkPermission = vi
|
|
334
|
-
.fn()
|
|
335
|
-
.mockImplementation((surface: string, input: unknown) => {
|
|
336
|
-
if (surface === "bash") {
|
|
337
|
-
const command = (input as { command?: string }).command ?? "";
|
|
338
|
-
return /^rm\b/.test(command)
|
|
339
|
-
? makeCheckResult({
|
|
340
|
-
state: "deny",
|
|
341
|
-
source: "bash",
|
|
342
|
-
command,
|
|
343
|
-
matchedPattern: "rm *",
|
|
344
|
-
})
|
|
345
|
-
: makeCheckResult({
|
|
346
|
-
state: "allow",
|
|
347
|
-
source: "bash",
|
|
348
|
-
command,
|
|
349
|
-
matchedPattern: "echo *",
|
|
350
|
-
});
|
|
351
|
-
}
|
|
352
|
-
return makeCheckResult({ state: "allow" });
|
|
353
|
-
});
|
|
354
268
|
const { handler } = makeHandler({
|
|
355
|
-
session: {
|
|
356
|
-
|
|
357
|
-
|
|
269
|
+
session: {
|
|
270
|
+
checkPermission: makeBashCommandCheck({
|
|
271
|
+
deny: /^rm\b/,
|
|
272
|
+
denyMatched: "rm *",
|
|
273
|
+
allowMatched: "echo *",
|
|
274
|
+
}),
|
|
358
275
|
},
|
|
276
|
+
tools: ["bash"],
|
|
359
277
|
});
|
|
360
|
-
const event = {
|
|
361
|
-
type: "tool_call",
|
|
362
|
-
toolCallId: "tc-bash-substitution",
|
|
363
|
-
name: "bash",
|
|
278
|
+
const event = makeToolCallEvent("bash", {
|
|
364
279
|
input: { command: "echo $(rm -rf foo)" },
|
|
365
|
-
};
|
|
280
|
+
});
|
|
366
281
|
const result = await handler.handleToolCall(event, makeCtx());
|
|
367
282
|
expect(result).toMatchObject({ block: true });
|
|
368
283
|
});
|
|
369
284
|
|
|
370
285
|
it("allows a single non-chained bash command", async () => {
|
|
371
|
-
const
|
|
372
|
-
|
|
373
|
-
.mockImplementation((surface: string, input: unknown) => {
|
|
374
|
-
if (surface === "bash") {
|
|
375
|
-
const command = (input as { command?: string }).command ?? "";
|
|
376
|
-
return makeCheckResult({
|
|
377
|
-
state: "allow",
|
|
378
|
-
source: "bash",
|
|
379
|
-
command,
|
|
380
|
-
matchedPattern: "echo *",
|
|
381
|
-
});
|
|
382
|
-
}
|
|
383
|
-
return makeCheckResult({ state: "allow" });
|
|
384
|
-
});
|
|
385
|
-
const { handler } = makeHandler({
|
|
386
|
-
session: { checkPermission },
|
|
387
|
-
toolRegistry: {
|
|
388
|
-
getAll: vi.fn().mockReturnValue([{ name: "bash" }]),
|
|
389
|
-
},
|
|
390
|
-
});
|
|
391
|
-
const event = {
|
|
392
|
-
type: "tool_call",
|
|
393
|
-
toolCallId: "tc-bash-single",
|
|
394
|
-
name: "bash",
|
|
395
|
-
input: { command: "echo hi" },
|
|
396
|
-
};
|
|
286
|
+
const { handler } = makeHandler({ tools: ["bash"] });
|
|
287
|
+
const event = makeToolCallEvent("bash", { input: { command: "echo hi" } });
|
|
397
288
|
const result = await handler.handleToolCall(event, makeCtx());
|
|
398
289
|
expect(result).toEqual({});
|
|
399
290
|
});
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Shared gate-level test fixtures for gate descriptor and runner tests.
|
|
3
3
|
*/
|
|
4
4
|
import { vi } from "vitest";
|
|
5
|
-
|
|
6
5
|
import type { DecisionReporter } from "#src/decision-reporter";
|
|
6
|
+
import type { DenialContext } from "#src/denial-messages";
|
|
7
7
|
import type { GatePrompter } from "#src/gate-prompter";
|
|
8
8
|
import type { GateDescriptor } from "#src/handlers/gates/descriptor";
|
|
9
9
|
import { GateRunner } from "#src/handlers/gates/runner";
|
|
@@ -90,6 +90,7 @@ export function makeReporter(
|
|
|
90
90
|
*/
|
|
91
91
|
export function makeGateRunner(
|
|
92
92
|
overrides: {
|
|
93
|
+
resolveResult?: PermissionCheckResult;
|
|
93
94
|
resolve?: PermissionResolver["resolve"];
|
|
94
95
|
recordSessionApproval?: SessionApprovalRecorder["recordSessionApproval"];
|
|
95
96
|
canConfirm?: GatePrompter["canConfirm"];
|
|
@@ -102,7 +103,9 @@ export function makeGateRunner(
|
|
|
102
103
|
overrides.resolve ??
|
|
103
104
|
vi
|
|
104
105
|
.fn<PermissionResolver["resolve"]>()
|
|
105
|
-
.mockReturnValue(
|
|
106
|
+
.mockReturnValue(
|
|
107
|
+
overrides.resolveResult ?? makeCheckResult({ matchedPattern: "*" }),
|
|
108
|
+
);
|
|
106
109
|
const recordSessionApproval =
|
|
107
110
|
overrides.recordSessionApproval ??
|
|
108
111
|
(vi.fn() as SessionApprovalRecorder["recordSessionApproval"]);
|
|
@@ -132,6 +135,42 @@ export function makeGateRunner(
|
|
|
132
135
|
};
|
|
133
136
|
}
|
|
134
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Gate descriptor variant with write-surface defaults and a caller-supplied
|
|
140
|
+
* denialContext.
|
|
141
|
+
*
|
|
142
|
+
* Use instead of `makeDescriptor` when the test exercises denial-message
|
|
143
|
+
* formatting — the write surface and its matching promptDetails/logContext
|
|
144
|
+
* keep the message helpers' field access consistent.
|
|
145
|
+
*/
|
|
146
|
+
export function makeDenialDescriptor(
|
|
147
|
+
denialContext: DenialContext,
|
|
148
|
+
overrides: Partial<GateDescriptor> = {},
|
|
149
|
+
): GateDescriptor {
|
|
150
|
+
return {
|
|
151
|
+
surface: "write",
|
|
152
|
+
input: {},
|
|
153
|
+
denialContext,
|
|
154
|
+
promptDetails: {
|
|
155
|
+
source: "tool_call",
|
|
156
|
+
agentName: null,
|
|
157
|
+
message: "Allow tool 'write'?",
|
|
158
|
+
toolCallId: "tc-1",
|
|
159
|
+
toolName: "write",
|
|
160
|
+
},
|
|
161
|
+
logContext: {
|
|
162
|
+
source: "tool_call",
|
|
163
|
+
toolCallId: "tc-1",
|
|
164
|
+
toolName: "write",
|
|
165
|
+
},
|
|
166
|
+
decision: {
|
|
167
|
+
surface: "write",
|
|
168
|
+
value: "write",
|
|
169
|
+
},
|
|
170
|
+
...overrides,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
135
174
|
/**
|
|
136
175
|
* Tool-call context factory with bash defaults.
|
|
137
176
|
*
|
|
@@ -151,6 +190,31 @@ export function makeTcc(
|
|
|
151
190
|
};
|
|
152
191
|
}
|
|
153
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Resolver whose `resolve` dispatches on `input.path`, falling back to a
|
|
195
|
+
* default result for any path not in the map.
|
|
196
|
+
*
|
|
197
|
+
* Use when a test needs different results for different path tokens without
|
|
198
|
+
* writing a full `mockImplementation` block.
|
|
199
|
+
*
|
|
200
|
+
* Return type is intentionally unannotated so callers retain full `vi.fn()`
|
|
201
|
+
* mock access (`mock.calls`, `toHaveBeenCalledWith`, etc.).
|
|
202
|
+
*/
|
|
203
|
+
export function makePathDispatchResolver(
|
|
204
|
+
byPath: Record<string, PermissionCheckResult>,
|
|
205
|
+
defaultResult: PermissionCheckResult,
|
|
206
|
+
) {
|
|
207
|
+
const resolve = vi.fn<PermissionResolver["resolve"]>();
|
|
208
|
+
resolve.mockImplementation((_surface, input) => {
|
|
209
|
+
const path = (input as Record<string, unknown>).path;
|
|
210
|
+
if (typeof path === "string" && path in byPath) {
|
|
211
|
+
return byPath[path];
|
|
212
|
+
}
|
|
213
|
+
return defaultResult;
|
|
214
|
+
});
|
|
215
|
+
return { resolve };
|
|
216
|
+
}
|
|
217
|
+
|
|
154
218
|
/**
|
|
155
219
|
* Path-surface check result factory.
|
|
156
220
|
*
|