@gotgenes/pi-permission-system 10.0.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.
Files changed (68) hide show
  1. package/CHANGELOG.md +33 -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 +53 -69
  24. package/src/mcp-targets.ts +56 -46
  25. package/src/permission-manager.ts +69 -3
  26. package/src/permission-prompter.ts +7 -58
  27. package/src/permission-resolver.ts +17 -0
  28. package/src/permission-session.ts +83 -27
  29. package/src/permissions-service.ts +53 -0
  30. package/src/runtime.ts +1 -37
  31. package/src/service-lifecycle.ts +49 -0
  32. package/src/session-approval-recorder.ts +6 -0
  33. package/src/session-lifecycle-session.ts +24 -0
  34. package/src/tool-input-preview.ts +0 -62
  35. package/src/tool-input-prompt-formatters.ts +63 -0
  36. package/src/tool-preview-formatter.ts +6 -4
  37. package/test/decision-reporter.test.ts +112 -0
  38. package/test/denial-messages.test.ts +62 -0
  39. package/test/forwarding-manager.test.ts +26 -44
  40. package/test/handlers/before-agent-start.test.ts +45 -21
  41. package/test/handlers/external-directory-integration.test.ts +83 -114
  42. package/test/handlers/external-directory-session-dedup.test.ts +102 -55
  43. package/test/handlers/gates/bash-command.test.ts +49 -90
  44. package/test/handlers/gates/bash-external-directory.test.ts +54 -95
  45. package/test/handlers/gates/bash-path.test.ts +54 -157
  46. package/test/handlers/gates/path.test.ts +38 -105
  47. package/test/handlers/gates/runner.test.ts +151 -186
  48. package/test/handlers/gates/skill-input-gate-pipeline.test.ts +176 -0
  49. package/test/handlers/gates/skill-input.test.ts +128 -0
  50. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +180 -0
  51. package/test/handlers/input.test.ts +1 -2
  52. package/test/handlers/lifecycle.test.ts +49 -33
  53. package/test/handlers/tool-call-events.test.ts +1 -1
  54. package/test/handlers/tool-call.test.ts +44 -153
  55. package/test/helpers/gate-fixtures.ts +212 -17
  56. package/test/helpers/handler-fixtures.ts +226 -29
  57. package/test/mcp-targets.test.ts +55 -0
  58. package/test/permission-forwarder.test.ts +295 -0
  59. package/test/permission-forwarding.test.ts +0 -282
  60. package/test/permission-manager-unified.test.ts +159 -1
  61. package/test/permission-prompter.test.ts +33 -44
  62. package/test/permission-session.test.ts +211 -105
  63. package/test/permissions-service.test.ts +151 -0
  64. package/test/runtime.test.ts +2 -86
  65. package/test/service-lifecycle.test.ts +162 -0
  66. package/test/tool-input-preview.test.ts +0 -111
  67. package/test/tool-input-prompt-formatters.test.ts +115 -0
  68. package/src/forwarded-permissions/polling.ts +0 -411
@@ -3,42 +3,32 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
3
3
 
4
4
  // ── Module mocks (hoisted) ─────────────────────────────────────────────────
5
5
 
6
- const {
7
- mockGetActiveAgentName,
8
- mockGetActiveAgentNameFromSystemPrompt,
9
- mockCreatePermissionManagerForCwd,
10
- } = vi.hoisted(() => ({
11
- mockGetActiveAgentName: vi.fn<(ctx: ExtensionContext) => string | null>(),
12
- mockGetActiveAgentNameFromSystemPrompt:
13
- vi.fn<(systemPrompt?: string) => string | null>(),
14
- mockCreatePermissionManagerForCwd: vi.fn(),
15
- }));
6
+ const { mockGetActiveAgentName, mockGetActiveAgentNameFromSystemPrompt } =
7
+ vi.hoisted(() => ({
8
+ mockGetActiveAgentName: vi.fn<(ctx: ExtensionContext) => string | null>(),
9
+ mockGetActiveAgentNameFromSystemPrompt:
10
+ vi.fn<(systemPrompt?: string) => string | null>(),
11
+ }));
16
12
 
17
13
  vi.mock("../src/active-agent", () => ({
18
14
  getActiveAgentName: mockGetActiveAgentName,
19
15
  getActiveAgentNameFromSystemPrompt: mockGetActiveAgentNameFromSystemPrompt,
20
16
  }));
21
17
 
22
- vi.mock("../src/runtime", async (importOriginal) => {
23
- const original = await importOriginal<typeof import("../src/runtime")>();
24
- return {
25
- ...original,
26
- createPermissionManagerForCwd: mockCreatePermissionManagerForCwd,
27
- };
28
- });
29
-
30
18
  // ── Test helpers ───────────────────────────────────────────────────────────
31
19
 
32
20
  import type { ExtensionPaths } from "#src/extension-paths";
33
21
  import type { ForwardingController } from "#src/forwarding-manager";
34
- import type { PermissionManager } from "#src/permission-manager";
22
+ import type { ScopedPermissionManager } from "#src/permission-manager";
35
23
  import {
36
24
  PermissionSession,
37
25
  type PermissionSessionRuntimeDeps,
38
26
  } from "#src/permission-session";
27
+ import type { Ruleset } from "#src/rule";
39
28
  import { SessionApproval } from "#src/session-approval";
40
29
  import type { SessionLogger } from "#src/session-logger";
41
30
  import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
31
+ import type { PermissionCheckResult, PermissionState } from "#src/types";
42
32
  import { makeCtx } from "#test/helpers/handler-fixtures";
43
33
 
44
34
  function makeSkillEntry(
@@ -95,29 +85,37 @@ function makeForwarding(): ForwardingController {
95
85
  };
96
86
  }
97
87
 
98
- function makePermissionManager(
99
- overrides: Partial<PermissionManager> = {},
100
- ): PermissionManager {
88
+ function makePermissionManager() {
101
89
  return {
102
- checkPermission: vi.fn().mockReturnValue({
103
- state: "allow",
104
- toolName: "read",
105
- source: "tool",
106
- origin: "builtin",
107
- }),
108
- getToolPermission: vi.fn().mockReturnValue("allow"),
109
- getConfigIssues: vi.fn().mockReturnValue([]),
110
- getPolicyCacheStamp: vi.fn().mockReturnValue("stamp-1"),
111
- getComposedConfigRules: vi.fn().mockReturnValue([]),
112
- getResolvedPolicyPaths: vi.fn().mockReturnValue({}),
113
- ...overrides,
114
- } as unknown as PermissionManager;
90
+ configureForCwd: vi.fn<(cwd: string | undefined | null) => void>(),
91
+ checkPermission: vi
92
+ .fn<
93
+ (
94
+ toolName: string,
95
+ input: unknown,
96
+ agentName?: string,
97
+ sessionRules?: Ruleset,
98
+ ) => PermissionCheckResult
99
+ >()
100
+ .mockReturnValue({
101
+ state: "allow",
102
+ toolName: "read",
103
+ source: "tool",
104
+ origin: "builtin",
105
+ }),
106
+ getToolPermission: vi
107
+ .fn<(toolName: string, agentName?: string) => PermissionState>()
108
+ .mockReturnValue("allow"),
109
+ getConfigIssues: vi.fn((): string[] => []),
110
+ getPolicyCacheStamp: vi.fn((): string => "stamp-1"),
111
+ };
115
112
  }
116
113
 
117
114
  function createSession(overrides?: {
118
115
  paths?: Partial<ExtensionPaths>;
119
116
  logger?: SessionLogger;
120
117
  forwarding?: ForwardingController;
118
+ permissionManager?: ScopedPermissionManager;
121
119
  runtimeDeps?: PermissionSessionRuntimeDeps;
122
120
  }): {
123
121
  session: PermissionSession;
@@ -129,8 +127,16 @@ function createSession(overrides?: {
129
127
  const paths = makePaths(overrides?.paths);
130
128
  const logger = overrides?.logger ?? makeLogger();
131
129
  const forwarding = overrides?.forwarding ?? makeForwarding();
130
+ const permissionManager =
131
+ overrides?.permissionManager ?? makePermissionManager();
132
132
  const runtimeDeps = overrides?.runtimeDeps ?? makeRuntimeDeps();
133
- const session = new PermissionSession(paths, logger, forwarding, runtimeDeps);
133
+ const session = new PermissionSession(
134
+ paths,
135
+ logger,
136
+ forwarding,
137
+ permissionManager,
138
+ runtimeDeps,
139
+ );
134
140
  return { session, paths, logger, forwarding, runtimeDeps };
135
141
  }
136
142
 
@@ -139,10 +145,6 @@ function createSession(overrides?: {
139
145
  beforeEach(() => {
140
146
  mockGetActiveAgentName.mockReset();
141
147
  mockGetActiveAgentNameFromSystemPrompt.mockReset();
142
- mockCreatePermissionManagerForCwd.mockReset();
143
-
144
- // Default: createPermissionManagerForCwd returns a fresh mock PM
145
- mockCreatePermissionManagerForCwd.mockReturnValue(makePermissionManager());
146
148
  mockGetActiveAgentName.mockReturnValue(null);
147
149
  mockGetActiveAgentNameFromSystemPrompt.mockReturnValue(null);
148
150
  });
@@ -151,8 +153,7 @@ describe("PermissionSession", () => {
151
153
  describe("constructor and delegation", () => {
152
154
  it("delegates checkPermission to internal PermissionManager", () => {
153
155
  const pm = makePermissionManager();
154
- mockCreatePermissionManagerForCwd.mockReturnValue(pm);
155
- const { session } = createSession();
156
+ const { session } = createSession({ permissionManager: pm });
156
157
 
157
158
  const result = session.checkPermission("bash", { command: "ls" });
158
159
 
@@ -167,8 +168,7 @@ describe("PermissionSession", () => {
167
168
 
168
169
  it("delegates getToolPermission to internal PermissionManager", () => {
169
170
  const pm = makePermissionManager();
170
- mockCreatePermissionManagerForCwd.mockReturnValue(pm);
171
- const { session } = createSession();
171
+ const { session } = createSession({ permissionManager: pm });
172
172
 
173
173
  const result = session.getToolPermission("read");
174
174
 
@@ -177,11 +177,9 @@ describe("PermissionSession", () => {
177
177
  });
178
178
 
179
179
  it("delegates getConfigIssues to internal PermissionManager", () => {
180
- const pm = makePermissionManager({
181
- getConfigIssues: vi.fn().mockReturnValue(["issue1"]),
182
- });
183
- mockCreatePermissionManagerForCwd.mockReturnValue(pm);
184
- const { session } = createSession();
180
+ const pm = makePermissionManager();
181
+ vi.mocked(pm.getConfigIssues).mockReturnValue(["issue1"]);
182
+ const { session } = createSession({ permissionManager: pm });
185
183
 
186
184
  expect(session.getConfigIssues("agent1")).toEqual(["issue1"]);
187
185
  expect(pm.getConfigIssues).toHaveBeenCalledWith("agent1");
@@ -189,8 +187,7 @@ describe("PermissionSession", () => {
189
187
 
190
188
  it("delegates getPolicyCacheStamp to internal PermissionManager", () => {
191
189
  const pm = makePermissionManager();
192
- mockCreatePermissionManagerForCwd.mockReturnValue(pm);
193
- const { session } = createSession();
190
+ const { session } = createSession({ permissionManager: pm });
194
191
 
195
192
  expect(session.getPolicyCacheStamp("agent1")).toBe("stamp-1");
196
193
  expect(pm.getPolicyCacheStamp).toHaveBeenCalledWith("agent1");
@@ -217,6 +214,74 @@ describe("PermissionSession", () => {
217
214
  });
218
215
  });
219
216
 
217
+ describe("resolve", () => {
218
+ it("forwards surface, input, and agentName, applying the empty session ruleset", () => {
219
+ const pm = makePermissionManager();
220
+ const { session } = createSession({ permissionManager: pm });
221
+
222
+ session.resolve("bash", { command: "ls" }, "agent-x");
223
+
224
+ expect(pm.checkPermission).toHaveBeenCalledWith(
225
+ "bash",
226
+ { command: "ls" },
227
+ "agent-x",
228
+ [],
229
+ );
230
+ });
231
+
232
+ it("defaults agentName to undefined when omitted", () => {
233
+ const pm = makePermissionManager();
234
+ const { session } = createSession({ permissionManager: pm });
235
+
236
+ session.resolve("read", { path: ".env" });
237
+
238
+ expect(pm.checkPermission).toHaveBeenCalledWith(
239
+ "read",
240
+ { path: ".env" },
241
+ undefined,
242
+ [],
243
+ );
244
+ });
245
+
246
+ it("applies a recorded session approval on the next resolve", () => {
247
+ const pm = makePermissionManager();
248
+ const { session } = createSession({ permissionManager: pm });
249
+
250
+ session.recordSessionApproval(SessionApproval.single("bash", "git *"));
251
+ session.resolve("bash", { command: "git status" });
252
+
253
+ const sessionRules = vi.mocked(pm.checkPermission).mock.calls[0][3];
254
+ expect(sessionRules).toHaveLength(1);
255
+ expect(sessionRules?.[0]).toMatchObject({
256
+ surface: "bash",
257
+ pattern: "git *",
258
+ action: "allow",
259
+ });
260
+ });
261
+
262
+ it("returns the PermissionManager's check result", () => {
263
+ const pm = makePermissionManager();
264
+ vi.mocked(pm.checkPermission).mockReturnValue({
265
+ state: "deny",
266
+ toolName: "bash",
267
+ source: "bash",
268
+ origin: "global",
269
+ matchedPattern: "rm *",
270
+ });
271
+ const { session } = createSession({ permissionManager: pm });
272
+
273
+ const result = session.resolve("bash", { command: "rm -rf /" });
274
+
275
+ expect(result).toEqual({
276
+ state: "deny",
277
+ toolName: "bash",
278
+ source: "bash",
279
+ origin: "global",
280
+ matchedPattern: "rm *",
281
+ });
282
+ });
283
+ });
284
+
220
285
  describe("activate and deactivate", () => {
221
286
  it("stores the context on activate", () => {
222
287
  const { session, forwarding } = createSession();
@@ -237,28 +302,14 @@ describe("PermissionSession", () => {
237
302
  });
238
303
 
239
304
  describe("resetForNewSession", () => {
240
- it("creates a new PermissionManager for the context cwd", () => {
241
- const pm2 = makePermissionManager({
242
- checkPermission: vi.fn().mockReturnValue({
243
- state: "deny",
244
- toolName: "bash",
245
- source: "bash",
246
- origin: "global",
247
- }),
248
- });
249
- mockCreatePermissionManagerForCwd.mockReturnValue(pm2);
250
- const { session } = createSession();
305
+ it("configures the injected PermissionManager for the context cwd", () => {
306
+ const pm = makePermissionManager();
307
+ const { session } = createSession({ permissionManager: pm });
251
308
  const ctx = makeCtx({ cwd: "/new/project" });
252
309
 
253
310
  session.resetForNewSession(ctx);
254
311
 
255
- expect(mockCreatePermissionManagerForCwd).toHaveBeenCalledWith(
256
- "/test/agent",
257
- "/new/project",
258
- );
259
- // Verify the new PM is used for subsequent calls
260
- const result = session.checkPermission("bash", { command: "rm" });
261
- expect(result.state).toBe("deny");
312
+ expect(pm.configureForCwd).toHaveBeenCalledWith("/new/project");
262
313
  });
263
314
 
264
315
  it("clears cache keys", () => {
@@ -432,26 +483,25 @@ describe("PermissionSession", () => {
432
483
  });
433
484
 
434
485
  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", () => {
486
+ it("getInfrastructureReadDirs combines piInfrastructureDirs and piInfrastructureReadPaths", () => {
444
487
  const runtimeDeps = makeRuntimeDeps();
445
488
  (runtimeDeps.getConfig as ReturnType<typeof vi.fn>).mockReturnValue({
446
489
  piInfrastructureReadPaths: ["/extra/path"],
447
490
  });
448
491
  const { session } = createSession({ runtimeDeps });
449
- expect(session.getInfrastructureReadPaths()).toEqual(["/extra/path"]);
492
+ expect(session.getInfrastructureReadDirs()).toEqual([
493
+ "/test/agent",
494
+ "/test/agent/git",
495
+ "/extra/path",
496
+ ]);
450
497
  });
451
498
 
452
- it("getInfrastructureReadPaths returns empty when config omits the field", () => {
499
+ it("getInfrastructureReadDirs returns only piInfrastructureDirs when config omits the field", () => {
453
500
  const { session } = createSession();
454
- expect(session.getInfrastructureReadPaths()).toEqual([]);
501
+ expect(session.getInfrastructureReadDirs()).toEqual([
502
+ "/test/agent",
503
+ "/test/agent/git",
504
+ ]);
455
505
  });
456
506
  });
457
507
 
@@ -478,23 +528,38 @@ describe("PermissionSession", () => {
478
528
  const { session } = createSession({ runtimeDeps });
479
529
  expect(session.config).toBe(fakeConfig);
480
530
  });
531
+
532
+ it("getToolPreviewLimits returns resolved preview limits from config", () => {
533
+ const runtimeDeps = makeRuntimeDeps();
534
+ (runtimeDeps.getConfig as ReturnType<typeof vi.fn>).mockReturnValue({
535
+ toolInputPreviewMaxLength: 400,
536
+ toolTextSummaryMaxLength: 120,
537
+ });
538
+ const { session } = createSession({ runtimeDeps });
539
+ const limits = session.getToolPreviewLimits();
540
+ expect(limits.toolInputPreviewMaxLength).toBe(400);
541
+ expect(limits.toolTextSummaryMaxLength).toBe(120);
542
+ });
543
+
544
+ it("getToolPreviewLimits falls back to built-in defaults when config omits fields", () => {
545
+ const { session } = createSession();
546
+ const limits = session.getToolPreviewLimits();
547
+ expect(limits.toolInputPreviewMaxLength).toBeGreaterThan(0);
548
+ expect(limits.toolTextSummaryMaxLength).toBeGreaterThan(0);
549
+ expect(limits.toolInputLogPreviewMaxLength).toBeGreaterThan(0);
550
+ });
481
551
  });
482
552
 
483
553
  describe("reload", () => {
484
- it("recreates PermissionManager for current context cwd", () => {
485
- const { session } = createSession();
554
+ it("configures PermissionManager for current context cwd", () => {
555
+ const pm = makePermissionManager();
556
+ const { session } = createSession({ permissionManager: pm });
486
557
  const ctx = makeCtx({ cwd: "/project" });
487
558
  session.activate(ctx);
488
559
 
489
- const pm2 = makePermissionManager();
490
- mockCreatePermissionManagerForCwd.mockReturnValue(pm2);
491
-
492
560
  session.reload();
493
561
 
494
- expect(mockCreatePermissionManagerForCwd).toHaveBeenCalledWith(
495
- "/test/agent",
496
- "/project",
497
- );
562
+ expect(pm.configureForCwd).toHaveBeenCalledWith("/project");
498
563
  });
499
564
 
500
565
  it("clears caches and skill entries", () => {
@@ -532,6 +597,62 @@ describe("PermissionSession", () => {
532
597
  });
533
598
  });
534
599
 
600
+ describe("canConfirm", () => {
601
+ it("returns true when context is active and canPrompt returns true", () => {
602
+ const { session } = createSession();
603
+ session.activate(makeCtx());
604
+ expect(session.canConfirm()).toBe(true);
605
+ });
606
+
607
+ it("returns false when no context is active", () => {
608
+ const { session } = createSession();
609
+ expect(session.canConfirm()).toBe(false);
610
+ });
611
+
612
+ it("returns false when canPrompt returns false", () => {
613
+ const runtimeDeps = makeRuntimeDeps();
614
+ (
615
+ runtimeDeps.canRequestPermissionConfirmation as ReturnType<typeof vi.fn>
616
+ ).mockReturnValue(false);
617
+ const { session } = createSession({ runtimeDeps });
618
+ session.activate(makeCtx());
619
+ expect(session.canConfirm()).toBe(false);
620
+ });
621
+ });
622
+
623
+ describe("promptPermission", () => {
624
+ it("delegates to prompt with stored context", async () => {
625
+ const { session, runtimeDeps } = createSession();
626
+ const ctx = makeCtx();
627
+ session.activate(ctx);
628
+ const details = {
629
+ requestId: "req-1",
630
+ source: "tool_call" as const,
631
+ agentName: null,
632
+ message: "Allow?",
633
+ };
634
+
635
+ const result = await session.promptPermission(details);
636
+
637
+ expect(runtimeDeps.promptPermission).toHaveBeenCalledWith(ctx, details);
638
+ expect(result).toEqual({ approved: true, state: "approved" });
639
+ });
640
+
641
+ it("throws when no context is active", async () => {
642
+ const { session } = createSession();
643
+ const details = {
644
+ requestId: "req-1",
645
+ source: "tool_call" as const,
646
+ agentName: null,
647
+ message: "Allow?",
648
+ };
649
+
650
+ await expect(session.promptPermission(details)).rejects.toThrow(
651
+ "promptPermission called before the session was activated",
652
+ );
653
+ });
654
+ });
655
+
535
656
  describe("canPrompt", () => {
536
657
  it("delegates to runtimeDeps.canRequestPermissionConfirmation", () => {
537
658
  const { session, runtimeDeps } = createSession();
@@ -573,19 +694,4 @@ describe("PermissionSession", () => {
573
694
  expect(result).toEqual({ approved: true, state: "approved" });
574
695
  });
575
696
  });
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
697
  });
@@ -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
+ });