@gotgenes/pi-permission-system 10.0.0 → 10.1.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 (64) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/agent-prep-session.ts +28 -0
  5. package/src/decision-reporter.ts +41 -0
  6. package/src/denial-messages.ts +11 -0
  7. package/src/forwarded-permissions/permission-forwarder.ts +549 -0
  8. package/src/forwarding-manager.ts +3 -7
  9. package/src/gate-handler-session.ts +13 -0
  10. package/src/gate-prompter.ts +14 -0
  11. package/src/handlers/before-agent-start.ts +2 -3
  12. package/src/handlers/gates/bash-command.ts +4 -18
  13. package/src/handlers/gates/bash-external-directory.ts +3 -15
  14. package/src/handlers/gates/bash-path.ts +3 -16
  15. package/src/handlers/gates/descriptor.ts +0 -28
  16. package/src/handlers/gates/path.ts +3 -15
  17. package/src/handlers/gates/runner.ts +142 -105
  18. package/src/handlers/gates/skill-input-gate-pipeline.ts +104 -0
  19. package/src/handlers/gates/skill-input.ts +44 -0
  20. package/src/handlers/gates/tool-call-gate-pipeline.ts +120 -0
  21. package/src/handlers/lifecycle.ts +9 -9
  22. package/src/handlers/permission-gate-handler.ts +34 -238
  23. package/src/index.ts +49 -69
  24. package/src/mcp-targets.ts +56 -46
  25. package/src/permission-prompter.ts +7 -58
  26. package/src/permission-resolver.ts +17 -0
  27. package/src/permission-session.ts +77 -9
  28. package/src/permissions-service.ts +53 -0
  29. package/src/service-lifecycle.ts +49 -0
  30. package/src/session-approval-recorder.ts +6 -0
  31. package/src/session-lifecycle-session.ts +24 -0
  32. package/src/tool-input-preview.ts +0 -62
  33. package/src/tool-input-prompt-formatters.ts +63 -0
  34. package/src/tool-preview-formatter.ts +6 -4
  35. package/test/decision-reporter.test.ts +112 -0
  36. package/test/denial-messages.test.ts +62 -0
  37. package/test/forwarding-manager.test.ts +26 -44
  38. package/test/handlers/before-agent-start.test.ts +45 -21
  39. package/test/handlers/external-directory-integration.test.ts +86 -22
  40. package/test/handlers/external-directory-session-dedup.test.ts +102 -55
  41. package/test/handlers/gates/bash-command.test.ts +49 -90
  42. package/test/handlers/gates/bash-external-directory.test.ts +54 -95
  43. package/test/handlers/gates/bash-path.test.ts +63 -148
  44. package/test/handlers/gates/path.test.ts +38 -105
  45. package/test/handlers/gates/runner.test.ts +150 -93
  46. package/test/handlers/gates/skill-input-gate-pipeline.test.ts +176 -0
  47. package/test/handlers/gates/skill-input.test.ts +128 -0
  48. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +180 -0
  49. package/test/handlers/input.test.ts +1 -2
  50. package/test/handlers/lifecycle.test.ts +49 -33
  51. package/test/handlers/tool-call-events.test.ts +1 -1
  52. package/test/helpers/gate-fixtures.ts +147 -16
  53. package/test/helpers/handler-fixtures.ts +143 -27
  54. package/test/mcp-targets.test.ts +55 -0
  55. package/test/permission-forwarder.test.ts +295 -0
  56. package/test/permission-forwarding.test.ts +0 -282
  57. package/test/permission-prompter.test.ts +33 -44
  58. package/test/permission-session.test.ts +160 -27
  59. package/test/permissions-service.test.ts +151 -0
  60. package/test/runtime.test.ts +0 -4
  61. package/test/service-lifecycle.test.ts +162 -0
  62. package/test/tool-input-preview.test.ts +0 -111
  63. package/test/tool-input-prompt-formatters.test.ts +115 -0
  64. package/src/forwarded-permissions/polling.ts +0 -411
@@ -1,17 +1,10 @@
1
1
  import { beforeEach, describe, expect, it, vi } from "vitest";
2
2
 
3
- // ── Module mocks ────────────────────────────────────────────────────────────
3
+ // ── Injected mock ───────────────────────────────────────────────────────────
4
4
 
5
- const { mockConfirmPermission } = vi.hoisted(() => ({
6
- mockConfirmPermission: vi.fn(),
7
- }));
5
+ const mockRequestApproval = vi.fn();
8
6
 
9
- vi.mock("../src/forwarded-permissions/polling", () => ({
10
- confirmPermission: mockConfirmPermission,
11
- processForwardedPermissionRequests: vi.fn().mockResolvedValue(undefined),
12
- }));
13
-
14
- // ── Imports (after mocks) ───────────────────────────────────────────────────
7
+ // ── Imports ─────────────────────────────────────────────────────────────────
15
8
 
16
9
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
17
10
  import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
@@ -51,12 +44,8 @@ function makeDeps(
51
44
  return {
52
45
  getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG, yoloMode: false }),
53
46
  writeReviewLog: vi.fn(),
54
- subagentSessionsDir: "/sessions/subagents",
55
- forwardingDir: "/sessions/permission-forwarding",
56
47
  events: { emit: vi.fn(), on: vi.fn().mockReturnValue(() => undefined) },
57
- requestPermissionDecisionFromUi: vi
58
- .fn()
59
- .mockResolvedValue({ approved: true, state: "approved" }),
48
+ forwarder: { requestApproval: mockRequestApproval },
60
49
  ...overrides,
61
50
  };
62
51
  }
@@ -65,7 +54,11 @@ function makeDeps(
65
54
 
66
55
  describe("PermissionPrompter", () => {
67
56
  beforeEach(() => {
68
- mockConfirmPermission.mockReset();
57
+ mockRequestApproval.mockReset();
58
+ mockRequestApproval.mockResolvedValue({
59
+ approved: true,
60
+ state: "approved",
61
+ });
69
62
  });
70
63
 
71
64
  // ── Yolo-mode auto-approve ───────────────────────────────────────────────
@@ -89,7 +82,7 @@ describe("PermissionPrompter", () => {
89
82
  state: "approved",
90
83
  autoApproved: true,
91
84
  });
92
- expect(mockConfirmPermission).not.toHaveBeenCalled();
85
+ expect(mockRequestApproval).not.toHaveBeenCalled();
93
86
  expect(events.emit).not.toHaveBeenCalledWith(
94
87
  "permissions:ui_prompt",
95
88
  expect.anything(),
@@ -136,7 +129,7 @@ describe("PermissionPrompter", () => {
136
129
 
137
130
  await prompter.prompt(makeCtx(true), makeDetails());
138
131
 
139
- expect(mockConfirmPermission).not.toHaveBeenCalled();
132
+ expect(mockRequestApproval).not.toHaveBeenCalled();
140
133
  });
141
134
  });
142
135
 
@@ -149,7 +142,7 @@ describe("PermissionPrompter", () => {
149
142
  approved: true,
150
143
  state: "approved",
151
144
  };
152
- mockConfirmPermission.mockResolvedValue(approved);
145
+ mockRequestApproval.mockResolvedValue(approved);
153
146
  const deps = makeDeps({ writeReviewLog });
154
147
  const prompter = new PermissionPrompter(deps);
155
148
 
@@ -169,7 +162,7 @@ describe("PermissionPrompter", () => {
169
162
  emit: vi.fn(),
170
163
  on: vi.fn().mockReturnValue(() => undefined),
171
164
  };
172
- mockConfirmPermission.mockResolvedValue({
165
+ mockRequestApproval.mockResolvedValue({
173
166
  approved: true,
174
167
  state: "approved",
175
168
  });
@@ -201,7 +194,7 @@ describe("PermissionPrompter", () => {
201
194
  emit: vi.fn(),
202
195
  on: vi.fn().mockReturnValue(() => undefined),
203
196
  };
204
- mockConfirmPermission.mockResolvedValue({
197
+ mockRequestApproval.mockResolvedValue({
205
198
  approved: true,
206
199
  state: "approved",
207
200
  });
@@ -233,7 +226,7 @@ describe("PermissionPrompter", () => {
233
226
  emit: vi.fn(),
234
227
  on: vi.fn().mockReturnValue(() => undefined),
235
228
  };
236
- mockConfirmPermission.mockResolvedValue({
229
+ mockRequestApproval.mockResolvedValue({
237
230
  approved: true,
238
231
  state: "approved",
239
232
  });
@@ -250,7 +243,7 @@ describe("PermissionPrompter", () => {
250
243
 
251
244
  it("logs permission_request.approved when confirmPermission returns approved", async () => {
252
245
  const writeReviewLog = vi.fn();
253
- mockConfirmPermission.mockResolvedValue({
246
+ mockRequestApproval.mockResolvedValue({
254
247
  approved: true,
255
248
  state: "approved",
256
249
  });
@@ -270,7 +263,7 @@ describe("PermissionPrompter", () => {
270
263
 
271
264
  it("logs permission_request.denied when confirmPermission returns denied", async () => {
272
265
  const writeReviewLog = vi.fn();
273
- mockConfirmPermission.mockResolvedValue({
266
+ mockRequestApproval.mockResolvedValue({
274
267
  approved: false,
275
268
  state: "denied",
276
269
  });
@@ -290,7 +283,7 @@ describe("PermissionPrompter", () => {
290
283
 
291
284
  it("logs permission_request.denied with denialReason when present", async () => {
292
285
  const writeReviewLog = vi.fn();
293
- mockConfirmPermission.mockResolvedValue({
286
+ mockRequestApproval.mockResolvedValue({
294
287
  approved: false,
295
288
  state: "denied_with_reason",
296
289
  denialReason: "too sensitive",
@@ -314,7 +307,7 @@ describe("PermissionPrompter", () => {
314
307
  state: "denied_with_reason",
315
308
  denialReason: "sensitive",
316
309
  };
317
- mockConfirmPermission.mockResolvedValue(decision);
310
+ mockRequestApproval.mockResolvedValue(decision);
318
311
  const deps = makeDeps();
319
312
  const prompter = new PermissionPrompter(deps);
320
313
 
@@ -324,7 +317,7 @@ describe("PermissionPrompter", () => {
324
317
  });
325
318
 
326
319
  it("passes sessionLabel option to confirmPermission when present", async () => {
327
- mockConfirmPermission.mockResolvedValue({
320
+ mockRequestApproval.mockResolvedValue({
328
321
  approved: true,
329
322
  state: "approved",
330
323
  });
@@ -334,17 +327,16 @@ describe("PermissionPrompter", () => {
334
327
 
335
328
  await prompter.prompt(makeCtx(true), details);
336
329
 
337
- expect(mockConfirmPermission).toHaveBeenCalledWith(
330
+ expect(mockRequestApproval).toHaveBeenCalledWith(
338
331
  expect.anything(),
339
332
  expect.any(String),
340
- expect.anything(),
341
333
  { sessionLabel: "Yes, for 'read' tool" },
342
334
  { source: "tool_call", surface: "read", value: "read" },
343
335
  );
344
336
  });
345
337
 
346
338
  it("passes the display fields (source/surface/value) to confirmPermission", async () => {
347
- mockConfirmPermission.mockResolvedValue({
339
+ mockRequestApproval.mockResolvedValue({
348
340
  approved: true,
349
341
  state: "approved",
350
342
  });
@@ -357,17 +349,16 @@ describe("PermissionPrompter", () => {
357
349
 
358
350
  await prompter.prompt(makeCtx(false), details);
359
351
 
360
- expect(mockConfirmPermission).toHaveBeenCalledWith(
352
+ expect(mockRequestApproval).toHaveBeenCalledWith(
361
353
  expect.anything(),
362
354
  expect.any(String),
363
- expect.anything(),
364
355
  undefined,
365
356
  { source: "tool_call", surface: "bash", value: "git push" },
366
357
  );
367
358
  });
368
359
 
369
360
  it("passes undefined options to confirmPermission when sessionLabel is absent", async () => {
370
- mockConfirmPermission.mockResolvedValue({
361
+ mockRequestApproval.mockResolvedValue({
371
362
  approved: true,
372
363
  state: "approved",
373
364
  });
@@ -376,17 +367,16 @@ describe("PermissionPrompter", () => {
376
367
 
377
368
  await prompter.prompt(makeCtx(true), makeDetails());
378
369
 
379
- expect(mockConfirmPermission).toHaveBeenCalledWith(
370
+ expect(mockRequestApproval).toHaveBeenCalledWith(
380
371
  expect.anything(),
381
372
  expect.any(String),
382
- expect.anything(),
383
373
  undefined,
384
374
  { source: "tool_call", surface: "read", value: "read" },
385
375
  );
386
376
  });
387
377
 
388
378
  it("passes the message from details to confirmPermission", async () => {
389
- mockConfirmPermission.mockResolvedValue({
379
+ mockRequestApproval.mockResolvedValue({
390
380
  approved: true,
391
381
  state: "approved",
392
382
  });
@@ -396,10 +386,9 @@ describe("PermissionPrompter", () => {
396
386
 
397
387
  await prompter.prompt(makeCtx(true), details);
398
388
 
399
- expect(mockConfirmPermission).toHaveBeenCalledWith(
389
+ expect(mockRequestApproval).toHaveBeenCalledWith(
400
390
  expect.anything(),
401
391
  "Allow bash: git status?",
402
- expect.anything(),
403
392
  undefined,
404
393
  { source: "tool_call", surface: "read", value: "read" },
405
394
  );
@@ -411,7 +400,7 @@ describe("PermissionPrompter", () => {
411
400
  describe("review log fields", () => {
412
401
  it("includes all standard fields in the waiting log entry", async () => {
413
402
  const writeReviewLog = vi.fn();
414
- mockConfirmPermission.mockResolvedValue({
403
+ mockRequestApproval.mockResolvedValue({
415
404
  approved: true,
416
405
  state: "approved",
417
406
  });
@@ -448,7 +437,7 @@ describe("PermissionPrompter", () => {
448
437
 
449
438
  it("uses null for optional fields not present in details", async () => {
450
439
  const writeReviewLog = vi.fn();
451
- mockConfirmPermission.mockResolvedValue({
440
+ mockRequestApproval.mockResolvedValue({
452
441
  approved: true,
453
442
  state: "approved",
454
443
  });
@@ -479,13 +468,13 @@ describe("PermissionPrompter", () => {
479
468
  approved: true,
480
469
  state: "approved",
481
470
  };
482
- mockConfirmPermission.mockResolvedValue(forwarded);
471
+ mockRequestApproval.mockResolvedValue(forwarded);
483
472
  const deps = makeDeps();
484
473
  const prompter = new PermissionPrompter(deps);
485
474
 
486
475
  await prompter.prompt(makeCtx(false), makeDetails());
487
476
 
488
- expect(mockConfirmPermission).toHaveBeenCalled();
477
+ expect(mockRequestApproval).toHaveBeenCalled();
489
478
  });
490
479
 
491
480
  it("returns the decision from confirmPermission in the subagent path", async () => {
@@ -493,7 +482,7 @@ describe("PermissionPrompter", () => {
493
482
  approved: false,
494
483
  state: "denied",
495
484
  };
496
- mockConfirmPermission.mockResolvedValue(forwarded);
485
+ mockRequestApproval.mockResolvedValue(forwarded);
497
486
  const deps = makeDeps();
498
487
  const prompter = new PermissionPrompter(deps);
499
488
 
@@ -504,7 +493,7 @@ describe("PermissionPrompter", () => {
504
493
 
505
494
  it("logs the outcome when confirmPermission resolves via forwarding", async () => {
506
495
  const writeReviewLog = vi.fn();
507
- mockConfirmPermission.mockResolvedValue({
496
+ mockRequestApproval.mockResolvedValue({
508
497
  approved: true,
509
498
  state: "approved",
510
499
  });
@@ -217,6 +217,79 @@ describe("PermissionSession", () => {
217
217
  });
218
218
  });
219
219
 
220
+ describe("resolve", () => {
221
+ it("forwards surface, input, and agentName, applying the empty session ruleset", () => {
222
+ const pm = makePermissionManager();
223
+ mockCreatePermissionManagerForCwd.mockReturnValue(pm);
224
+ const { session } = createSession();
225
+
226
+ session.resolve("bash", { command: "ls" }, "agent-x");
227
+
228
+ expect(pm.checkPermission).toHaveBeenCalledWith(
229
+ "bash",
230
+ { command: "ls" },
231
+ "agent-x",
232
+ [],
233
+ );
234
+ });
235
+
236
+ it("defaults agentName to undefined when omitted", () => {
237
+ const pm = makePermissionManager();
238
+ mockCreatePermissionManagerForCwd.mockReturnValue(pm);
239
+ const { session } = createSession();
240
+
241
+ session.resolve("read", { path: ".env" });
242
+
243
+ expect(pm.checkPermission).toHaveBeenCalledWith(
244
+ "read",
245
+ { path: ".env" },
246
+ undefined,
247
+ [],
248
+ );
249
+ });
250
+
251
+ it("applies a recorded session approval on the next resolve", () => {
252
+ const pm = makePermissionManager();
253
+ mockCreatePermissionManagerForCwd.mockReturnValue(pm);
254
+ const { session } = createSession();
255
+
256
+ session.recordSessionApproval(SessionApproval.single("bash", "git *"));
257
+ session.resolve("bash", { command: "git status" });
258
+
259
+ const sessionRules = vi.mocked(pm.checkPermission).mock.calls[0][3];
260
+ expect(sessionRules).toHaveLength(1);
261
+ expect(sessionRules?.[0]).toMatchObject({
262
+ surface: "bash",
263
+ pattern: "git *",
264
+ action: "allow",
265
+ });
266
+ });
267
+
268
+ it("returns the PermissionManager's check result", () => {
269
+ const pm = makePermissionManager({
270
+ checkPermission: vi.fn().mockReturnValue({
271
+ state: "deny",
272
+ toolName: "bash",
273
+ source: "bash",
274
+ origin: "global",
275
+ matchedPattern: "rm *",
276
+ }),
277
+ });
278
+ mockCreatePermissionManagerForCwd.mockReturnValue(pm);
279
+ const { session } = createSession();
280
+
281
+ const result = session.resolve("bash", { command: "rm -rf /" });
282
+
283
+ expect(result).toEqual({
284
+ state: "deny",
285
+ toolName: "bash",
286
+ source: "bash",
287
+ origin: "global",
288
+ matchedPattern: "rm *",
289
+ });
290
+ });
291
+ });
292
+
220
293
  describe("activate and deactivate", () => {
221
294
  it("stores the context on activate", () => {
222
295
  const { session, forwarding } = createSession();
@@ -432,26 +505,25 @@ describe("PermissionSession", () => {
432
505
  });
433
506
 
434
507
  describe("infrastructure paths", () => {
435
- it("getInfrastructureDirs returns paths from ExtensionPaths", () => {
436
- const { session } = createSession();
437
- expect(session.getInfrastructureDirs()).toEqual([
438
- "/test/agent",
439
- "/test/agent/git",
440
- ]);
441
- });
442
-
443
- it("getInfrastructureReadPaths returns config paths", () => {
508
+ it("getInfrastructureReadDirs combines piInfrastructureDirs and piInfrastructureReadPaths", () => {
444
509
  const runtimeDeps = makeRuntimeDeps();
445
510
  (runtimeDeps.getConfig as ReturnType<typeof vi.fn>).mockReturnValue({
446
511
  piInfrastructureReadPaths: ["/extra/path"],
447
512
  });
448
513
  const { session } = createSession({ runtimeDeps });
449
- expect(session.getInfrastructureReadPaths()).toEqual(["/extra/path"]);
514
+ expect(session.getInfrastructureReadDirs()).toEqual([
515
+ "/test/agent",
516
+ "/test/agent/git",
517
+ "/extra/path",
518
+ ]);
450
519
  });
451
520
 
452
- it("getInfrastructureReadPaths returns empty when config omits the field", () => {
521
+ it("getInfrastructureReadDirs returns only piInfrastructureDirs when config omits the field", () => {
453
522
  const { session } = createSession();
454
- expect(session.getInfrastructureReadPaths()).toEqual([]);
523
+ expect(session.getInfrastructureReadDirs()).toEqual([
524
+ "/test/agent",
525
+ "/test/agent/git",
526
+ ]);
455
527
  });
456
528
  });
457
529
 
@@ -478,6 +550,26 @@ describe("PermissionSession", () => {
478
550
  const { session } = createSession({ runtimeDeps });
479
551
  expect(session.config).toBe(fakeConfig);
480
552
  });
553
+
554
+ it("getToolPreviewLimits returns resolved preview limits from config", () => {
555
+ const runtimeDeps = makeRuntimeDeps();
556
+ (runtimeDeps.getConfig as ReturnType<typeof vi.fn>).mockReturnValue({
557
+ toolInputPreviewMaxLength: 400,
558
+ toolTextSummaryMaxLength: 120,
559
+ });
560
+ const { session } = createSession({ runtimeDeps });
561
+ const limits = session.getToolPreviewLimits();
562
+ expect(limits.toolInputPreviewMaxLength).toBe(400);
563
+ expect(limits.toolTextSummaryMaxLength).toBe(120);
564
+ });
565
+
566
+ it("getToolPreviewLimits falls back to built-in defaults when config omits fields", () => {
567
+ const { session } = createSession();
568
+ const limits = session.getToolPreviewLimits();
569
+ expect(limits.toolInputPreviewMaxLength).toBeGreaterThan(0);
570
+ expect(limits.toolTextSummaryMaxLength).toBeGreaterThan(0);
571
+ expect(limits.toolInputLogPreviewMaxLength).toBeGreaterThan(0);
572
+ });
481
573
  });
482
574
 
483
575
  describe("reload", () => {
@@ -532,6 +624,62 @@ describe("PermissionSession", () => {
532
624
  });
533
625
  });
534
626
 
627
+ describe("canConfirm", () => {
628
+ it("returns true when context is active and canPrompt returns true", () => {
629
+ const { session } = createSession();
630
+ session.activate(makeCtx());
631
+ expect(session.canConfirm()).toBe(true);
632
+ });
633
+
634
+ it("returns false when no context is active", () => {
635
+ const { session } = createSession();
636
+ expect(session.canConfirm()).toBe(false);
637
+ });
638
+
639
+ it("returns false when canPrompt returns false", () => {
640
+ const runtimeDeps = makeRuntimeDeps();
641
+ (
642
+ runtimeDeps.canRequestPermissionConfirmation as ReturnType<typeof vi.fn>
643
+ ).mockReturnValue(false);
644
+ const { session } = createSession({ runtimeDeps });
645
+ session.activate(makeCtx());
646
+ expect(session.canConfirm()).toBe(false);
647
+ });
648
+ });
649
+
650
+ describe("promptPermission", () => {
651
+ it("delegates to prompt with stored context", async () => {
652
+ const { session, runtimeDeps } = createSession();
653
+ const ctx = makeCtx();
654
+ session.activate(ctx);
655
+ const details = {
656
+ requestId: "req-1",
657
+ source: "tool_call" as const,
658
+ agentName: null,
659
+ message: "Allow?",
660
+ };
661
+
662
+ const result = await session.promptPermission(details);
663
+
664
+ expect(runtimeDeps.promptPermission).toHaveBeenCalledWith(ctx, details);
665
+ expect(result).toEqual({ approved: true, state: "approved" });
666
+ });
667
+
668
+ it("throws when no context is active", async () => {
669
+ const { session } = createSession();
670
+ const details = {
671
+ requestId: "req-1",
672
+ source: "tool_call" as const,
673
+ agentName: null,
674
+ message: "Allow?",
675
+ };
676
+
677
+ await expect(session.promptPermission(details)).rejects.toThrow(
678
+ "promptPermission called before the session was activated",
679
+ );
680
+ });
681
+ });
682
+
535
683
  describe("canPrompt", () => {
536
684
  it("delegates to runtimeDeps.canRequestPermissionConfirmation", () => {
537
685
  const { session, runtimeDeps } = createSession();
@@ -573,19 +721,4 @@ describe("PermissionSession", () => {
573
721
  expect(result).toEqual({ approved: true, state: "approved" });
574
722
  });
575
723
  });
576
-
577
- describe("createPermissionRequestId", () => {
578
- it("starts with the given prefix", () => {
579
- const { session } = createSession();
580
- const id = session.createPermissionRequestId("skill-input");
581
- expect(id.startsWith("skill-input-")).toBe(true);
582
- });
583
-
584
- it("generates unique IDs on repeated calls", () => {
585
- const { session } = createSession();
586
- const id1 = session.createPermissionRequestId("test");
587
- const id2 = session.createPermissionRequestId("test");
588
- expect(id1).not.toBe(id2);
589
- });
590
- });
591
724
  });
@@ -0,0 +1,151 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { PermissionManager } from "#src/permission-manager";
3
+ import { LocalPermissionsService } from "#src/permissions-service";
4
+ import type { Ruleset } from "#src/rule";
5
+ import type { SessionRules } from "#src/session-rules";
6
+ import type {
7
+ ToolInputFormatter,
8
+ ToolInputFormatterRegistry,
9
+ } from "#src/tool-input-formatter-registry";
10
+
11
+ import { makeCheckResult } from "#test/helpers/handler-fixtures";
12
+
13
+ // ── input-normalizer stub ──────────────────────────────────────────────────
14
+
15
+ const mockBuildInputForSurface = vi.hoisted(() =>
16
+ vi.fn<(surface: string, value?: string) => unknown>(),
17
+ );
18
+
19
+ vi.mock("#src/input-normalizer", () => ({
20
+ buildInputForSurface: mockBuildInputForSurface,
21
+ }));
22
+
23
+ // ── helpers ────────────────────────────────────────────────────────────────
24
+
25
+ function makePermissionManager(): PermissionManager {
26
+ return {
27
+ checkPermission: vi
28
+ .fn<PermissionManager["checkPermission"]>()
29
+ .mockReturnValue(makeCheckResult()),
30
+ getToolPermission: vi
31
+ .fn<PermissionManager["getToolPermission"]>()
32
+ .mockReturnValue("allow"),
33
+ } as unknown as PermissionManager;
34
+ }
35
+
36
+ function makeSessionRules(rules: Ruleset = []): SessionRules {
37
+ return {
38
+ getRuleset: vi.fn<SessionRules["getRuleset"]>().mockReturnValue(rules),
39
+ } as unknown as SessionRules;
40
+ }
41
+
42
+ function makeFormatterRegistry(): ToolInputFormatterRegistry {
43
+ return {
44
+ register: vi
45
+ .fn<ToolInputFormatterRegistry["register"]>()
46
+ .mockReturnValue(vi.fn()),
47
+ } as unknown as ToolInputFormatterRegistry;
48
+ }
49
+
50
+ function makeService(overrides?: {
51
+ permissionManager?: PermissionManager;
52
+ sessionRules?: SessionRules;
53
+ formatterRegistry?: ToolInputFormatterRegistry;
54
+ }) {
55
+ const permissionManager =
56
+ overrides?.permissionManager ?? makePermissionManager();
57
+ const sessionRules = overrides?.sessionRules ?? makeSessionRules();
58
+ const formatterRegistry =
59
+ overrides?.formatterRegistry ?? makeFormatterRegistry();
60
+ const service = new LocalPermissionsService(
61
+ permissionManager,
62
+ sessionRules,
63
+ formatterRegistry,
64
+ );
65
+ return { service, permissionManager, sessionRules, formatterRegistry };
66
+ }
67
+
68
+ // ── tests ──────────────────────────────────────────────────────────────────
69
+
70
+ beforeEach(() => {
71
+ mockBuildInputForSurface.mockReset();
72
+ mockBuildInputForSurface.mockReturnValue({ type: "tool-input" });
73
+ });
74
+
75
+ describe("checkPermission", () => {
76
+ it("builds the surface input from surface and value", () => {
77
+ const { service } = makeService();
78
+ service.checkPermission("bash", "echo hi");
79
+ expect(mockBuildInputForSurface).toHaveBeenCalledWith("bash", "echo hi");
80
+ });
81
+
82
+ it("builds the surface input with undefined value when value is omitted", () => {
83
+ const { service } = makeService();
84
+ service.checkPermission("read");
85
+ expect(mockBuildInputForSurface).toHaveBeenCalledWith("read", undefined);
86
+ });
87
+
88
+ it("calls permissionManager.checkPermission with surface, built input, agentName, and current ruleset", () => {
89
+ const ruleset: Ruleset = [
90
+ { surface: "bash", pattern: "*", action: "allow", origin: "global" },
91
+ ];
92
+ const builtInput = { type: "bash-input" };
93
+ mockBuildInputForSurface.mockReturnValue(builtInput);
94
+ const { service, permissionManager, sessionRules } = makeService({
95
+ sessionRules: makeSessionRules(ruleset),
96
+ });
97
+ service.checkPermission("bash", "echo hi", "my-agent");
98
+ expect(permissionManager.checkPermission).toHaveBeenCalledWith(
99
+ "bash",
100
+ builtInput,
101
+ "my-agent",
102
+ ruleset,
103
+ );
104
+ void sessionRules; // used indirectly
105
+ });
106
+
107
+ it("returns the result from permissionManager.checkPermission", () => {
108
+ const expected = makeCheckResult({ state: "deny", toolName: "bash" });
109
+ const { service, permissionManager } = makeService();
110
+ vi.mocked(permissionManager.checkPermission).mockReturnValue(expected);
111
+ const result = service.checkPermission("bash", "rm -rf /");
112
+ expect(result).toBe(expected);
113
+ });
114
+ });
115
+
116
+ describe("getToolPermission", () => {
117
+ it("delegates to permissionManager.getToolPermission", () => {
118
+ const { service, permissionManager } = makeService();
119
+ vi.mocked(permissionManager.getToolPermission).mockReturnValue("deny");
120
+ const result = service.getToolPermission("write", "my-agent");
121
+ expect(permissionManager.getToolPermission).toHaveBeenCalledWith(
122
+ "write",
123
+ "my-agent",
124
+ );
125
+ expect(result).toBe("deny");
126
+ });
127
+
128
+ it("omits agentName when not provided", () => {
129
+ const { service, permissionManager } = makeService();
130
+ service.getToolPermission("read");
131
+ expect(permissionManager.getToolPermission).toHaveBeenCalledWith(
132
+ "read",
133
+ undefined,
134
+ );
135
+ });
136
+ });
137
+
138
+ describe("registerToolInputFormatter", () => {
139
+ it("delegates to formatterRegistry.register and returns the unsubscribe function", () => {
140
+ const unsub = vi.fn();
141
+ const { service, formatterRegistry } = makeService();
142
+ vi.mocked(formatterRegistry.register).mockReturnValue(unsub);
143
+ const formatter: ToolInputFormatter = vi.fn();
144
+ const result = service.registerToolInputFormatter("my-tool", formatter);
145
+ expect(formatterRegistry.register).toHaveBeenCalledWith(
146
+ "my-tool",
147
+ formatter,
148
+ );
149
+ expect(result).toBe(unsub);
150
+ });
151
+ });
@@ -49,10 +49,6 @@ vi.mock("../src/config-reporter", () => ({
49
49
  buildResolvedConfigLogEntry: mockBuildResolvedConfigLogEntry,
50
50
  }));
51
51
 
52
- vi.mock("../src/forwarded-permissions/polling", () => ({
53
- processForwardedPermissionRequests: vi.fn().mockResolvedValue(undefined),
54
- }));
55
-
56
52
  vi.mock("../src/subagent-context", () => ({
57
53
  isSubagentExecutionContext: vi.fn().mockReturnValue(false),
58
54
  }));