@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.
@@ -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 { GateRunner } from "#src/handlers/gates/runner";
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
- makeEvents,
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
- function makeToolRegistry(overrides: Partial<ToolRegistry> = {}): ToolRegistry {
157
- return {
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
- function makeHandler(overrides?: {
169
- session?: Partial<MockGateHandlerSession>;
170
- toolRegistry?: Partial<ToolRegistry>;
171
- }): {
172
- handler: PermissionGateHandler;
173
- events: ReturnType<typeof makeEvents>;
174
- session: MockGateHandlerSession;
175
- } {
176
- const session = makeSession(overrides?.session);
177
- const events = makeEvents();
178
- const toolRegistry = makeToolRegistry(overrides?.toolRegistry);
179
- const pipeline = new ToolCallGatePipeline(session);
180
- const skillInputPipeline = new SkillInputGatePipeline(session);
181
- const reporter = new GateDecisionReporter(session.logger, events);
182
- const runner = new GateRunner(session, session, session, reporter);
183
- const handler = new PermissionGateHandler(
184
- session,
185
- toolRegistry,
186
- pipeline,
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: makeCheckPermission("deny") },
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 makeCheckPermission)
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: makeCheckPermission("deny") },
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: makeCheckPermission("deny", "allow") },
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: makeCheckPermission("deny") },
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: makeCheckPermission("deny") },
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: makeCheckPermission("allow") },
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: makeCheckPermission("allow") },
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: makeCheckPermission("allow") },
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: makeCheckPermission("allow", "allow") },
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: makeCheckPermission("allow", "ask"),
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: makeCheckPermission("allow", "deny") },
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: makeCheckPermission("allow", "deny") },
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: makeCheckPermission("deny") },
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: makeCheckPermission("deny") },
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: makeCheckPermission("deny") },
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: makeCheckPermission("deny") },
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: makeCheckPermission("ask"),
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: makeCheckPermission("ask"),
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: makeCheckPermission("ask"),
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: makeCheckPermission("ask"),
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: makeCheckPermission("ask"),
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: makeCheckPermission("ask"),
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: makeCheckPermission("ask"),
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: makeCheckPermission("ask"),
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: makeCheckPermission("deny") },
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: makeCheckPermission("allow"),
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: makeCheckPermission("allow") },
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({ input: { command: "cat .env" } }),
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({ input: { command: "cat .env" } }),
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({ input: { command: "cat .env" } }),
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({ input: { command: "cat .env" } }),
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({ input: { command: "cat .env" } }),
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({ input: { command: "cat .env" } }),
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 = makeResolver();
139
- resolver.resolve.mockImplementation((_surface, input) => {
140
- const record = input as Record<string, unknown>;
141
- if (record.path === "src/foo.ts") {
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 = makeResolver();
157
- resolver.resolve.mockImplementation((_surface, input) => {
158
- const record = input as Record<string, unknown>;
159
- if (record.path === ".env") {
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 = makeResolver();
177
- resolver.resolve.mockImplementation((_surface, input) => {
178
- const record = input as Record<string, unknown>;
179
- if (record.path === ".env") {
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({ input: { command: "cat .env" } }),
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 = makeResolver();
210
- resolver.resolve.mockImplementation((_surface, input) => {
211
- const record = input as Record<string, unknown>;
212
- if (record.path === ".env") {
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,