@gotgenes/pi-permission-system 5.10.0 → 5.11.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.
@@ -2,11 +2,11 @@ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
2
  import { describe, expect, it, vi } from "vitest";
3
3
 
4
4
  import {
5
- handleBeforeAgentStart,
5
+ AgentPrepHandler,
6
6
  shouldExposeTool,
7
7
  } from "../../src/handlers/before-agent-start";
8
- import type { HandlerDeps } from "../../src/handlers/types";
9
8
  import type { PermissionSession } from "../../src/permission-session";
9
+ import type { ToolRegistry } from "../../src/tool-registry";
10
10
  import type { PermissionState } from "../../src/types";
11
11
 
12
12
  // ── SDK stubs ──────────────────────────────────────────────────────────────
@@ -65,22 +65,28 @@ function makeSession(
65
65
  } as unknown as PermissionSession;
66
66
  }
67
67
 
68
- function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
68
+ function makeToolRegistry(overrides: Partial<ToolRegistry> = {}): ToolRegistry {
69
69
  return {
70
- session: makeSession(),
71
- events: { emit: vi.fn(), on: vi.fn().mockReturnValue(() => undefined) },
72
- canRequestPermissionConfirmation: vi.fn().mockReturnValue(false),
73
- promptPermission: vi
74
- .fn()
75
- .mockResolvedValue({ approved: true, state: "approved" }),
76
- createPermissionRequestId: vi.fn().mockReturnValue("test-id"),
77
- stopPermissionRpcHandlers: vi.fn(),
78
- getAllTools: vi.fn().mockReturnValue([]),
79
- setActiveTools: vi.fn(),
70
+ getAll: vi.fn().mockReturnValue([]),
71
+ setActive: vi.fn(),
80
72
  ...overrides,
81
73
  };
82
74
  }
83
75
 
76
+ function makeHandler(overrides?: {
77
+ session?: Partial<Record<keyof PermissionSession, unknown>>;
78
+ toolRegistry?: Partial<ToolRegistry>;
79
+ }): {
80
+ handler: AgentPrepHandler;
81
+ session: PermissionSession;
82
+ toolRegistry: ToolRegistry;
83
+ } {
84
+ const session = makeSession(overrides?.session);
85
+ const toolRegistry = makeToolRegistry(overrides?.toolRegistry);
86
+ const handler = new AgentPrepHandler(session, toolRegistry);
87
+ return { handler, session, toolRegistry };
88
+ }
89
+
84
90
  // ── shouldExposeTool (pure helper) ─────────────────────────────────────────
85
91
 
86
92
  describe("shouldExposeTool", () => {
@@ -112,136 +118,110 @@ describe("shouldExposeTool", () => {
112
118
  });
113
119
  });
114
120
 
115
- // ── handleBeforeAgentStart ─────────────────────────────────────────────────
121
+ // ── AgentPrepHandler.handle ────────────────────────────────────────────────
116
122
 
117
- describe("handleBeforeAgentStart", () => {
123
+ describe("AgentPrepHandler.handle", () => {
118
124
  it("activates the session with ctx", async () => {
119
125
  const ctx = makeCtx();
120
- const deps = makeDeps();
121
- await handleBeforeAgentStart(deps, makeEvent(), ctx);
122
- expect(deps.session.activate).toHaveBeenCalledWith(ctx);
126
+ const { handler, session } = makeHandler();
127
+ await handler.handle(makeEvent(), ctx);
128
+ expect(session.activate).toHaveBeenCalledWith(ctx);
123
129
  });
124
130
 
125
131
  it("refreshes config with ctx", async () => {
126
132
  const ctx = makeCtx();
127
- const deps = makeDeps();
128
- await handleBeforeAgentStart(deps, makeEvent(), ctx);
129
- expect(deps.session.refreshConfig).toHaveBeenCalledWith(ctx);
133
+ const { handler, session } = makeHandler();
134
+ await handler.handle(makeEvent(), ctx);
135
+ expect(session.refreshConfig).toHaveBeenCalledWith(ctx);
130
136
  });
131
137
 
132
138
  it("resolves agent name using systemPrompt", async () => {
133
139
  const ctx = makeCtx();
134
- const deps = makeDeps();
135
- await handleBeforeAgentStart(
136
- deps,
137
- makeEvent("<active_agent name='x'>"),
138
- ctx,
139
- );
140
- expect(deps.session.resolveAgentName).toHaveBeenCalledWith(
140
+ const { handler, session } = makeHandler();
141
+ await handler.handle(makeEvent("<active_agent name='x'>"), ctx);
142
+ expect(session.resolveAgentName).toHaveBeenCalledWith(
141
143
  ctx,
142
144
  "<active_agent name='x'>",
143
145
  );
144
146
  });
145
147
 
146
148
  it("filters out denied tools from allowed list", async () => {
147
- const session = makeSession({
148
- getToolPermission: vi.fn().mockReturnValue("deny"),
149
+ const { handler, toolRegistry } = makeHandler({
150
+ session: { getToolPermission: vi.fn().mockReturnValue("deny") },
151
+ toolRegistry: {
152
+ getAll: vi.fn().mockReturnValue([{ name: "write" }, { name: "read" }]),
153
+ },
149
154
  });
150
- const deps = makeDeps({
151
- session,
152
- getAllTools: vi
153
- .fn()
154
- .mockReturnValue([{ name: "write" }, { name: "read" }]),
155
- });
156
- await handleBeforeAgentStart(deps, makeEvent(), makeCtx());
157
- expect(deps.setActiveTools).toHaveBeenCalledWith([]);
155
+ await handler.handle(makeEvent(), makeCtx());
156
+ expect(toolRegistry.setActive).toHaveBeenCalledWith([]);
158
157
  });
159
158
 
160
159
  it("includes allowed and ask tools in the active list", async () => {
161
- const deps = makeDeps({
162
- getAllTools: vi
163
- .fn()
164
- .mockReturnValue([{ name: "read" }, { name: "write" }]),
160
+ const { handler, toolRegistry } = makeHandler({
161
+ toolRegistry: {
162
+ getAll: vi.fn().mockReturnValue([{ name: "read" }, { name: "write" }]),
163
+ },
165
164
  });
166
- await handleBeforeAgentStart(deps, makeEvent(), makeCtx());
167
- expect(deps.setActiveTools).toHaveBeenCalledWith(["read", "write"]);
165
+ await handler.handle(makeEvent(), makeCtx());
166
+ expect(toolRegistry.setActive).toHaveBeenCalledWith(["read", "write"]);
168
167
  });
169
168
 
170
169
  it("commits active-tools cache key after applying", async () => {
171
- const deps = makeDeps({
172
- getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
170
+ const { handler, session } = makeHandler({
171
+ toolRegistry: {
172
+ getAll: vi.fn().mockReturnValue([{ name: "read" }]),
173
+ },
173
174
  });
174
- await handleBeforeAgentStart(deps, makeEvent(), makeCtx());
175
- expect(deps.session.commitActiveToolsCacheKey).toHaveBeenCalled();
175
+ await handler.handle(makeEvent(), makeCtx());
176
+ expect(session.commitActiveToolsCacheKey).toHaveBeenCalled();
176
177
  });
177
178
 
178
- it("skips setActiveTools when cache key is unchanged", async () => {
179
- const session = makeSession({
180
- shouldUpdateActiveTools: vi.fn().mockReturnValue(false),
181
- });
182
- const deps = makeDeps({
183
- session,
184
- getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
179
+ it("skips setActive when cache key is unchanged", async () => {
180
+ const { handler, session, toolRegistry } = makeHandler({
181
+ session: { shouldUpdateActiveTools: vi.fn().mockReturnValue(false) },
182
+ toolRegistry: {
183
+ getAll: vi.fn().mockReturnValue([{ name: "read" }]),
184
+ },
185
185
  });
186
- await handleBeforeAgentStart(deps, makeEvent(), makeCtx());
187
- expect(deps.setActiveTools).not.toHaveBeenCalled();
186
+ await handler.handle(makeEvent(), makeCtx());
187
+ expect(toolRegistry.setActive).not.toHaveBeenCalled();
188
188
  expect(session.commitActiveToolsCacheKey).not.toHaveBeenCalled();
189
189
  });
190
190
 
191
191
  it("returns empty object when prompt cache is unchanged", async () => {
192
- const session = makeSession({
193
- shouldUpdatePromptState: vi.fn().mockReturnValue(false),
194
- });
195
- const deps = makeDeps({
196
- session,
197
- getAllTools: vi.fn().mockReturnValue([]),
192
+ const { handler, session } = makeHandler({
193
+ session: { shouldUpdatePromptState: vi.fn().mockReturnValue(false) },
198
194
  });
199
- const result = await handleBeforeAgentStart(deps, makeEvent(), makeCtx());
195
+ const result = await handler.handle(makeEvent(), makeCtx());
200
196
  expect(result).toEqual({});
201
197
  expect(session.commitPromptStateCacheKey).not.toHaveBeenCalled();
202
198
  });
203
199
 
204
200
  it("commits prompt-state cache key and processes prompt when cache is new", async () => {
205
- const deps = makeDeps({
206
- getAllTools: vi.fn().mockReturnValue([]),
207
- });
208
- await handleBeforeAgentStart(deps, makeEvent(), makeCtx());
209
- expect(deps.session.commitPromptStateCacheKey).toHaveBeenCalled();
201
+ const { handler, session } = makeHandler();
202
+ await handler.handle(makeEvent(), makeCtx());
203
+ expect(session.commitPromptStateCacheKey).toHaveBeenCalled();
210
204
  });
211
205
 
212
206
  it("stores resolved skill entries on the session", async () => {
213
- const deps = makeDeps({
214
- getAllTools: vi.fn().mockReturnValue([]),
215
- });
216
- await handleBeforeAgentStart(deps, makeEvent(), makeCtx());
217
- expect(deps.session.setActiveSkillEntries).toHaveBeenCalledWith(
207
+ const { handler, session } = makeHandler();
208
+ await handler.handle(makeEvent(), makeCtx());
209
+ expect(session.setActiveSkillEntries).toHaveBeenCalledWith(
218
210
  expect.any(Array),
219
211
  );
220
212
  });
221
213
 
222
214
  it("returns modified systemPrompt when prompt changes", async () => {
223
215
  const systemPrompt = `You are an assistant.\n\nAvailable tools:\n- read\n- write\n`;
224
- const deps = makeDeps({
225
- getAllTools: vi.fn().mockReturnValue([]),
226
- });
227
- const result = await handleBeforeAgentStart(
228
- deps,
229
- makeEvent(systemPrompt),
230
- makeCtx(),
231
- );
216
+ const { handler } = makeHandler();
217
+ const result = await handler.handle(makeEvent(systemPrompt), makeCtx());
232
218
  expect(result).toHaveProperty("systemPrompt");
233
219
  });
234
220
 
235
221
  it("returns empty object when systemPrompt is unchanged", async () => {
236
222
  const prompt = "No tools section here.";
237
- const deps = makeDeps({
238
- getAllTools: vi.fn().mockReturnValue([]),
239
- });
240
- const result = await handleBeforeAgentStart(
241
- deps,
242
- makeEvent(prompt),
243
- makeCtx(),
244
- );
223
+ const { handler } = makeHandler();
224
+ const result = await handler.handle(makeEvent(prompt), makeCtx());
245
225
  expect(result).toEqual({});
246
226
  });
247
227
  });
@@ -4,11 +4,11 @@
4
4
  import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
5
5
  import { describe, expect, it, vi } from "vitest";
6
6
 
7
- import { handleInput } from "../../src/handlers/input";
8
- import type { HandlerDeps } from "../../src/handlers/types";
7
+ import { PermissionGateHandler } from "../../src/handlers/permission-gate-handler";
9
8
  import type { PermissionDecisionEvent } from "../../src/permission-events";
10
9
  import { PERMISSIONS_DECISION_CHANNEL } from "../../src/permission-events";
11
10
  import type { PermissionSession } from "../../src/permission-session";
11
+ import type { ToolRegistry } from "../../src/tool-registry";
12
12
  import type { PermissionState } from "../../src/types";
13
13
 
14
14
  // ── helpers ────────────────────────────────────────────────────────────────
@@ -41,6 +41,7 @@ function makeCtx(overrides: Partial<ExtensionContext> = {}): ExtensionContext {
41
41
 
42
42
  function makeSession(
43
43
  state: "allow" | "deny" | "ask" = "allow",
44
+ overrides: Partial<Record<keyof PermissionSession, unknown>> = {},
44
45
  ): PermissionSession {
45
46
  return {
46
47
  logger: { debug: vi.fn(), review: vi.fn(), warn: vi.fn() },
@@ -56,31 +57,42 @@ function makeSession(
56
57
  getToolPermission: vi.fn().mockReturnValue("allow" as PermissionState),
57
58
  getSessionRuleset: vi.fn().mockReturnValue([]),
58
59
  approveSessionRule: vi.fn(),
60
+ canPrompt: vi.fn().mockReturnValue(true),
61
+ prompt: vi.fn().mockResolvedValue({ approved: true, state: "approved" }),
62
+ createPermissionRequestId: vi.fn().mockReturnValue("req-id"),
63
+ ...overrides,
59
64
  } as unknown as PermissionSession;
60
65
  }
61
66
 
62
- function makeDeps(
63
- state: "allow" | "deny" | "ask" = "allow",
64
- overrides: Partial<HandlerDeps> = {},
65
- ): HandlerDeps {
67
+ function makeToolRegistry(): ToolRegistry {
66
68
  return {
67
- session: makeSession(state),
68
- events: makeEvents(),
69
- canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
70
- promptPermission: vi
71
- .fn()
72
- .mockResolvedValue({ approved: true, state: "approved" }),
73
- createPermissionRequestId: vi.fn().mockReturnValue("req-id"),
74
- stopPermissionRpcHandlers: vi.fn(),
75
- getAllTools: vi.fn().mockReturnValue([]),
76
- setActiveTools: vi.fn(),
77
- ...overrides,
69
+ getAll: vi.fn().mockReturnValue([]),
70
+ setActive: vi.fn(),
78
71
  };
79
72
  }
80
73
 
81
- function getDecisionEvents(deps: HandlerDeps): PermissionDecisionEvent[] {
82
- const emitMock = (deps.events as ReturnType<typeof makeEvents>).emit;
83
- return emitMock.mock.calls
74
+ function makeHandler(
75
+ state: "allow" | "deny" | "ask" = "allow",
76
+ sessionOverrides: Partial<Record<keyof PermissionSession, unknown>> = {},
77
+ ): {
78
+ handler: PermissionGateHandler;
79
+ events: ReturnType<typeof makeEvents>;
80
+ } {
81
+ const session = makeSession(state, sessionOverrides);
82
+ const events = makeEvents();
83
+ const handler = new PermissionGateHandler(
84
+ session,
85
+ events,
86
+ makeToolRegistry(),
87
+ );
88
+ return { handler, events };
89
+ }
90
+
91
+ /** Extract all permissions:decision payloads from the events.emit mock. */
92
+ function getDecisionEvents(
93
+ events: ReturnType<typeof makeEvents>,
94
+ ): PermissionDecisionEvent[] {
95
+ return events.emit.mock.calls
84
96
  .filter(([channel]) => channel === PERMISSIONS_DECISION_CHANNEL)
85
97
  .map(([, payload]) => payload as PermissionDecisionEvent);
86
98
  }
@@ -89,18 +101,18 @@ function getDecisionEvents(deps: HandlerDeps): PermissionDecisionEvent[] {
89
101
 
90
102
  describe("handleInput decision events — skill gate", () => {
91
103
  it("does not emit when input is not a skill invocation", async () => {
92
- const deps = makeDeps();
93
- await handleInput(deps, { text: "hello world" }, makeCtx());
94
- expect(getDecisionEvents(deps)).toHaveLength(0);
104
+ const { handler, events } = makeHandler();
105
+ await handler.handleInput({ text: "hello world" }, makeCtx());
106
+ expect(getDecisionEvents(events)).toHaveLength(0);
95
107
  });
96
108
 
97
109
  it("emits allow with policy_allow for an allowed skill", async () => {
98
- const deps = makeDeps("allow");
99
- await handleInput(deps, { text: "/skill:librarian" }, makeCtx());
110
+ const { handler, events } = makeHandler("allow");
111
+ await handler.handleInput({ text: "/skill:librarian" }, makeCtx());
100
112
 
101
- const events = getDecisionEvents(deps);
102
- expect(events).toHaveLength(1);
103
- expect(events[0]).toMatchObject({
113
+ const decisions = getDecisionEvents(events);
114
+ expect(decisions).toHaveLength(1);
115
+ expect(decisions[0]).toMatchObject({
104
116
  surface: "skill",
105
117
  value: "librarian",
106
118
  result: "allow",
@@ -109,12 +121,12 @@ describe("handleInput decision events — skill gate", () => {
109
121
  });
110
122
 
111
123
  it("emits deny with policy_deny for a denied skill", async () => {
112
- const deps = makeDeps("deny");
113
- await handleInput(deps, { text: "/skill:restricted" }, makeCtx());
124
+ const { handler, events } = makeHandler("deny");
125
+ await handler.handleInput({ text: "/skill:restricted" }, makeCtx());
114
126
 
115
- const events = getDecisionEvents(deps);
116
- expect(events).toHaveLength(1);
117
- expect(events[0]).toMatchObject({
127
+ const decisions = getDecisionEvents(events);
128
+ expect(decisions).toHaveLength(1);
129
+ expect(decisions[0]).toMatchObject({
118
130
  surface: "skill",
119
131
  value: "restricted",
120
132
  result: "deny",
@@ -123,16 +135,14 @@ describe("handleInput decision events — skill gate", () => {
123
135
  });
124
136
 
125
137
  it("emits allow with user_approved when state=ask and user approves", async () => {
126
- const deps = makeDeps("ask", {
127
- promptPermission: vi
128
- .fn()
129
- .mockResolvedValue({ approved: true, state: "approved" }),
138
+ const { handler, events } = makeHandler("ask", {
139
+ prompt: vi.fn().mockResolvedValue({ approved: true, state: "approved" }),
130
140
  });
131
- await handleInput(deps, { text: "/skill:explorer" }, makeCtx());
141
+ await handler.handleInput({ text: "/skill:explorer" }, makeCtx());
132
142
 
133
- const events = getDecisionEvents(deps);
134
- expect(events).toHaveLength(1);
135
- expect(events[0]).toMatchObject({
143
+ const decisions = getDecisionEvents(events);
144
+ expect(decisions).toHaveLength(1);
145
+ expect(decisions[0]).toMatchObject({
136
146
  surface: "skill",
137
147
  value: "explorer",
138
148
  result: "allow",
@@ -141,16 +151,14 @@ describe("handleInput decision events — skill gate", () => {
141
151
  });
142
152
 
143
153
  it("emits deny with user_denied when state=ask and user denies", async () => {
144
- const deps = makeDeps("ask", {
145
- promptPermission: vi
146
- .fn()
147
- .mockResolvedValue({ approved: false, state: "denied" }),
154
+ const { handler, events } = makeHandler("ask", {
155
+ prompt: vi.fn().mockResolvedValue({ approved: false, state: "denied" }),
148
156
  });
149
- await handleInput(deps, { text: "/skill:explorer" }, makeCtx());
157
+ await handler.handleInput({ text: "/skill:explorer" }, makeCtx());
150
158
 
151
- const events = getDecisionEvents(deps);
152
- expect(events).toHaveLength(1);
153
- expect(events[0]).toMatchObject({
159
+ const decisions = getDecisionEvents(events);
160
+ expect(decisions).toHaveLength(1);
161
+ expect(decisions[0]).toMatchObject({
154
162
  surface: "skill",
155
163
  value: "explorer",
156
164
  result: "deny",
@@ -159,18 +167,17 @@ describe("handleInput decision events — skill gate", () => {
159
167
  });
160
168
 
161
169
  it("emits deny with confirmation_unavailable when state=ask but no UI", async () => {
162
- const deps = makeDeps("ask", {
163
- canRequestPermissionConfirmation: vi.fn().mockReturnValue(false),
170
+ const { handler, events } = makeHandler("ask", {
171
+ canPrompt: vi.fn().mockReturnValue(false),
164
172
  });
165
- await handleInput(
166
- deps,
173
+ await handler.handleInput(
167
174
  { text: "/skill:explorer" },
168
175
  makeCtx({ hasUI: false }),
169
176
  );
170
177
 
171
- const events = getDecisionEvents(deps);
172
- expect(events).toHaveLength(1);
173
- expect(events[0]).toMatchObject({
178
+ const decisions = getDecisionEvents(events);
179
+ expect(decisions).toHaveLength(1);
180
+ expect(decisions[0]).toMatchObject({
174
181
  surface: "skill",
175
182
  value: "explorer",
176
183
  result: "deny",
@@ -178,19 +185,19 @@ describe("handleInput decision events — skill gate", () => {
178
185
  });
179
186
  });
180
187
 
181
- it("emits allow with auto_approved when promptPermission returns autoApproved:true", async () => {
182
- const deps = makeDeps("ask", {
183
- promptPermission: vi.fn().mockResolvedValue({
188
+ it("emits allow with auto_approved when prompt returns autoApproved:true", async () => {
189
+ const { handler, events } = makeHandler("ask", {
190
+ prompt: vi.fn().mockResolvedValue({
184
191
  approved: true,
185
192
  state: "approved",
186
193
  autoApproved: true,
187
194
  }),
188
195
  });
189
- await handleInput(deps, { text: "/skill:explorer" }, makeCtx());
196
+ await handler.handleInput({ text: "/skill:explorer" }, makeCtx());
190
197
 
191
- const events = getDecisionEvents(deps);
192
- expect(events).toHaveLength(1);
193
- expect(events[0]).toMatchObject({
198
+ const decisions = getDecisionEvents(events);
199
+ expect(decisions).toHaveLength(1);
200
+ expect(decisions[0]).toMatchObject({
194
201
  surface: "skill",
195
202
  value: "explorer",
196
203
  result: "allow",