@gotgenes/pi-permission-system 9.2.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.
- package/CHANGELOG.md +52 -0
- package/README.md +12 -11
- package/package.json +1 -1
- package/src/agent-prep-session.ts +28 -0
- package/src/decision-reporter.ts +41 -0
- package/src/denial-messages.ts +11 -0
- package/src/forwarded-permissions/io.ts +29 -0
- package/src/forwarded-permissions/permission-forwarder.ts +549 -0
- package/src/forwarding-manager.ts +3 -7
- package/src/gate-handler-session.ts +13 -0
- package/src/gate-prompter.ts +14 -0
- package/src/handlers/before-agent-start.ts +2 -3
- package/src/handlers/gates/bash-command.ts +4 -18
- package/src/handlers/gates/bash-external-directory.ts +3 -15
- package/src/handlers/gates/bash-path.ts +3 -16
- package/src/handlers/gates/descriptor.ts +0 -28
- package/src/handlers/gates/path.ts +3 -15
- package/src/handlers/gates/runner.ts +142 -105
- package/src/handlers/gates/skill-input-gate-pipeline.ts +104 -0
- package/src/handlers/gates/skill-input.ts +44 -0
- package/src/handlers/gates/tool-call-gate-pipeline.ts +120 -0
- package/src/handlers/lifecycle.ts +9 -9
- package/src/handlers/permission-gate-handler.ts +34 -238
- package/src/index.ts +50 -68
- package/src/mcp-targets.ts +56 -46
- package/src/permission-event-rpc.ts +7 -0
- package/src/permission-events.ts +89 -8
- package/src/permission-forwarding.ts +23 -0
- package/src/permission-prompter.ts +27 -56
- package/src/permission-resolver.ts +17 -0
- package/src/permission-session.ts +77 -9
- package/src/permission-ui-prompt.ts +127 -0
- package/src/permissions-service.ts +53 -0
- package/src/service-lifecycle.ts +49 -0
- package/src/service.ts +17 -0
- package/src/session-approval-recorder.ts +6 -0
- package/src/session-lifecycle-session.ts +24 -0
- package/src/tool-input-preview.ts +0 -62
- package/src/tool-input-prompt-formatters.ts +63 -0
- package/src/tool-preview-formatter.ts +6 -4
- package/test/composition-root.test.ts +5 -0
- package/test/decision-reporter.test.ts +112 -0
- package/test/denial-messages.test.ts +62 -0
- package/test/forwarding-manager.test.ts +26 -44
- package/test/handlers/before-agent-start.test.ts +45 -21
- package/test/handlers/external-directory-integration.test.ts +86 -22
- package/test/handlers/external-directory-session-dedup.test.ts +102 -55
- package/test/handlers/gates/bash-command.test.ts +49 -90
- package/test/handlers/gates/bash-external-directory.test.ts +54 -95
- package/test/handlers/gates/bash-path.test.ts +63 -148
- package/test/handlers/gates/path.test.ts +38 -105
- package/test/handlers/gates/runner.test.ts +150 -93
- package/test/handlers/gates/skill-input-gate-pipeline.test.ts +176 -0
- package/test/handlers/gates/skill-input.test.ts +128 -0
- package/test/handlers/gates/tool-call-gate-pipeline.test.ts +180 -0
- package/test/handlers/input.test.ts +1 -2
- package/test/handlers/lifecycle.test.ts +49 -33
- package/test/handlers/tool-call-events.test.ts +1 -1
- package/test/helpers/gate-fixtures.ts +147 -16
- package/test/helpers/handler-fixtures.ts +143 -27
- package/test/mcp-targets.test.ts +55 -0
- package/test/permission-event-rpc.test.ts +39 -0
- package/test/permission-events.test.ts +78 -10
- package/test/permission-forwarder.test.ts +295 -0
- package/test/permission-prompter.test.ts +147 -38
- package/test/permission-session.test.ts +160 -27
- package/test/permission-ui-prompt.test.ts +146 -0
- package/test/permissions-service.test.ts +151 -0
- package/test/runtime.test.ts +0 -4
- package/test/service-lifecycle.test.ts +162 -0
- package/test/tool-input-preview.test.ts +0 -111
- package/test/tool-input-prompt-formatters.test.ts +115 -0
- package/src/forwarded-permissions/polling.ts +0 -379
|
@@ -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("
|
|
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.
|
|
514
|
+
expect(session.getInfrastructureReadDirs()).toEqual([
|
|
515
|
+
"/test/agent",
|
|
516
|
+
"/test/agent/git",
|
|
517
|
+
"/extra/path",
|
|
518
|
+
]);
|
|
450
519
|
});
|
|
451
520
|
|
|
452
|
-
it("
|
|
521
|
+
it("getInfrastructureReadDirs returns only piInfrastructureDirs when config omits the field", () => {
|
|
453
522
|
const { session } = createSession();
|
|
454
|
-
expect(session.
|
|
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,146 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
buildDirectUiPrompt,
|
|
5
|
+
buildForwardedUiPrompt,
|
|
6
|
+
buildRpcUiPrompt,
|
|
7
|
+
} from "#src/permission-ui-prompt";
|
|
8
|
+
|
|
9
|
+
describe("buildDirectUiPrompt", () => {
|
|
10
|
+
it("maps a tool_call prompt to the tool surface and command value", () => {
|
|
11
|
+
expect(
|
|
12
|
+
buildDirectUiPrompt({
|
|
13
|
+
requestId: "req-1",
|
|
14
|
+
source: "tool_call",
|
|
15
|
+
agentName: "Explore",
|
|
16
|
+
message: "Allow git push?",
|
|
17
|
+
toolName: "bash",
|
|
18
|
+
command: "git push",
|
|
19
|
+
}),
|
|
20
|
+
).toEqual({
|
|
21
|
+
requestId: "req-1",
|
|
22
|
+
source: "tool_call",
|
|
23
|
+
surface: "bash",
|
|
24
|
+
value: "git push",
|
|
25
|
+
agentName: "Explore",
|
|
26
|
+
message: "Allow git push?",
|
|
27
|
+
forwarding: null,
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("normalizes a skill prompt to the skill surface and skill-name value", () => {
|
|
32
|
+
expect(
|
|
33
|
+
buildDirectUiPrompt({
|
|
34
|
+
requestId: "req-2",
|
|
35
|
+
source: "skill_input",
|
|
36
|
+
agentName: null,
|
|
37
|
+
message: "Allow skill?",
|
|
38
|
+
skillName: "deploy-helper",
|
|
39
|
+
}),
|
|
40
|
+
).toEqual({
|
|
41
|
+
requestId: "req-2",
|
|
42
|
+
source: "skill_input",
|
|
43
|
+
surface: "skill",
|
|
44
|
+
value: "deploy-helper",
|
|
45
|
+
agentName: null,
|
|
46
|
+
message: "Allow skill?",
|
|
47
|
+
forwarding: null,
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("derives value with command > path > target > skillName > toolName precedence", () => {
|
|
52
|
+
expect(
|
|
53
|
+
buildDirectUiPrompt({
|
|
54
|
+
requestId: "req-3",
|
|
55
|
+
source: "tool_call",
|
|
56
|
+
agentName: null,
|
|
57
|
+
message: "m",
|
|
58
|
+
toolName: "read",
|
|
59
|
+
path: "/etc/hosts",
|
|
60
|
+
target: "ignored",
|
|
61
|
+
}).value,
|
|
62
|
+
).toBe("/etc/hosts");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("buildRpcUiPrompt", () => {
|
|
67
|
+
it("maps an RPC prompt to the rpc_prompt source with passthrough surface/value", () => {
|
|
68
|
+
expect(
|
|
69
|
+
buildRpcUiPrompt({
|
|
70
|
+
requestId: "req-rpc",
|
|
71
|
+
surface: "bash",
|
|
72
|
+
value: "git push",
|
|
73
|
+
agentName: "Worker",
|
|
74
|
+
message: "Allow git push?",
|
|
75
|
+
}),
|
|
76
|
+
).toEqual({
|
|
77
|
+
requestId: "req-rpc",
|
|
78
|
+
source: "rpc_prompt",
|
|
79
|
+
surface: "bash",
|
|
80
|
+
value: "git push",
|
|
81
|
+
agentName: "Worker",
|
|
82
|
+
message: "Allow git push?",
|
|
83
|
+
forwarding: null,
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("defaults missing surface, value, and agentName to null", () => {
|
|
88
|
+
expect(
|
|
89
|
+
buildRpcUiPrompt({ requestId: "req-rpc-2", message: "Allow?" }),
|
|
90
|
+
).toEqual({
|
|
91
|
+
requestId: "req-rpc-2",
|
|
92
|
+
source: "rpc_prompt",
|
|
93
|
+
surface: null,
|
|
94
|
+
value: null,
|
|
95
|
+
agentName: null,
|
|
96
|
+
message: "Allow?",
|
|
97
|
+
forwarding: null,
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("buildForwardedUiPrompt", () => {
|
|
103
|
+
it("populates forwarding context and carries the original source/surface/value", () => {
|
|
104
|
+
expect(
|
|
105
|
+
buildForwardedUiPrompt({
|
|
106
|
+
requestId: "req-fwd",
|
|
107
|
+
message: "Subagent 'Explore' requested permission.\n\nAllow git push?",
|
|
108
|
+
requesterAgentName: "Explore",
|
|
109
|
+
requesterSessionId: "child-session",
|
|
110
|
+
source: "tool_call",
|
|
111
|
+
surface: "bash",
|
|
112
|
+
value: "git push",
|
|
113
|
+
}),
|
|
114
|
+
).toEqual({
|
|
115
|
+
requestId: "req-fwd",
|
|
116
|
+
source: "tool_call",
|
|
117
|
+
surface: "bash",
|
|
118
|
+
value: "git push",
|
|
119
|
+
agentName: "Explore",
|
|
120
|
+
message: "Subagent 'Explore' requested permission.\n\nAllow git push?",
|
|
121
|
+
forwarding: {
|
|
122
|
+
requesterAgentName: "Explore",
|
|
123
|
+
requesterSessionId: "child-session",
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("falls back to source tool_call with null surface/value when the request omits them", () => {
|
|
129
|
+
expect(
|
|
130
|
+
buildForwardedUiPrompt({
|
|
131
|
+
requestId: "req-fwd-old",
|
|
132
|
+
message: "Allow?",
|
|
133
|
+
requesterAgentName: null,
|
|
134
|
+
requesterSessionId: null,
|
|
135
|
+
}),
|
|
136
|
+
).toEqual({
|
|
137
|
+
requestId: "req-fwd-old",
|
|
138
|
+
source: "tool_call",
|
|
139
|
+
surface: null,
|
|
140
|
+
value: null,
|
|
141
|
+
agentName: null,
|
|
142
|
+
message: "Allow?",
|
|
143
|
+
forwarding: { requesterAgentName: null, requesterSessionId: null },
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -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
|
+
});
|
package/test/runtime.test.ts
CHANGED
|
@@ -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
|
}));
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { PermissionsService } from "#src/service";
|
|
3
|
+
import {
|
|
4
|
+
PermissionServiceLifecycle,
|
|
5
|
+
type ServiceLifecycle,
|
|
6
|
+
} from "#src/service-lifecycle";
|
|
7
|
+
import type { SubagentSessionRegistry } from "#src/subagent-registry";
|
|
8
|
+
|
|
9
|
+
import { makeCtx } from "#test/helpers/handler-fixtures";
|
|
10
|
+
|
|
11
|
+
// ── module stubs ───────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
const mockIsRegisteredSubagentChild = vi.hoisted(() =>
|
|
14
|
+
vi.fn<(ctx: unknown, registry: unknown) => boolean>().mockReturnValue(false),
|
|
15
|
+
);
|
|
16
|
+
const mockPublishPermissionsService = vi.hoisted(() => vi.fn<() => void>());
|
|
17
|
+
const mockUnpublishPermissionsService = vi.hoisted(() => vi.fn<() => void>());
|
|
18
|
+
const mockEmitReadyEvent = vi.hoisted(() => vi.fn<() => void>());
|
|
19
|
+
|
|
20
|
+
vi.mock("#src/subagent-context", () => ({
|
|
21
|
+
isRegisteredSubagentChild: mockIsRegisteredSubagentChild,
|
|
22
|
+
}));
|
|
23
|
+
vi.mock("#src/service", () => ({
|
|
24
|
+
publishPermissionsService: mockPublishPermissionsService,
|
|
25
|
+
unpublishPermissionsService: mockUnpublishPermissionsService,
|
|
26
|
+
}));
|
|
27
|
+
vi.mock("#src/permission-events", () => ({
|
|
28
|
+
emitReadyEvent: mockEmitReadyEvent,
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
// ── helpers ────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
function makeService(): PermissionsService {
|
|
34
|
+
return {
|
|
35
|
+
checkPermission: vi.fn(),
|
|
36
|
+
getToolPermission: vi.fn(),
|
|
37
|
+
registerToolInputFormatter: vi.fn(),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function makeRegistry(): SubagentSessionRegistry {
|
|
42
|
+
return {
|
|
43
|
+
has: vi.fn().mockReturnValue(false),
|
|
44
|
+
} as unknown as SubagentSessionRegistry;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function makeLifecycle(overrides?: { subscriptions?: (() => void)[] }) {
|
|
48
|
+
const service = makeService();
|
|
49
|
+
const registry = makeRegistry();
|
|
50
|
+
const events = { emit: vi.fn(), on: vi.fn() };
|
|
51
|
+
const subscriptions = overrides?.subscriptions ?? [];
|
|
52
|
+
const lifecycle = new PermissionServiceLifecycle(
|
|
53
|
+
service,
|
|
54
|
+
registry,
|
|
55
|
+
events,
|
|
56
|
+
subscriptions,
|
|
57
|
+
);
|
|
58
|
+
return { lifecycle, service, registry, events, subscriptions };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
mockIsRegisteredSubagentChild.mockReset();
|
|
63
|
+
mockIsRegisteredSubagentChild.mockReturnValue(false);
|
|
64
|
+
mockPublishPermissionsService.mockReset();
|
|
65
|
+
mockUnpublishPermissionsService.mockReset();
|
|
66
|
+
mockEmitReadyEvent.mockReset();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// ── ServiceLifecycle interface shape ──────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
it("PermissionServiceLifecycle satisfies ServiceLifecycle", () => {
|
|
72
|
+
const { lifecycle } = makeLifecycle();
|
|
73
|
+
const _: ServiceLifecycle = lifecycle;
|
|
74
|
+
expect(_).toBeDefined();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ── activate ──────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
describe("activate", () => {
|
|
80
|
+
it("publishes the service for a non-child session", () => {
|
|
81
|
+
const ctx = makeCtx();
|
|
82
|
+
const { lifecycle, service } = makeLifecycle();
|
|
83
|
+
mockIsRegisteredSubagentChild.mockReturnValue(false);
|
|
84
|
+
lifecycle.activate(ctx);
|
|
85
|
+
expect(mockPublishPermissionsService).toHaveBeenCalledWith(service);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("skips publishing for a registered child session", () => {
|
|
89
|
+
const ctx = makeCtx();
|
|
90
|
+
const { lifecycle } = makeLifecycle();
|
|
91
|
+
mockIsRegisteredSubagentChild.mockReturnValue(true);
|
|
92
|
+
lifecycle.activate(ctx);
|
|
93
|
+
expect(mockPublishPermissionsService).not.toHaveBeenCalled();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("always emits the ready event, even for a child session", () => {
|
|
97
|
+
const ctx = makeCtx();
|
|
98
|
+
const { lifecycle, events } = makeLifecycle();
|
|
99
|
+
mockIsRegisteredSubagentChild.mockReturnValue(true);
|
|
100
|
+
lifecycle.activate(ctx);
|
|
101
|
+
expect(mockEmitReadyEvent).toHaveBeenCalledWith(events);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("emits ready after publishing the service", () => {
|
|
105
|
+
const ctx = makeCtx();
|
|
106
|
+
const order: string[] = [];
|
|
107
|
+
mockPublishPermissionsService.mockImplementation(() =>
|
|
108
|
+
order.push("publish"),
|
|
109
|
+
);
|
|
110
|
+
mockEmitReadyEvent.mockImplementation(() => order.push("ready"));
|
|
111
|
+
const { lifecycle } = makeLifecycle();
|
|
112
|
+
lifecycle.activate(ctx);
|
|
113
|
+
expect(order).toEqual(["publish", "ready"]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("passes ctx and registry to isRegisteredSubagentChild", () => {
|
|
117
|
+
const ctx = makeCtx();
|
|
118
|
+
const { lifecycle, registry } = makeLifecycle();
|
|
119
|
+
lifecycle.activate(ctx);
|
|
120
|
+
expect(mockIsRegisteredSubagentChild).toHaveBeenCalledWith(ctx, registry);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// ── teardown ──────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
describe("teardown", () => {
|
|
127
|
+
it("calls each subscription unsubscribe function", () => {
|
|
128
|
+
const unsub1 = vi.fn();
|
|
129
|
+
const unsub2 = vi.fn();
|
|
130
|
+
const unsub3 = vi.fn();
|
|
131
|
+
const { lifecycle } = makeLifecycle({
|
|
132
|
+
subscriptions: [unsub1, unsub2, unsub3],
|
|
133
|
+
});
|
|
134
|
+
lifecycle.teardown();
|
|
135
|
+
expect(unsub1).toHaveBeenCalledOnce();
|
|
136
|
+
expect(unsub2).toHaveBeenCalledOnce();
|
|
137
|
+
expect(unsub3).toHaveBeenCalledOnce();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("unpublishes the service after running subscriptions", () => {
|
|
141
|
+
const order: string[] = [];
|
|
142
|
+
const unsub = vi.fn(() => order.push("unsub"));
|
|
143
|
+
mockUnpublishPermissionsService.mockImplementation(() =>
|
|
144
|
+
order.push("unpublish"),
|
|
145
|
+
);
|
|
146
|
+
const { lifecycle } = makeLifecycle({ subscriptions: [unsub] });
|
|
147
|
+
lifecycle.teardown();
|
|
148
|
+
expect(order).toEqual(["unsub", "unpublish"]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("passes the service to unpublishPermissionsService", () => {
|
|
152
|
+
const { lifecycle, service } = makeLifecycle();
|
|
153
|
+
lifecycle.teardown();
|
|
154
|
+
expect(mockUnpublishPermissionsService).toHaveBeenCalledWith(service);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("works with no subscriptions", () => {
|
|
158
|
+
const { lifecycle } = makeLifecycle({ subscriptions: [] });
|
|
159
|
+
expect(() => lifecycle.teardown()).not.toThrow();
|
|
160
|
+
expect(mockUnpublishPermissionsService).toHaveBeenCalledOnce();
|
|
161
|
+
});
|
|
162
|
+
});
|