@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
@@ -0,0 +1,176 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ createSkillInputRequestId,
5
+ formatSkillDenyNotice,
6
+ SkillInputGatePipeline,
7
+ } from "#src/handlers/gates/skill-input-gate-pipeline";
8
+
9
+ import {
10
+ makeGateRunner,
11
+ makeNotifier,
12
+ makeSkillInputInputs,
13
+ } from "#test/helpers/gate-fixtures";
14
+ import { makeCheckResult } from "#test/helpers/handler-fixtures";
15
+
16
+ // ── createSkillInputRequestId ─────────────────────────────────────────────
17
+
18
+ describe("createSkillInputRequestId", () => {
19
+ it("starts with 'skill-input-'", () => {
20
+ expect(createSkillInputRequestId().startsWith("skill-input-")).toBe(true);
21
+ });
22
+
23
+ it("returns a unique id on each call", () => {
24
+ const id1 = createSkillInputRequestId();
25
+ const id2 = createSkillInputRequestId();
26
+ expect(id1).not.toBe(id2);
27
+ });
28
+ });
29
+
30
+ // ── formatSkillDenyNotice ─────────────────────────────────────────────────
31
+
32
+ describe("formatSkillDenyNotice", () => {
33
+ it("includes the skill name in the message (no agent)", () => {
34
+ const msg = formatSkillDenyNotice("librarian", null);
35
+ expect(msg).toContain("librarian");
36
+ });
37
+
38
+ it("includes the skill name and agent name when agent is present", () => {
39
+ const msg = formatSkillDenyNotice("librarian", "code-agent");
40
+ expect(msg).toContain("librarian");
41
+ expect(msg).toContain("code-agent");
42
+ });
43
+ });
44
+
45
+ // ── SkillInputGatePipeline.evaluate ───────────────────────────────────────
46
+
47
+ describe("SkillInputGatePipeline.evaluate", () => {
48
+ // ── notifier behaviour ──────────────────────────────────────────────────
49
+
50
+ it("calls notifier.warn when the skill is denied", async () => {
51
+ const inputs = makeSkillInputInputs({
52
+ checkPermission: () => makeCheckResult({ state: "deny" }),
53
+ });
54
+ const notifier = makeNotifier();
55
+ const { runner } = makeGateRunner();
56
+ const pipeline = new SkillInputGatePipeline(inputs);
57
+
58
+ await pipeline.evaluate("librarian", null, notifier, runner);
59
+
60
+ expect(notifier.warn).toHaveBeenCalledOnce();
61
+ expect(notifier.warn).toHaveBeenCalledWith(
62
+ expect.stringContaining("librarian"),
63
+ );
64
+ });
65
+
66
+ it("does not call notifier.warn when the skill is allowed", async () => {
67
+ const inputs = makeSkillInputInputs({
68
+ checkPermission: () => makeCheckResult({ state: "allow" }),
69
+ });
70
+ const notifier = makeNotifier();
71
+ const { runner } = makeGateRunner();
72
+ const pipeline = new SkillInputGatePipeline(inputs);
73
+
74
+ await pipeline.evaluate("librarian", null, notifier, runner);
75
+
76
+ expect(notifier.warn).not.toHaveBeenCalled();
77
+ });
78
+
79
+ it("does not call notifier.warn when the skill requires approval (ask)", async () => {
80
+ const inputs = makeSkillInputInputs({
81
+ checkPermission: () => makeCheckResult({ state: "ask" }),
82
+ });
83
+ const notifier = makeNotifier();
84
+ const { runner } = makeGateRunner();
85
+ const pipeline = new SkillInputGatePipeline(inputs);
86
+
87
+ await pipeline.evaluate("librarian", null, notifier, runner);
88
+
89
+ expect(notifier.warn).not.toHaveBeenCalled();
90
+ });
91
+
92
+ it("includes agent name in the deny notice when agent is present", async () => {
93
+ const inputs = makeSkillInputInputs({
94
+ checkPermission: () => makeCheckResult({ state: "deny" }),
95
+ });
96
+ const notifier = makeNotifier();
97
+ const { runner } = makeGateRunner();
98
+ const pipeline = new SkillInputGatePipeline(inputs);
99
+
100
+ await pipeline.evaluate("librarian", "code-agent", notifier, runner);
101
+
102
+ expect(notifier.warn).toHaveBeenCalledWith(
103
+ expect.stringContaining("code-agent"),
104
+ );
105
+ });
106
+
107
+ // ── outcome mapping ─────────────────────────────────────────────────────
108
+
109
+ it("returns allow when the gate passes", async () => {
110
+ const inputs = makeSkillInputInputs({
111
+ checkPermission: () => makeCheckResult({ state: "allow" }),
112
+ });
113
+ const { runner } = makeGateRunner();
114
+ const pipeline = new SkillInputGatePipeline(inputs);
115
+
116
+ const result = await pipeline.evaluate(
117
+ "librarian",
118
+ null,
119
+ makeNotifier(),
120
+ runner,
121
+ );
122
+
123
+ expect(result).toEqual({ action: "allow" });
124
+ });
125
+
126
+ it("returns block when the gate denies", async () => {
127
+ const inputs = makeSkillInputInputs({
128
+ checkPermission: () =>
129
+ makeCheckResult({ state: "deny", matchedPattern: "*" }),
130
+ });
131
+ const { runner } = makeGateRunner();
132
+ const pipeline = new SkillInputGatePipeline(inputs);
133
+
134
+ const result = await pipeline.evaluate(
135
+ "librarian",
136
+ null,
137
+ makeNotifier(),
138
+ runner,
139
+ );
140
+
141
+ expect(result).toEqual({
142
+ action: "block",
143
+ reason: expect.stringContaining("librarian"),
144
+ });
145
+ });
146
+
147
+ // ── checkPermission call ────────────────────────────────────────────────
148
+
149
+ it("calls checkPermission with the skill surface, skill name, and agent name", async () => {
150
+ const inputs = makeSkillInputInputs();
151
+ const { runner } = makeGateRunner();
152
+ const pipeline = new SkillInputGatePipeline(inputs);
153
+
154
+ await pipeline.evaluate("explorer", "code-agent", makeNotifier(), runner);
155
+
156
+ expect(inputs.checkPermission).toHaveBeenCalledWith(
157
+ "skill",
158
+ { name: "explorer" },
159
+ "code-agent",
160
+ );
161
+ });
162
+
163
+ it("calls checkPermission with undefined agentName when agentName is null", async () => {
164
+ const inputs = makeSkillInputInputs();
165
+ const { runner } = makeGateRunner();
166
+ const pipeline = new SkillInputGatePipeline(inputs);
167
+
168
+ await pipeline.evaluate("explorer", null, makeNotifier(), runner);
169
+
170
+ expect(inputs.checkPermission).toHaveBeenCalledWith(
171
+ "skill",
172
+ { name: "explorer" },
173
+ undefined,
174
+ );
175
+ });
176
+ });
@@ -0,0 +1,128 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { describeSkillInputGate } from "#src/handlers/gates/skill-input";
4
+ import { makeCheckResult } from "#test/helpers/handler-fixtures";
5
+
6
+ // ── helpers ────────────────────────────────────────────────────────────────
7
+
8
+ function makeSkillCheck(state: "allow" | "deny" | "ask") {
9
+ return makeCheckResult({
10
+ state,
11
+ toolName: "skill",
12
+ source: "skill",
13
+ origin: "global",
14
+ matchedPattern: "*",
15
+ });
16
+ }
17
+
18
+ // ── describeSkillInputGate ─────────────────────────────────────────────────
19
+
20
+ describe("describeSkillInputGate", () => {
21
+ it("sets surface to 'skill'", () => {
22
+ const descriptor = describeSkillInputGate(
23
+ "librarian",
24
+ null,
25
+ makeSkillCheck("allow"),
26
+ );
27
+ expect(descriptor.surface).toBe("skill");
28
+ });
29
+
30
+ it("sets input.name to the skill name", () => {
31
+ const descriptor = describeSkillInputGate(
32
+ "librarian",
33
+ null,
34
+ makeSkillCheck("allow"),
35
+ );
36
+ expect(descriptor.input).toEqual({ name: "librarian" });
37
+ });
38
+
39
+ it("passes preCheck through verbatim", () => {
40
+ const check = makeSkillCheck("deny");
41
+ const descriptor = describeSkillInputGate("librarian", null, check);
42
+ expect(descriptor.preCheck).toBe(check);
43
+ });
44
+
45
+ it("sets denialContext with kind skill_input and skill name", () => {
46
+ const descriptor = describeSkillInputGate(
47
+ "librarian",
48
+ null,
49
+ makeSkillCheck("allow"),
50
+ );
51
+ expect(descriptor.denialContext).toEqual({
52
+ kind: "skill_input",
53
+ skillName: "librarian",
54
+ agentName: undefined,
55
+ });
56
+ });
57
+
58
+ it("includes agentName in denialContext when provided", () => {
59
+ const descriptor = describeSkillInputGate(
60
+ "librarian",
61
+ "code-agent",
62
+ makeSkillCheck("allow"),
63
+ );
64
+ expect(descriptor.denialContext).toEqual({
65
+ kind: "skill_input",
66
+ skillName: "librarian",
67
+ agentName: "code-agent",
68
+ });
69
+ });
70
+
71
+ it("sets promptDetails source to 'skill_input' with skill name and agent", () => {
72
+ const descriptor = describeSkillInputGate(
73
+ "librarian",
74
+ "code-agent",
75
+ makeSkillCheck("ask"),
76
+ );
77
+ expect(descriptor.promptDetails).toMatchObject({
78
+ source: "skill_input",
79
+ agentName: "code-agent",
80
+ skillName: "librarian",
81
+ });
82
+ });
83
+
84
+ it("includes a non-empty message in promptDetails", () => {
85
+ const descriptor = describeSkillInputGate(
86
+ "librarian",
87
+ null,
88
+ makeSkillCheck("ask"),
89
+ );
90
+ expect(typeof descriptor.promptDetails.message).toBe("string");
91
+ expect(descriptor.promptDetails.message.length).toBeGreaterThan(0);
92
+ });
93
+
94
+ it("sets logContext source to 'skill_input' with skill name and agent", () => {
95
+ const descriptor = describeSkillInputGate(
96
+ "librarian",
97
+ "code-agent",
98
+ makeSkillCheck("allow"),
99
+ );
100
+ expect(descriptor.logContext).toMatchObject({
101
+ source: "skill_input",
102
+ skillName: "librarian",
103
+ agentName: "code-agent",
104
+ });
105
+ });
106
+
107
+ it("sets decision surface to 'skill' and value to the skill name", () => {
108
+ const descriptor = describeSkillInputGate(
109
+ "my-skill",
110
+ null,
111
+ makeSkillCheck("allow"),
112
+ );
113
+ expect(descriptor.decision).toEqual({
114
+ surface: "skill",
115
+ value: "my-skill",
116
+ });
117
+ });
118
+
119
+ it("does not set preResolved or sessionApproval", () => {
120
+ const descriptor = describeSkillInputGate(
121
+ "librarian",
122
+ null,
123
+ makeSkillCheck("allow"),
124
+ );
125
+ expect(descriptor.preResolved).toBeUndefined();
126
+ expect(descriptor.sessionApproval).toBeUndefined();
127
+ });
128
+ });
@@ -0,0 +1,180 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { ToolCallGatePipeline } from "#src/handlers/gates/tool-call-gate-pipeline";
4
+
5
+ import {
6
+ makeGateInputs,
7
+ makeGateRunner,
8
+ makeResolver,
9
+ makeTcc,
10
+ } from "#test/helpers/gate-fixtures";
11
+ import { makeCheckResult } from "#test/helpers/handler-fixtures";
12
+
13
+ // ── BashProgram.parse mock ─────────────────────────────────────────────────
14
+
15
+ const { mockBashProgramParse } = vi.hoisted(() => ({
16
+ mockBashProgramParse: vi.fn(),
17
+ }));
18
+
19
+ vi.mock("#src/handlers/gates/bash-program", () => ({
20
+ BashProgram: { parse: mockBashProgramParse },
21
+ }));
22
+
23
+ function makeMockBashProgram() {
24
+ return {
25
+ commands: vi.fn<() => []>(() => []),
26
+ pathTokens: vi.fn<() => []>(() => []),
27
+ externalPaths: vi.fn<() => []>(() => []),
28
+ };
29
+ }
30
+
31
+ // ── ToolCallGatePipeline ───────────────────────────────────────────────────
32
+
33
+ describe("ToolCallGatePipeline", () => {
34
+ beforeEach(() => {
35
+ mockBashProgramParse.mockReset();
36
+ mockBashProgramParse.mockResolvedValue(makeMockBashProgram());
37
+ });
38
+
39
+ // ── non-bash tools ───────────────────────────────────────────────────────
40
+
41
+ describe("evaluate — non-bash tool", () => {
42
+ it("returns allow when all gates pass", async () => {
43
+ const inputs = makeGateInputs();
44
+ const { runner } = makeGateRunner({ resolve: inputs.resolve });
45
+ const pipeline = new ToolCallGatePipeline(inputs);
46
+
47
+ const result = await pipeline.evaluate(
48
+ makeTcc({ toolName: "read", input: {} }),
49
+ runner,
50
+ );
51
+
52
+ expect(result).toEqual({ action: "allow" });
53
+ });
54
+
55
+ it("returns block when the tool gate denies", async () => {
56
+ const { resolve } = makeResolver(
57
+ makeCheckResult({ state: "deny", matchedPattern: "*" }),
58
+ );
59
+ const inputs = makeGateInputs({ resolve });
60
+ const { runner } = makeGateRunner({ resolve });
61
+ const pipeline = new ToolCallGatePipeline(inputs);
62
+
63
+ const result = await pipeline.evaluate(
64
+ makeTcc({ toolName: "read", input: {} }),
65
+ runner,
66
+ );
67
+
68
+ expect(result).toMatchObject({ action: "block" });
69
+ });
70
+
71
+ it("short-circuits after the first blocking gate without evaluating later ones", async () => {
72
+ const inputs = makeGateInputs();
73
+ const { runner } = makeGateRunner();
74
+ const runSpy = vi
75
+ .spyOn(runner, "run")
76
+ .mockResolvedValue({ action: "block", reason: "first gate blocked" });
77
+
78
+ const pipeline = new ToolCallGatePipeline(inputs);
79
+ const result = await pipeline.evaluate(
80
+ makeTcc({ toolName: "read", input: {} }),
81
+ runner,
82
+ );
83
+
84
+ expect(result).toEqual({ action: "block", reason: "first gate blocked" });
85
+ // Pipeline looped to the first gate, got block, and stopped — not all 6 gates.
86
+ expect(runSpy).toHaveBeenCalledTimes(1);
87
+ });
88
+
89
+ it("calls getToolPreviewLimits() during evaluate", async () => {
90
+ const getToolPreviewLimits = vi.fn(() => ({
91
+ toolInputPreviewMaxLength: 500,
92
+ toolTextSummaryMaxLength: 100,
93
+ toolInputLogPreviewMaxLength: 200,
94
+ }));
95
+ const inputs = makeGateInputs({ getToolPreviewLimits });
96
+ const { runner } = makeGateRunner({ resolve: inputs.resolve });
97
+ const pipeline = new ToolCallGatePipeline(inputs);
98
+
99
+ await pipeline.evaluate(makeTcc({ toolName: "read", input: {} }), runner);
100
+
101
+ expect(getToolPreviewLimits).toHaveBeenCalled();
102
+ });
103
+
104
+ it("calls getInfrastructureReadDirs() during evaluate", async () => {
105
+ const getInfrastructureReadDirs = vi.fn<() => string[]>(() => []);
106
+ const inputs = makeGateInputs({ getInfrastructureReadDirs });
107
+ const { runner } = makeGateRunner({ resolve: inputs.resolve });
108
+ const pipeline = new ToolCallGatePipeline(inputs);
109
+
110
+ await pipeline.evaluate(makeTcc({ toolName: "read", input: {} }), runner);
111
+
112
+ expect(getInfrastructureReadDirs).toHaveBeenCalled();
113
+ });
114
+
115
+ it("calls getActiveSkillEntries() during evaluate", async () => {
116
+ const getActiveSkillEntries = vi.fn<() => []>(() => []);
117
+ const inputs = makeGateInputs({ getActiveSkillEntries });
118
+ const { runner } = makeGateRunner({ resolve: inputs.resolve });
119
+ const pipeline = new ToolCallGatePipeline(inputs);
120
+
121
+ await pipeline.evaluate(makeTcc({ toolName: "read", input: {} }), runner);
122
+
123
+ expect(getActiveSkillEntries).toHaveBeenCalled();
124
+ });
125
+
126
+ it("does not call BashProgram.parse for non-bash tools", async () => {
127
+ const inputs = makeGateInputs();
128
+ const { runner } = makeGateRunner({ resolve: inputs.resolve });
129
+ const pipeline = new ToolCallGatePipeline(inputs);
130
+
131
+ await pipeline.evaluate(makeTcc({ toolName: "read", input: {} }), runner);
132
+
133
+ expect(mockBashProgramParse).not.toHaveBeenCalled();
134
+ });
135
+ });
136
+
137
+ // ── bash tool ────────────────────────────────────────────────────────────
138
+
139
+ describe("evaluate — bash tool", () => {
140
+ it("returns allow when the bash command is permitted", async () => {
141
+ const inputs = makeGateInputs();
142
+ const { runner } = makeGateRunner({ resolve: inputs.resolve });
143
+ const pipeline = new ToolCallGatePipeline(inputs);
144
+
145
+ const result = await pipeline.evaluate(
146
+ makeTcc({ toolName: "bash", input: { command: "echo hello" } }),
147
+ runner,
148
+ );
149
+
150
+ expect(result).toEqual({ action: "allow" });
151
+ });
152
+
153
+ it("parses BashProgram exactly once per evaluate for bash tools with a command", async () => {
154
+ const inputs = makeGateInputs();
155
+ const { runner } = makeGateRunner({ resolve: inputs.resolve });
156
+ const pipeline = new ToolCallGatePipeline(inputs);
157
+
158
+ await pipeline.evaluate(
159
+ makeTcc({ toolName: "bash", input: { command: "echo hello" } }),
160
+ runner,
161
+ );
162
+
163
+ expect(mockBashProgramParse).toHaveBeenCalledTimes(1);
164
+ expect(mockBashProgramParse).toHaveBeenCalledWith("echo hello");
165
+ });
166
+
167
+ it("does not parse BashProgram when the bash command is empty", async () => {
168
+ const inputs = makeGateInputs();
169
+ const { runner } = makeGateRunner({ resolve: inputs.resolve });
170
+ const pipeline = new ToolCallGatePipeline(inputs);
171
+
172
+ await pipeline.evaluate(
173
+ makeTcc({ toolName: "bash", input: { command: "" } }),
174
+ runner,
175
+ );
176
+
177
+ expect(mockBashProgramParse).not.toHaveBeenCalled();
178
+ });
179
+ });
180
+ });
@@ -172,8 +172,7 @@ describe("handleInput", () => {
172
172
  },
173
173
  });
174
174
  await handler.handleInput(makeInputEvent("/skill:librarian"), makeCtx());
175
- expect(session.prompt).toHaveBeenCalledWith(
176
- expect.anything(),
175
+ expect(session.promptPermission).toHaveBeenCalledWith(
177
176
  expect.objectContaining({
178
177
  agentName: "code-agent",
179
178
  skillName: "librarian",
@@ -1,7 +1,8 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
2
 
3
3
  import { SessionLifecycleHandler } from "#src/handlers/lifecycle";
4
- import type { PermissionSession } from "#src/permission-session";
4
+ import type { ServiceLifecycle } from "#src/service-lifecycle";
5
+ import type { SessionLifecycleSession } from "#src/session-lifecycle-session";
5
6
 
6
7
  import { makeCtx } from "#test/helpers/handler-fixtures";
7
8
 
@@ -15,39 +16,54 @@ vi.mock("../../src/status", () => ({
15
16
  // ── helpers ────────────────────────────────────────────────────────────────
16
17
 
17
18
  function makeSession(
18
- overrides: Partial<Record<keyof PermissionSession, unknown>> = {},
19
- ): PermissionSession {
19
+ overrides: Partial<SessionLifecycleSession> = {},
20
+ ): SessionLifecycleSession {
20
21
  return {
21
- logger: { debug: vi.fn(), review: vi.fn(), warn: vi.fn() },
22
- refreshConfig: vi.fn(),
23
- resetForNewSession: vi.fn(),
24
- logResolvedConfigPaths: vi.fn(),
25
- resolveAgentName: vi.fn().mockReturnValue(null),
26
- getConfigIssues: vi.fn().mockReturnValue([]),
27
- reload: vi.fn(),
28
- getRuntimeContext: vi.fn().mockReturnValue(null),
29
- shutdown: vi.fn(),
30
- ...overrides,
31
- } as unknown as PermissionSession;
22
+ logger: overrides.logger ?? {
23
+ debug: vi.fn<SessionLifecycleSession["logger"]["debug"]>(),
24
+ review: vi.fn<SessionLifecycleSession["logger"]["review"]>(),
25
+ warn: vi.fn<SessionLifecycleSession["logger"]["warn"]>(),
26
+ },
27
+ refreshConfig:
28
+ overrides.refreshConfig ??
29
+ vi.fn<SessionLifecycleSession["refreshConfig"]>(),
30
+ resetForNewSession:
31
+ overrides.resetForNewSession ??
32
+ vi.fn<SessionLifecycleSession["resetForNewSession"]>(),
33
+ logResolvedConfigPaths:
34
+ overrides.logResolvedConfigPaths ??
35
+ vi.fn<SessionLifecycleSession["logResolvedConfigPaths"]>(),
36
+ resolveAgentName:
37
+ overrides.resolveAgentName ??
38
+ vi
39
+ .fn<SessionLifecycleSession["resolveAgentName"]>()
40
+ .mockReturnValue(null),
41
+ getConfigIssues:
42
+ overrides.getConfigIssues ??
43
+ vi.fn<SessionLifecycleSession["getConfigIssues"]>().mockReturnValue([]),
44
+ reload: overrides.reload ?? vi.fn<SessionLifecycleSession["reload"]>(),
45
+ getRuntimeContext:
46
+ overrides.getRuntimeContext ??
47
+ vi
48
+ .fn<SessionLifecycleSession["getRuntimeContext"]>()
49
+ .mockReturnValue(null),
50
+ shutdown:
51
+ overrides.shutdown ?? vi.fn<SessionLifecycleSession["shutdown"]>(),
52
+ };
32
53
  }
33
54
 
34
- function makeHandler(
35
- overrides?: Partial<Record<keyof PermissionSession, unknown>>,
36
- ): {
55
+ function makeHandler(overrides?: Partial<SessionLifecycleSession>): {
37
56
  handler: SessionLifecycleHandler;
38
- session: PermissionSession;
39
- activateService: ReturnType<typeof vi.fn>;
40
- cleanupRpc: ReturnType<typeof vi.fn>;
57
+ session: SessionLifecycleSession;
58
+ serviceLifecycle: ServiceLifecycle;
41
59
  } {
42
60
  const session = makeSession(overrides);
43
- const activateService = vi.fn();
44
- const cleanupRpc = vi.fn();
45
- const handler = new SessionLifecycleHandler(
46
- session,
47
- activateService,
48
- cleanupRpc,
49
- );
50
- return { handler, session, activateService, cleanupRpc };
61
+ const serviceLifecycle: ServiceLifecycle = {
62
+ activate: vi.fn<ServiceLifecycle["activate"]>(),
63
+ teardown: vi.fn<ServiceLifecycle["teardown"]>(),
64
+ };
65
+ const handler = new SessionLifecycleHandler(session, serviceLifecycle);
66
+ return { handler, session, serviceLifecycle };
51
67
  }
52
68
 
53
69
  // ── handleSessionStart ─────────────────────────────────────────────────────
@@ -114,9 +130,9 @@ describe("handleSessionStart", () => {
114
130
 
115
131
  it("activates the service for the session with ctx", async () => {
116
132
  const ctx = makeCtx();
117
- const { handler, activateService } = makeHandler();
133
+ const { handler, serviceLifecycle } = makeHandler();
118
134
  await handler.handleSessionStart({ reason: "startup" }, ctx);
119
- expect(activateService).toHaveBeenCalledWith(ctx);
135
+ expect(serviceLifecycle.activate).toHaveBeenCalledWith(ctx);
120
136
  });
121
137
 
122
138
  it("calls refreshConfig before resetForNewSession", async () => {
@@ -195,9 +211,9 @@ describe("handleSessionShutdown", () => {
195
211
  expect(session.shutdown).toHaveBeenCalledOnce();
196
212
  });
197
213
 
198
- it("calls cleanupRpc", async () => {
199
- const { handler, cleanupRpc } = makeHandler();
214
+ it("calls serviceLifecycle.teardown", async () => {
215
+ const { handler, serviceLifecycle } = makeHandler();
200
216
  await handler.handleSessionShutdown();
201
- expect(cleanupRpc).toHaveBeenCalledOnce();
217
+ expect(serviceLifecycle.teardown).toHaveBeenCalledOnce();
202
218
  });
203
219
  });
@@ -209,7 +209,7 @@ describe("handleToolCall decision events — infrastructure_auto_allowed", () =>
209
209
  const { handler, events } = makeHandler({
210
210
  session: {
211
211
  checkPermission: vi.fn().mockReturnValue(makeCheckResult()),
212
- getInfrastructureDirs: vi.fn().mockReturnValue([infraDir]),
212
+ getInfrastructureReadDirs: vi.fn().mockReturnValue([infraDir]),
213
213
  },
214
214
  });
215
215