@gotgenes/pi-permission-system 10.3.1 → 10.5.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/package.json +1 -1
  3. package/src/config-modal.ts +10 -8
  4. package/src/config-store.ts +6 -11
  5. package/src/forwarded-permissions/io.ts +16 -22
  6. package/src/forwarded-permissions/permission-forwarder.ts +16 -19
  7. package/src/gate-prompter.ts +1 -3
  8. package/src/handlers/gates/bash-command.ts +2 -2
  9. package/src/handlers/gates/bash-external-directory.ts +2 -2
  10. package/src/handlers/gates/bash-path.ts +2 -2
  11. package/src/handlers/gates/path.ts +2 -2
  12. package/src/handlers/gates/runner.ts +3 -3
  13. package/src/handlers/gates/tool-call-gate-pipeline.ts +10 -9
  14. package/src/index.ts +27 -41
  15. package/src/permission-event-rpc.ts +19 -15
  16. package/src/permission-prompter.ts +4 -3
  17. package/src/permission-resolver.ts +69 -2
  18. package/src/permission-session.ts +7 -83
  19. package/src/prompting-gateway.ts +104 -0
  20. package/src/session-logger.ts +17 -3
  21. package/test/config-modal.test.ts +13 -7
  22. package/test/config-store.test.ts +7 -9
  23. package/test/forwarded-permissions/io.test.ts +23 -26
  24. package/test/handlers/external-directory-integration.test.ts +45 -32
  25. package/test/handlers/external-directory-session-dedup.test.ts +47 -57
  26. package/test/handlers/gates/bash-external-directory.test.ts +2 -2
  27. package/test/handlers/gates/bash-path.test.ts +2 -2
  28. package/test/handlers/gates/runner.test.ts +10 -16
  29. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +30 -21
  30. package/test/handlers/input-events.test.ts +19 -4
  31. package/test/handlers/input.test.ts +29 -13
  32. package/test/handlers/tool-call-events.test.ts +23 -5
  33. package/test/helpers/gate-fixtures.ts +11 -15
  34. package/test/helpers/handler-fixtures.ts +31 -50
  35. package/test/permission-event-rpc.test.ts +30 -28
  36. package/test/permission-forwarder.test.ts +6 -5
  37. package/test/permission-prompter.test.ts +28 -28
  38. package/test/permission-resolver.test.ts +194 -0
  39. package/test/permission-session.test.ts +27 -180
  40. package/test/prompting-gateway.test.ts +230 -0
@@ -90,10 +90,8 @@ function makePolicyPathProvider(
90
90
 
91
91
  function makeLogger() {
92
92
  return {
93
- writeDebugLog:
94
- vi.fn<(event: string, details?: Record<string, unknown>) => void>(),
95
- writeReviewLog:
96
- vi.fn<(event: string, details?: Record<string, unknown>) => void>(),
93
+ debug: vi.fn<(event: string, details?: Record<string, unknown>) => void>(),
94
+ review: vi.fn<(event: string, details?: Record<string, unknown>) => void>(),
97
95
  };
98
96
  }
99
97
 
@@ -197,7 +195,7 @@ describe("ConfigStore", () => {
197
195
  it("writes config.loaded debug log", () => {
198
196
  const { store, logger } = makeStore();
199
197
  store.refresh();
200
- expect(logger.writeDebugLog).toHaveBeenCalledWith(
198
+ expect(logger.debug).toHaveBeenCalledWith(
201
199
  "config.loaded",
202
200
  expect.objectContaining({ debugLog: false }),
203
201
  );
@@ -336,7 +334,7 @@ describe("ConfigStore", () => {
336
334
  it("writes config.saved debug log after a successful save", () => {
337
335
  const { store, logger } = makeStore();
338
336
  store.save({ ...DEFAULT_EXTENSION_CONFIG }, makeCommandCtx());
339
- expect(logger.writeDebugLog).toHaveBeenCalledWith(
337
+ expect(logger.debug).toHaveBeenCalledWith(
340
338
  "config.saved",
341
339
  expect.objectContaining({ debugLog: false }),
342
340
  );
@@ -357,7 +355,7 @@ describe("ConfigStore", () => {
357
355
  // current() is not updated on failure
358
356
  expect(store.current()).toEqual(DEFAULT_EXTENSION_CONFIG);
359
357
  // no debug log on failure
360
- expect(logger.writeDebugLog).not.toHaveBeenCalledWith(
358
+ expect(logger.debug).not.toHaveBeenCalledWith(
361
359
  "config.saved",
362
360
  expect.anything(),
363
361
  );
@@ -381,11 +379,11 @@ describe("ConfigStore", () => {
381
379
  it("writes config.resolved to both review and debug logs", () => {
382
380
  const { store, logger } = makeStore();
383
381
  store.logResolvedPaths();
384
- expect(logger.writeReviewLog).toHaveBeenCalledWith(
382
+ expect(logger.review).toHaveBeenCalledWith(
385
383
  "config.resolved",
386
384
  expect.any(Object),
387
385
  );
388
- expect(logger.writeDebugLog).toHaveBeenCalledWith(
386
+ expect(logger.debug).toHaveBeenCalledWith(
389
387
  "config.resolved",
390
388
  expect.any(Object),
391
389
  );
@@ -1,19 +1,19 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
2
 
3
- import type { ForwardedPermissionLogger } from "#src/forwarded-permissions/io";
4
3
  import {
5
4
  formatUnknownErrorMessage,
6
5
  isErrnoCode,
7
6
  logPermissionForwardingError,
8
7
  logPermissionForwardingWarning,
9
8
  } from "#src/forwarded-permissions/io";
9
+ import type { DebugReviewLogger } from "#src/session-logger";
10
10
 
11
11
  // ── helpers ────────────────────────────────────────────────────────────────
12
12
 
13
- function makeLogger(): ForwardedPermissionLogger {
13
+ function makeLogger(): DebugReviewLogger {
14
14
  return {
15
- writeReviewLog: vi.fn(),
16
- writeDebugLog: vi.fn(),
15
+ review: vi.fn(),
16
+ debug: vi.fn(),
17
17
  };
18
18
  }
19
19
 
@@ -59,28 +59,27 @@ describe("isErrnoCode", () => {
59
59
  // ── logPermissionForwardingWarning ─────────────────────────────────────────
60
60
 
61
61
  describe("logPermissionForwardingWarning", () => {
62
- it("calls logger.writeReviewLog with the warning event", () => {
62
+ it("calls logger.review with the warning event", () => {
63
63
  const logger = makeLogger();
64
64
  logPermissionForwardingWarning(logger, "something went wrong");
65
- expect(logger.writeReviewLog).toHaveBeenCalledWith(
65
+ expect(logger.review).toHaveBeenCalledWith(
66
66
  "permission_forwarding.warning",
67
67
  { message: "something went wrong" },
68
68
  );
69
69
  });
70
70
 
71
- it("calls logger.writeDebugLog with the warning event", () => {
71
+ it("calls logger.debug with the warning event", () => {
72
72
  const logger = makeLogger();
73
73
  logPermissionForwardingWarning(logger, "something went wrong");
74
- expect(logger.writeDebugLog).toHaveBeenCalledWith(
75
- "permission_forwarding.warning",
76
- { message: "something went wrong" },
77
- );
74
+ expect(logger.debug).toHaveBeenCalledWith("permission_forwarding.warning", {
75
+ message: "something went wrong",
76
+ });
78
77
  });
79
78
 
80
79
  it("includes formatted error when an error is provided", () => {
81
80
  const logger = makeLogger();
82
81
  logPermissionForwardingWarning(logger, "bad thing", new Error("fs fail"));
83
- expect(logger.writeReviewLog).toHaveBeenCalledWith(
82
+ expect(logger.review).toHaveBeenCalledWith(
84
83
  "permission_forwarding.warning",
85
84
  { message: "bad thing", error: "fs fail" },
86
85
  );
@@ -102,31 +101,29 @@ describe("logPermissionForwardingWarning", () => {
102
101
  // ── logPermissionForwardingError ───────────────────────────────────────────
103
102
 
104
103
  describe("logPermissionForwardingError", () => {
105
- it("calls logger.writeReviewLog with the error event", () => {
104
+ it("calls logger.review with the error event", () => {
106
105
  const logger = makeLogger();
107
106
  logPermissionForwardingError(logger, "critical failure");
108
- expect(logger.writeReviewLog).toHaveBeenCalledWith(
109
- "permission_forwarding.error",
110
- { message: "critical failure" },
111
- );
107
+ expect(logger.review).toHaveBeenCalledWith("permission_forwarding.error", {
108
+ message: "critical failure",
109
+ });
112
110
  });
113
111
 
114
- it("calls logger.writeDebugLog with the error event", () => {
112
+ it("calls logger.debug with the error event", () => {
115
113
  const logger = makeLogger();
116
114
  logPermissionForwardingError(logger, "critical failure");
117
- expect(logger.writeDebugLog).toHaveBeenCalledWith(
118
- "permission_forwarding.error",
119
- { message: "critical failure" },
120
- );
115
+ expect(logger.debug).toHaveBeenCalledWith("permission_forwarding.error", {
116
+ message: "critical failure",
117
+ });
121
118
  });
122
119
 
123
120
  it("includes formatted error when an error is provided", () => {
124
121
  const logger = makeLogger();
125
122
  logPermissionForwardingError(logger, "io error", new Error("ENOENT"));
126
- expect(logger.writeReviewLog).toHaveBeenCalledWith(
127
- "permission_forwarding.error",
128
- { message: "io error", error: "ENOENT" },
129
- );
123
+ expect(logger.review).toHaveBeenCalledWith("permission_forwarding.error", {
124
+ message: "io error",
125
+ error: "ENOENT",
126
+ });
130
127
  });
131
128
 
132
129
  it("does not throw when logger is null", () => {
@@ -12,6 +12,7 @@
12
12
  import { describe, expect, it, vi } from "vitest";
13
13
 
14
14
  import { EXTENSION_TAG } from "#src/denial-messages";
15
+ import type { GatePrompter } from "#src/gate-prompter";
15
16
  import { formatExternalDirectoryAskPrompt } from "#src/handlers/gates/external-directory-messages";
16
17
  import type { PermissionCheckResult } from "#src/types";
17
18
 
@@ -218,13 +219,13 @@ describe("external_directory — allow external reads, gate external writes (#14
218
219
  });
219
220
 
220
221
  it("prompts for write to external path when external_directory allows but write is ask", async () => {
221
- const prompt = vi
222
- .fn()
223
- .mockResolvedValue({ approved: true, state: "approved" });
224
- const { handler } = makeHandler({
225
- session: {
226
- checkPermission: makeExtDirCheck("allow", "ask"),
227
- prompt,
222
+ const { handler, prompter } = makeHandler({
223
+ session: { checkPermission: makeExtDirCheck("allow", "ask") },
224
+ prompter: {
225
+ canConfirm: vi.fn().mockReturnValue(true),
226
+ prompt: vi
227
+ .fn<GatePrompter["prompt"]>()
228
+ .mockResolvedValue({ approved: true, state: "approved" }),
228
229
  },
229
230
  tools: ALL_TOOLS,
230
231
  });
@@ -234,7 +235,7 @@ describe("external_directory — allow external reads, gate external writes (#14
234
235
  const result = await handler.handleToolCall(event, makeCtx());
235
236
  // external_directory passes; write gate prompts and user approves
236
237
  expect(result).toEqual({});
237
- expect(prompt).toHaveBeenCalledOnce();
238
+ expect(prompter.prompt).toHaveBeenCalledOnce();
238
239
  });
239
240
 
240
241
  it("blocks write to external path when external_directory allows but write is deny", async () => {
@@ -341,10 +342,11 @@ describe("external_directory policy state — deny", () => {
341
342
  describe("external_directory policy state — ask", () => {
342
343
  it("does not block when user approves", async () => {
343
344
  const { handler } = makeHandler({
344
- session: {
345
- checkPermission: makeExtDirCheck("ask"),
345
+ session: { checkPermission: makeExtDirCheck("ask") },
346
+ prompter: {
347
+ canConfirm: vi.fn().mockReturnValue(true),
346
348
  prompt: vi
347
- .fn()
349
+ .fn<GatePrompter["prompt"]>()
348
350
  .mockResolvedValue({ approved: true, state: "approved" }),
349
351
  },
350
352
  tools: ALL_TOOLS,
@@ -356,10 +358,11 @@ describe("external_directory policy state — ask", () => {
356
358
 
357
359
  it("emits user_approved decision when user approves", async () => {
358
360
  const { handler, events } = makeHandler({
359
- session: {
360
- checkPermission: makeExtDirCheck("ask"),
361
+ session: { checkPermission: makeExtDirCheck("ask") },
362
+ prompter: {
363
+ canConfirm: vi.fn().mockReturnValue(true),
361
364
  prompt: vi
362
- .fn()
365
+ .fn<GatePrompter["prompt"]>()
363
366
  .mockResolvedValue({ approved: true, state: "approved" }),
364
367
  },
365
368
  tools: ALL_TOOLS,
@@ -379,9 +382,12 @@ describe("external_directory policy state — ask", () => {
379
382
 
380
383
  it("blocks when user denies", async () => {
381
384
  const { handler } = makeHandler({
382
- session: {
383
- checkPermission: makeExtDirCheck("ask"),
384
- prompt: vi.fn().mockResolvedValue({ approved: false, state: "denied" }),
385
+ session: { checkPermission: makeExtDirCheck("ask") },
386
+ prompter: {
387
+ canConfirm: vi.fn().mockReturnValue(true),
388
+ prompt: vi
389
+ .fn<GatePrompter["prompt"]>()
390
+ .mockResolvedValue({ approved: false, state: "denied" }),
385
391
  },
386
392
  tools: ALL_TOOLS,
387
393
  });
@@ -392,9 +398,12 @@ describe("external_directory policy state — ask", () => {
392
398
 
393
399
  it("emits user_denied decision when user denies", async () => {
394
400
  const { handler, events } = makeHandler({
395
- session: {
396
- checkPermission: makeExtDirCheck("ask"),
397
- prompt: vi.fn().mockResolvedValue({ approved: false, state: "denied" }),
401
+ session: { checkPermission: makeExtDirCheck("ask") },
402
+ prompter: {
403
+ canConfirm: vi.fn().mockReturnValue(true),
404
+ prompt: vi
405
+ .fn<GatePrompter["prompt"]>()
406
+ .mockResolvedValue({ approved: false, state: "denied" }),
398
407
  },
399
408
  tools: ALL_TOOLS,
400
409
  });
@@ -413,9 +422,10 @@ describe("external_directory policy state — ask", () => {
413
422
 
414
423
  it("block reason includes denialReason when user provides one", async () => {
415
424
  const { handler } = makeHandler({
416
- session: {
417
- checkPermission: makeExtDirCheck("ask"),
418
- prompt: vi.fn().mockResolvedValue({
425
+ session: { checkPermission: makeExtDirCheck("ask") },
426
+ prompter: {
427
+ canConfirm: vi.fn().mockReturnValue(true),
428
+ prompt: vi.fn<GatePrompter["prompt"]>().mockResolvedValue({
419
429
  approved: false,
420
430
  state: "denied",
421
431
  denialReason: "not needed",
@@ -431,9 +441,10 @@ describe("external_directory policy state — ask", () => {
431
441
 
432
442
  it("blocks with confirmation_unavailable when no UI is available", async () => {
433
443
  const { handler } = makeHandler({
434
- session: {
435
- checkPermission: makeExtDirCheck("ask"),
436
- canPrompt: vi.fn().mockReturnValue(false),
444
+ session: { checkPermission: makeExtDirCheck("ask") },
445
+ prompter: {
446
+ canConfirm: vi.fn().mockReturnValue(false),
447
+ prompt: vi.fn<GatePrompter["prompt"]>(),
437
448
  },
438
449
  tools: ALL_TOOLS,
439
450
  });
@@ -448,9 +459,10 @@ describe("external_directory policy state — ask", () => {
448
459
 
449
460
  it("writes review-log entry with confirmation_unavailable when no UI", async () => {
450
461
  const { handler, session } = makeHandler({
451
- session: {
452
- checkPermission: makeExtDirCheck("ask"),
453
- canPrompt: vi.fn().mockReturnValue(false),
462
+ session: { checkPermission: makeExtDirCheck("ask") },
463
+ prompter: {
464
+ canConfirm: vi.fn().mockReturnValue(false),
465
+ prompt: vi.fn<GatePrompter["prompt"]>(),
454
466
  },
455
467
  tools: ALL_TOOLS,
456
468
  });
@@ -469,9 +481,10 @@ describe("external_directory policy state — ask", () => {
469
481
 
470
482
  it("emits confirmation_unavailable decision when no UI", async () => {
471
483
  const { handler, events } = makeHandler({
472
- session: {
473
- checkPermission: makeExtDirCheck("ask"),
474
- canPrompt: vi.fn().mockReturnValue(false),
484
+ session: { checkPermission: makeExtDirCheck("ask") },
485
+ prompter: {
486
+ canConfirm: vi.fn().mockReturnValue(false),
487
+ prompt: vi.fn<GatePrompter["prompt"]>(),
475
488
  },
476
489
  tools: ALL_TOOLS,
477
490
  });
@@ -9,16 +9,15 @@
9
9
  * PermissionManager.
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
14
  import { GateDecisionReporter } from "#src/decision-reporter";
16
15
  import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
16
+ import type { GatePrompter } from "#src/gate-prompter";
17
17
  import { GateRunner } from "#src/handlers/gates/runner";
18
18
  import { SkillInputGatePipeline } from "#src/handlers/gates/skill-input-gate-pipeline";
19
19
  import { ToolCallGatePipeline } from "#src/handlers/gates/tool-call-gate-pipeline";
20
20
  import { PermissionGateHandler } from "#src/handlers/permission-gate-handler";
21
- import type { PromptPermissionDetails } from "#src/permission-prompter";
22
21
  import type { Rule } from "#src/rule";
23
22
  import type { SessionApproval } from "#src/session-approval";
24
23
  import { resolveToolPreviewLimits } from "#src/tool-preview-formatter";
@@ -154,53 +153,42 @@ function makeStatefulSession(
154
153
  vi
155
154
  .fn<MockGateHandlerSession["getToolPreviewLimits"]>()
156
155
  .mockReturnValue(resolveToolPreviewLimits(DEFAULT_EXTENSION_CONFIG)),
157
- canPrompt:
158
- overrides.canPrompt ??
159
- vi.fn<MockGateHandlerSession["canPrompt"]>().mockReturnValue(true),
160
- prompt:
161
- overrides.prompt ??
162
- vi
163
- .fn<MockGateHandlerSession["prompt"]>()
164
- .mockResolvedValue({ approved: true, state: "approved_for_session" }),
165
- // Delegations — closures read `session` at call time so overrides win.
166
- resolve:
167
- overrides.resolve ??
168
- vi.fn<MockGateHandlerSession["resolve"]>((surface, input, agentName) =>
169
- session.checkPermission(
170
- surface,
171
- input,
172
- agentName,
173
- session.getSessionRuleset(),
174
- ),
175
- ),
176
- canConfirm:
177
- overrides.canConfirm ??
178
- vi.fn<MockGateHandlerSession["canConfirm"]>(() =>
179
- session.canPrompt(undefined as unknown as ExtensionContext),
180
- ),
181
- promptPermission:
182
- overrides.promptPermission ??
183
- vi.fn<MockGateHandlerSession["promptPermission"]>(
184
- (details: PromptPermissionDetails) =>
185
- session.prompt(undefined as unknown as ExtensionContext, details),
186
- ),
187
156
  };
188
157
  return session;
189
158
  }
190
159
 
191
160
  function makeHandlerForSession(
192
161
  session: MockGateHandlerSession,
193
- ): PermissionGateHandler {
162
+ prompter?: GatePrompter,
163
+ ): { handler: PermissionGateHandler; prompter: GatePrompter } {
194
164
  const events = makeEvents();
195
165
  const reporter = new GateDecisionReporter(session.logger, events);
196
- const runner = new GateRunner(session, session, session, reporter);
197
- return new PermissionGateHandler(
166
+ const resolvedPrompter: GatePrompter = prompter ?? {
167
+ canConfirm: vi.fn().mockReturnValue(true),
168
+ prompt: vi
169
+ .fn<GatePrompter["prompt"]>()
170
+ .mockResolvedValue({ approved: true, state: "approved_for_session" }),
171
+ };
172
+ // Resolver delegates to session's checkPermission + getSessionRuleset so
173
+ // stateful approval tracking steers resolve automatically.
174
+ const resolver = {
175
+ resolve: (surface: string, input: unknown, agentName?: string) =>
176
+ session.checkPermission(
177
+ surface,
178
+ input,
179
+ agentName,
180
+ session.getSessionRuleset(),
181
+ ),
182
+ };
183
+ const runner = new GateRunner(resolver, session, resolvedPrompter, reporter);
184
+ const handler = new PermissionGateHandler(
198
185
  session,
199
186
  makeToolRegistry(),
200
- new ToolCallGatePipeline(session),
187
+ new ToolCallGatePipeline(resolver, session),
201
188
  new SkillInputGatePipeline(session),
202
189
  runner,
203
190
  );
191
+ return { handler, prompter: resolvedPrompter };
204
192
  }
205
193
 
206
194
  function makeToolRegistry(): ToolRegistry {
@@ -223,7 +211,7 @@ describe("external-directory session dedup", () => {
223
211
  describe("path-bearing tools (read, write, edit)", () => {
224
212
  it("does not re-prompt for the same external path after session approval", async () => {
225
213
  const session = makeStatefulSession();
226
- const handler = makeHandlerForSession(session);
214
+ const { handler, prompter } = makeHandlerForSession(session);
227
215
  const ctx = makeCtx();
228
216
  const externalPath = "/outside/project/data.txt";
229
217
 
@@ -236,7 +224,7 @@ describe("external-directory session dedup", () => {
236
224
  };
237
225
  const result1 = await handler.handleToolCall(event1, ctx);
238
226
  expect(result1).toEqual({});
239
- expect(session.prompt).toHaveBeenCalledTimes(1);
227
+ expect(prompter.prompt).toHaveBeenCalledTimes(1);
240
228
 
241
229
  // Second call — same path, should hit session rule, no prompt
242
230
  const event2 = {
@@ -247,12 +235,12 @@ describe("external-directory session dedup", () => {
247
235
  };
248
236
  const result2 = await handler.handleToolCall(event2, ctx);
249
237
  expect(result2).toEqual({});
250
- expect(session.prompt).toHaveBeenCalledTimes(1);
238
+ expect(prompter.prompt).toHaveBeenCalledTimes(1);
251
239
  });
252
240
 
253
241
  it("does not re-prompt for a different file in the same external directory", async () => {
254
242
  const session = makeStatefulSession();
255
- const handler = makeHandlerForSession(session);
243
+ const { handler, prompter } = makeHandlerForSession(session);
256
244
  const ctx = makeCtx();
257
245
 
258
246
  // First call — prompt for /outside/project/a.txt
@@ -263,7 +251,7 @@ describe("external-directory session dedup", () => {
263
251
  input: { path: "/outside/project/a.txt" },
264
252
  };
265
253
  await handler.handleToolCall(event1, ctx);
266
- expect(session.prompt).toHaveBeenCalledTimes(1);
254
+ expect(prompter.prompt).toHaveBeenCalledTimes(1);
267
255
 
268
256
  // Second call — /outside/project/b.txt is in the same directory
269
257
  const event2 = {
@@ -273,12 +261,12 @@ describe("external-directory session dedup", () => {
273
261
  input: { path: "/outside/project/b.txt" },
274
262
  };
275
263
  await handler.handleToolCall(event2, ctx);
276
- expect(session.prompt).toHaveBeenCalledTimes(1);
264
+ expect(prompter.prompt).toHaveBeenCalledTimes(1);
277
265
  });
278
266
 
279
267
  it("does prompt for a file in a different external directory", async () => {
280
268
  const session = makeStatefulSession();
281
- const handler = makeHandlerForSession(session);
269
+ const { handler, prompter } = makeHandlerForSession(session);
282
270
  const ctx = makeCtx();
283
271
 
284
272
  // First call — /outside/alpha/file.txt
@@ -289,7 +277,7 @@ describe("external-directory session dedup", () => {
289
277
  input: { path: "/outside/alpha/file.txt" },
290
278
  };
291
279
  await handler.handleToolCall(event1, ctx);
292
- expect(session.prompt).toHaveBeenCalledTimes(1);
280
+ expect(prompter.prompt).toHaveBeenCalledTimes(1);
293
281
 
294
282
  // Second call — /outside/beta/file.txt is a different directory
295
283
  const event2 = {
@@ -299,16 +287,18 @@ describe("external-directory session dedup", () => {
299
287
  input: { path: "/outside/beta/file.txt" },
300
288
  };
301
289
  await handler.handleToolCall(event2, ctx);
302
- expect(session.prompt).toHaveBeenCalledTimes(2);
290
+ expect(prompter.prompt).toHaveBeenCalledTimes(2);
303
291
  });
304
292
 
305
293
  it("re-prompts when user approved once (not for session)", async () => {
306
- const session = makeStatefulSession({
294
+ const session = makeStatefulSession();
295
+ const approveOnce: GatePrompter = {
296
+ canConfirm: vi.fn().mockReturnValue(true),
307
297
  prompt: vi
308
- .fn()
298
+ .fn<GatePrompter["prompt"]>()
309
299
  .mockResolvedValue({ approved: true, state: "approved" }),
310
- });
311
- const handler = makeHandlerForSession(session);
300
+ };
301
+ const { handler, prompter } = makeHandlerForSession(session, approveOnce);
312
302
  const ctx = makeCtx();
313
303
  const externalPath = "/outside/project/data.txt";
314
304
 
@@ -320,7 +310,7 @@ describe("external-directory session dedup", () => {
320
310
  input: { path: externalPath },
321
311
  };
322
312
  await handler.handleToolCall(event1, ctx);
323
- expect(session.prompt).toHaveBeenCalledTimes(1);
313
+ expect(prompter.prompt).toHaveBeenCalledTimes(1);
324
314
 
325
315
  // Second call — no session rule recorded, should prompt again
326
316
  const event2 = {
@@ -330,14 +320,14 @@ describe("external-directory session dedup", () => {
330
320
  input: { path: externalPath },
331
321
  };
332
322
  await handler.handleToolCall(event2, ctx);
333
- expect(session.prompt).toHaveBeenCalledTimes(2);
323
+ expect(prompter.prompt).toHaveBeenCalledTimes(2);
334
324
  });
335
325
  });
336
326
 
337
327
  describe("bash commands with external paths", () => {
338
328
  it("does not re-prompt for a bash command referencing the same external path after session approval", async () => {
339
329
  const session = makeStatefulSession();
340
- const handler = makeHandlerForSession(session);
330
+ const { handler, prompter } = makeHandlerForSession(session);
341
331
  const ctx = makeCtx();
342
332
 
343
333
  // First call — bash referencing /tmp/out.txt
@@ -349,7 +339,7 @@ describe("external-directory session dedup", () => {
349
339
  };
350
340
  const result1 = await handler.handleToolCall(event1, ctx);
351
341
  expect(result1).toEqual({});
352
- expect(session.prompt).toHaveBeenCalledTimes(1);
342
+ expect(prompter.prompt).toHaveBeenCalledTimes(1);
353
343
 
354
344
  // Second call — different bash command, same external path
355
345
  const event2 = {
@@ -360,12 +350,12 @@ describe("external-directory session dedup", () => {
360
350
  };
361
351
  const result2 = await handler.handleToolCall(event2, ctx);
362
352
  expect(result2).toEqual({});
363
- expect(session.prompt).toHaveBeenCalledTimes(1);
353
+ expect(prompter.prompt).toHaveBeenCalledTimes(1);
364
354
  });
365
355
 
366
356
  it("does not re-prompt for read after bash already approved the same directory", async () => {
367
357
  const session = makeStatefulSession();
368
- const handler = makeHandlerForSession(session);
358
+ const { handler, prompter } = makeHandlerForSession(session);
369
359
  const ctx = makeCtx();
370
360
 
371
361
  // First call — bash writes to /tmp/out.txt
@@ -376,7 +366,7 @@ describe("external-directory session dedup", () => {
376
366
  input: { command: "echo hello > /tmp/out.txt" },
377
367
  };
378
368
  await handler.handleToolCall(event1, ctx);
379
- expect(session.prompt).toHaveBeenCalledTimes(1);
369
+ expect(prompter.prompt).toHaveBeenCalledTimes(1);
380
370
 
381
371
  // Second call — read from /tmp/out.txt (same directory, different tool)
382
372
  const event2 = {
@@ -386,7 +376,7 @@ describe("external-directory session dedup", () => {
386
376
  input: { path: "/tmp/out.txt" },
387
377
  };
388
378
  await handler.handleToolCall(event2, ctx);
389
- expect(session.prompt).toHaveBeenCalledTimes(1);
379
+ expect(prompter.prompt).toHaveBeenCalledTimes(1);
390
380
  });
391
381
  });
392
382
  });
@@ -9,7 +9,7 @@ import type {
9
9
  } from "#src/handlers/gates/descriptor";
10
10
  import { isGateBypass, isGateDescriptor } from "#src/handlers/gates/descriptor";
11
11
  import type { ToolCallContext } from "#src/handlers/gates/types";
12
- import type { PermissionResolver } from "#src/permission-resolver";
12
+ import type { ScopedPermissionResolver } from "#src/permission-resolver";
13
13
  import type { PermissionCheckResult } from "#src/types";
14
14
 
15
15
  import { makeResolver } from "#test/helpers/gate-fixtures";
@@ -47,7 +47,7 @@ function makeCheckResult(
47
47
  */
48
48
  async function describeGate(
49
49
  tcc: ToolCallContext,
50
- resolver: PermissionResolver,
50
+ resolver: ScopedPermissionResolver,
51
51
  ): Promise<GateResult> {
52
52
  const command = getNonEmptyString(toRecord(tcc.input).command);
53
53
  const bashProgram =
@@ -19,7 +19,7 @@ import type {
19
19
  } from "#src/handlers/gates/descriptor";
20
20
  import { isGateBypass, isGateDescriptor } from "#src/handlers/gates/descriptor";
21
21
  import type { ToolCallContext } from "#src/handlers/gates/types";
22
- import type { PermissionResolver } from "#src/permission-resolver";
22
+ import type { ScopedPermissionResolver } from "#src/permission-resolver";
23
23
 
24
24
  import {
25
25
  makeGateCheckResult as makeCheckResult,
@@ -39,7 +39,7 @@ afterEach(() => {
39
39
  */
40
40
  async function describeGate(
41
41
  tcc: ToolCallContext,
42
- resolver: PermissionResolver,
42
+ resolver: ScopedPermissionResolver,
43
43
  ): Promise<GateResult> {
44
44
  const command = getNonEmptyString(toRecord(tcc.input).command);
45
45
  const bashProgram =