@gotgenes/pi-permission-system 10.1.0 → 10.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.
@@ -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 { makeDescriptor, makeGateRunner } from "#test/helpers/gate-fixtures";
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
- resolve: vi
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
- resolve: vi
55
- .fn()
56
- .mockReturnValue(
57
- makeCheckResult({ source: "session", matchedPattern: "git *" }),
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
- resolve: vi
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
- resolve: vi
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
- resolve: vi
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
- resolve: vi
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
- resolve: vi
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
- resolve: vi
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
- resolve: vi
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
- resolve: vi
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
- resolve: vi
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
- resolve: vi
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
- makeDenialContextDescriptor(ctx),
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
- resolve: vi
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
- resolve: vi
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
- resolve: vi
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
- toolRegistry: {
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
- toolRegistry: {
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: { checkPermission },
229
- toolRegistry: {
230
- getAll: vi.fn().mockReturnValue([{ name: "read" }]),
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
- toolRegistry: {
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: { checkPermission },
276
- toolRegistry: {
277
- getAll: vi.fn().mockReturnValue([{ name: "bash" }]),
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: { checkPermission },
318
- toolRegistry: {
319
- getAll: vi.fn().mockReturnValue([{ name: "bash" }]),
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: { checkPermission },
356
- toolRegistry: {
357
- getAll: vi.fn().mockReturnValue([{ name: "bash" }]),
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 checkPermission = vi
372
- .fn()
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(makeCheckResult({ matchedPattern: "*" }));
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
  *