@gotgenes/pi-permission-system 8.1.0 → 8.2.1

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 (44) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/package.json +1 -1
  3. package/src/config-loader.ts +53 -46
  4. package/src/handlers/gates/bash-external-directory.ts +2 -4
  5. package/src/handlers/gates/bash-path-extractor.ts +135 -169
  6. package/src/handlers/gates/bash-path.ts +2 -4
  7. package/src/handlers/gates/bash-token-classification.ts +105 -0
  8. package/src/handlers/gates/descriptor.ts +6 -6
  9. package/src/handlers/gates/external-directory.ts +2 -4
  10. package/src/handlers/gates/helpers.ts +30 -1
  11. package/src/handlers/gates/path.ts +2 -4
  12. package/src/handlers/gates/runner.ts +29 -56
  13. package/src/handlers/gates/tool.ts +5 -4
  14. package/src/handlers/permission-gate-handler.ts +4 -3
  15. package/src/permission-manager.ts +6 -49
  16. package/src/permission-session.ts +3 -2
  17. package/src/scope-merge.ts +72 -0
  18. package/src/session-approval.ts +43 -0
  19. package/src/session-rules.ts +13 -0
  20. package/test/config-loader.test.ts +82 -0
  21. package/test/handlers/before-agent-start.test.ts +2 -20
  22. package/test/handlers/external-directory-integration.test.ts +44 -82
  23. package/test/handlers/external-directory-session-dedup.test.ts +17 -41
  24. package/test/handlers/gates/bash-external-directory.test.ts +11 -9
  25. package/test/handlers/gates/bash-path.test.ts +5 -26
  26. package/test/handlers/gates/bash-token-classification.test.ts +241 -0
  27. package/test/handlers/gates/external-directory.test.ts +2 -5
  28. package/test/handlers/gates/helpers.test.ts +81 -0
  29. package/test/handlers/gates/path.test.ts +5 -14
  30. package/test/handlers/gates/runner.test.ts +95 -113
  31. package/test/handlers/gates/tool.test.ts +2 -2
  32. package/test/handlers/input-events.test.ts +42 -95
  33. package/test/handlers/input.test.ts +3 -71
  34. package/test/handlers/lifecycle.test.ts +3 -20
  35. package/test/handlers/tool-call-events.test.ts +30 -127
  36. package/test/handlers/tool-call.test.ts +21 -110
  37. package/test/helpers/gate-fixtures.ts +105 -0
  38. package/test/helpers/handler-fixtures.ts +141 -0
  39. package/test/helpers/manager-harness.ts +51 -0
  40. package/test/permission-session.test.ts +7 -22
  41. package/test/permission-system.test.ts +4 -40
  42. package/test/scope-merge.test.ts +116 -0
  43. package/test/session-approval.test.ts +75 -0
  44. package/test/session-rules.test.ts +49 -0
@@ -2,75 +2,11 @@ import { describe, expect, it, vi } from "vitest";
2
2
 
3
3
  import type { DenialContext } from "#src/denial-messages";
4
4
  import { EXTENSION_TAG } from "#src/denial-messages";
5
- import type {
6
- GateDescriptor,
7
- GateRunnerDeps,
8
- } from "#src/handlers/gates/descriptor";
5
+ import type { GateDescriptor } from "#src/handlers/gates/descriptor";
9
6
  import { runGateCheck } from "#src/handlers/gates/runner";
10
- import type { PermissionCheckResult } from "#src/types";
11
-
12
- // ── helpers ────────────────────────────────────────────────────────────────
13
-
14
- function makeDescriptor(
15
- overrides: Partial<GateDescriptor> = {},
16
- ): GateDescriptor {
17
- return {
18
- surface: "read",
19
- input: {},
20
- denialContext: {
21
- kind: "tool",
22
- check: makeCheckResult("deny"),
23
- },
24
- promptDetails: {
25
- source: "tool_call",
26
- agentName: null,
27
- message: "Allow tool 'read'?",
28
- toolCallId: "tc-1",
29
- toolName: "read",
30
- },
31
- logContext: {
32
- source: "tool_call",
33
- toolCallId: "tc-1",
34
- toolName: "read",
35
- },
36
- decision: {
37
- surface: "read",
38
- value: "read",
39
- },
40
- ...overrides,
41
- };
42
- }
43
-
44
- function makeCheckResult(
45
- state: "allow" | "deny" | "ask",
46
- overrides: Partial<PermissionCheckResult> = {},
47
- ): PermissionCheckResult {
48
- return {
49
- state,
50
- toolName: "read",
51
- source: "tool",
52
- origin: "builtin",
53
- matchedPattern: "*",
54
- ...overrides,
55
- };
56
- }
57
-
58
- function makeRunnerDeps(
59
- overrides: Partial<GateRunnerDeps> = {},
60
- ): GateRunnerDeps {
61
- return {
62
- checkPermission: vi.fn().mockReturnValue(makeCheckResult("allow")),
63
- getSessionRuleset: vi.fn().mockReturnValue([]),
64
- approveSessionRule: vi.fn(),
65
- writeReviewLog: vi.fn(),
66
- emitDecision: vi.fn(),
67
- canConfirm: vi.fn().mockReturnValue(true),
68
- promptPermission: vi
69
- .fn()
70
- .mockResolvedValue({ approved: true, state: "approved" }),
71
- ...overrides,
72
- };
73
- }
7
+ import { SessionApproval } from "#src/session-approval";
8
+ import { makeDescriptor, makeRunnerDeps } from "#test/helpers/gate-fixtures";
9
+ import { makeCheckResult } from "#test/helpers/handler-fixtures";
74
10
 
75
11
  // ── tests ──────────────────────────────────────────────────────────────────
76
12
 
@@ -91,7 +27,11 @@ describe("runGateCheck", () => {
91
27
 
92
28
  it("returns block and emits policy_deny when policy is deny", async () => {
93
29
  const deps = makeRunnerDeps({
94
- checkPermission: vi.fn().mockReturnValue(makeCheckResult("deny")),
30
+ checkPermission: vi
31
+ .fn()
32
+ .mockReturnValue(
33
+ makeCheckResult({ state: "deny", matchedPattern: "*" }),
34
+ ),
95
35
  });
96
36
  const result = await runGateCheck(makeDescriptor(), null, "tc-1", deps);
97
37
  expect(result).toMatchObject({ action: "block" });
@@ -109,12 +49,11 @@ describe("runGateCheck", () => {
109
49
 
110
50
  it("returns allow and emits session_approved on session hit", async () => {
111
51
  const deps = makeRunnerDeps({
112
- checkPermission: vi.fn().mockReturnValue(
113
- makeCheckResult("allow", {
114
- source: "session",
115
- matchedPattern: "git *",
116
- }),
117
- ),
52
+ checkPermission: vi
53
+ .fn()
54
+ .mockReturnValue(
55
+ makeCheckResult({ source: "session", matchedPattern: "git *" }),
56
+ ),
118
57
  });
119
58
  const result = await runGateCheck(
120
59
  makeDescriptor({
@@ -144,7 +83,11 @@ describe("runGateCheck", () => {
144
83
 
145
84
  it("returns allow and emits user_approved when ask + user approves", async () => {
146
85
  const deps = makeRunnerDeps({
147
- checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
86
+ checkPermission: vi
87
+ .fn()
88
+ .mockReturnValue(
89
+ makeCheckResult({ state: "ask", matchedPattern: "*" }),
90
+ ),
148
91
  promptPermission: vi
149
92
  .fn()
150
93
  .mockResolvedValue({ approved: true, state: "approved" }),
@@ -161,13 +104,17 @@ describe("runGateCheck", () => {
161
104
 
162
105
  it("returns allow, emits user_approved_for_session, and records session rule on approved_for_session", async () => {
163
106
  const deps = makeRunnerDeps({
164
- checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
107
+ checkPermission: vi
108
+ .fn()
109
+ .mockReturnValue(
110
+ makeCheckResult({ state: "ask", matchedPattern: "*" }),
111
+ ),
165
112
  promptPermission: vi
166
113
  .fn()
167
114
  .mockResolvedValue({ approved: true, state: "approved_for_session" }),
168
115
  });
169
116
  const descriptor = makeDescriptor({
170
- sessionApproval: { surface: "read", pattern: "*" },
117
+ sessionApproval: SessionApproval.single("read", "*"),
171
118
  });
172
119
  const result = await runGateCheck(descriptor, null, "tc-1", deps);
173
120
  expect(result).toEqual({ action: "allow" });
@@ -176,38 +123,40 @@ describe("runGateCheck", () => {
176
123
  resolution: "user_approved_for_session",
177
124
  }),
178
125
  );
179
- expect(deps.approveSessionRule).toHaveBeenCalledWith("read", "*");
126
+ expect(deps.recordSessionApproval).toHaveBeenCalledWith(
127
+ SessionApproval.single("read", "*"),
128
+ );
180
129
  });
181
130
 
182
- it("calls approveSessionRule once per pattern when sessionApproval has multiple patterns", async () => {
131
+ it("calls recordSessionApproval once with the full SessionApproval when sessionApproval has multiple patterns", async () => {
183
132
  const deps = makeRunnerDeps({
184
- checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
133
+ checkPermission: vi
134
+ .fn()
135
+ .mockReturnValue(
136
+ makeCheckResult({ state: "ask", matchedPattern: "*" }),
137
+ ),
185
138
  promptPermission: vi
186
139
  .fn()
187
140
  .mockResolvedValue({ approved: true, state: "approved_for_session" }),
188
141
  });
189
- const descriptor = makeDescriptor({
190
- sessionApproval: {
191
- surface: "external_directory",
192
- patterns: ["/outside/a/*", "/outside/b/*"],
193
- },
194
- });
195
- const result = await runGateCheck(descriptor, null, "tc-1", deps);
196
- expect(result).toEqual({ action: "allow" });
197
- expect(deps.approveSessionRule).toHaveBeenCalledTimes(2);
198
- expect(deps.approveSessionRule).toHaveBeenCalledWith(
199
- "external_directory",
142
+ const approval = SessionApproval.multiple("external_directory", [
200
143
  "/outside/a/*",
201
- );
202
- expect(deps.approveSessionRule).toHaveBeenCalledWith(
203
- "external_directory",
204
144
  "/outside/b/*",
205
- );
145
+ ]);
146
+ const descriptor = makeDescriptor({ sessionApproval: approval });
147
+ const result = await runGateCheck(descriptor, null, "tc-1", deps);
148
+ expect(result).toEqual({ action: "allow" });
149
+ expect(deps.recordSessionApproval).toHaveBeenCalledTimes(1);
150
+ expect(deps.recordSessionApproval).toHaveBeenCalledWith(approval);
206
151
  });
207
152
 
208
153
  it("returns block and emits user_denied when ask + user denies", async () => {
209
154
  const deps = makeRunnerDeps({
210
- checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
155
+ checkPermission: vi
156
+ .fn()
157
+ .mockReturnValue(
158
+ makeCheckResult({ state: "ask", matchedPattern: "*" }),
159
+ ),
211
160
  promptPermission: vi
212
161
  .fn()
213
162
  .mockResolvedValue({ approved: false, state: "denied" }),
@@ -224,7 +173,11 @@ describe("runGateCheck", () => {
224
173
 
225
174
  it("returns block and emits confirmation_unavailable when ask + no UI", async () => {
226
175
  const deps = makeRunnerDeps({
227
- checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
176
+ checkPermission: vi
177
+ .fn()
178
+ .mockReturnValue(
179
+ makeCheckResult({ state: "ask", matchedPattern: "*" }),
180
+ ),
228
181
  canConfirm: vi.fn().mockReturnValue(false),
229
182
  });
230
183
  const result = await runGateCheck(makeDescriptor(), null, "tc-1", deps);
@@ -239,7 +192,11 @@ describe("runGateCheck", () => {
239
192
 
240
193
  it("emits auto_approved resolution when decision has autoApproved flag", async () => {
241
194
  const deps = makeRunnerDeps({
242
- checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
195
+ checkPermission: vi
196
+ .fn()
197
+ .mockReturnValue(
198
+ makeCheckResult({ state: "ask", matchedPattern: "*" }),
199
+ ),
243
200
  promptPermission: vi.fn().mockResolvedValue({
244
201
  approved: true,
245
202
  state: "approved",
@@ -309,7 +266,11 @@ describe("runGateCheck", () => {
309
266
 
310
267
  it("passes requestId from toolCallId to promptPermission", async () => {
311
268
  const deps = makeRunnerDeps({
312
- checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
269
+ checkPermission: vi
270
+ .fn()
271
+ .mockReturnValue(
272
+ makeCheckResult({ state: "ask", matchedPattern: "*" }),
273
+ ),
313
274
  });
314
275
  await runGateCheck(makeDescriptor(), null, "tc-42", deps);
315
276
  expect(deps.promptPermission).toHaveBeenCalledWith(
@@ -317,21 +278,26 @@ describe("runGateCheck", () => {
317
278
  );
318
279
  });
319
280
 
320
- it("does not call approveSessionRule when user approves once (no sessionApproval)", async () => {
281
+ it("does not call recordSessionApproval when user approves once (no sessionApproval)", async () => {
321
282
  const deps = makeRunnerDeps({
322
- checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
283
+ checkPermission: vi
284
+ .fn()
285
+ .mockReturnValue(
286
+ makeCheckResult({ state: "ask", matchedPattern: "*" }),
287
+ ),
323
288
  promptPermission: vi
324
289
  .fn()
325
290
  .mockResolvedValue({ approved: true, state: "approved" }),
326
291
  });
327
292
  await runGateCheck(makeDescriptor(), null, "tc-1", deps);
328
- expect(deps.approveSessionRule).not.toHaveBeenCalled();
293
+ expect(deps.recordSessionApproval).not.toHaveBeenCalled();
329
294
  });
330
295
 
331
296
  it("uses preCheck result directly instead of calling checkPermission", async () => {
332
297
  const deps = makeRunnerDeps();
333
298
  const descriptor = makeDescriptor({
334
- preCheck: makeCheckResult("deny", {
299
+ preCheck: makeCheckResult({
300
+ state: "deny",
335
301
  origin: "global",
336
302
  matchedPattern: "rm *",
337
303
  }),
@@ -348,16 +314,20 @@ describe("runGateCheck", () => {
348
314
  );
349
315
  });
350
316
 
351
- it("does not call approveSessionRule when user approves for session but no sessionApproval on descriptor", async () => {
317
+ it("does not call recordSessionApproval when user approves for session but no sessionApproval on descriptor", async () => {
352
318
  const deps = makeRunnerDeps({
353
- checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
319
+ checkPermission: vi
320
+ .fn()
321
+ .mockReturnValue(
322
+ makeCheckResult({ state: "ask", matchedPattern: "*" }),
323
+ ),
354
324
  promptPermission: vi
355
325
  .fn()
356
326
  .mockResolvedValue({ approved: true, state: "approved_for_session" }),
357
327
  });
358
328
  // No sessionApproval on descriptor
359
329
  await runGateCheck(makeDescriptor(), null, "tc-1", deps);
360
- expect(deps.approveSessionRule).not.toHaveBeenCalled();
330
+ expect(deps.recordSessionApproval).not.toHaveBeenCalled();
361
331
  });
362
332
 
363
333
  describe("denialContext formatting", () => {
@@ -391,11 +361,15 @@ describe("runGateCheck", () => {
391
361
 
392
362
  it("uses denialContext to format denyReason with extension tag", async () => {
393
363
  const deps = makeRunnerDeps({
394
- checkPermission: vi.fn().mockReturnValue(makeCheckResult("deny")),
364
+ checkPermission: vi
365
+ .fn()
366
+ .mockReturnValue(
367
+ makeCheckResult({ state: "deny", matchedPattern: "*" }),
368
+ ),
395
369
  });
396
370
  const ctx: DenialContext = {
397
371
  kind: "tool",
398
- check: makeCheckResult("deny"),
372
+ check: makeCheckResult({ state: "deny", matchedPattern: "*" }),
399
373
  agentName: "test-agent",
400
374
  };
401
375
  const result = await runGateCheck(
@@ -413,12 +387,16 @@ describe("runGateCheck", () => {
413
387
 
414
388
  it("uses denialContext to format unavailableReason with extension tag", async () => {
415
389
  const deps = makeRunnerDeps({
416
- checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
390
+ checkPermission: vi
391
+ .fn()
392
+ .mockReturnValue(
393
+ makeCheckResult({ state: "ask", matchedPattern: "*" }),
394
+ ),
417
395
  canConfirm: vi.fn().mockReturnValue(false),
418
396
  });
419
397
  const ctx: DenialContext = {
420
398
  kind: "tool",
421
- check: makeCheckResult("ask"),
399
+ check: makeCheckResult({ state: "ask", matchedPattern: "*" }),
422
400
  };
423
401
  const result = await runGateCheck(
424
402
  makeDenialContextDescriptor(ctx),
@@ -435,7 +413,11 @@ describe("runGateCheck", () => {
435
413
 
436
414
  it("uses denialContext to format userDeniedReason with extension tag", async () => {
437
415
  const deps = makeRunnerDeps({
438
- checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
416
+ checkPermission: vi
417
+ .fn()
418
+ .mockReturnValue(
419
+ makeCheckResult({ state: "ask", matchedPattern: "*" }),
420
+ ),
439
421
  promptPermission: vi.fn().mockResolvedValue({
440
422
  approved: false,
441
423
  state: "denied",
@@ -444,7 +426,7 @@ describe("runGateCheck", () => {
444
426
  });
445
427
  const ctx: DenialContext = {
446
428
  kind: "tool",
447
- check: makeCheckResult("ask"),
429
+ check: makeCheckResult({ state: "ask", matchedPattern: "*" }),
448
430
  };
449
431
  const result = await runGateCheck(
450
432
  makeDenialContextDescriptor(ctx),
@@ -142,8 +142,8 @@ describe("describeToolGate", () => {
142
142
  makeFormatter(),
143
143
  );
144
144
  expect(desc.sessionApproval).toBeDefined();
145
- expect(desc.sessionApproval!).toHaveProperty("surface", "bash");
146
- expect(desc.sessionApproval!).toHaveProperty("pattern");
145
+ expect(desc.sessionApproval?.surface).toBe("bash");
146
+ expect(desc.sessionApproval?.representativePattern).toBeDefined();
147
147
  });
148
148
 
149
149
  it("populates promptDetails with correct fields", () => {
@@ -1,99 +1,28 @@
1
1
  /**
2
2
  * Tests that handleInput emits permissions:decision events for skill input gates.
3
3
  */
4
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
4
  import { describe, expect, it, vi } from "vitest";
6
5
 
7
- import { PermissionGateHandler } from "#src/handlers/permission-gate-handler";
8
- import type { PermissionDecisionEvent } from "#src/permission-events";
9
- import { PERMISSIONS_DECISION_CHANNEL } from "#src/permission-events";
10
- import type { PermissionSession } from "#src/permission-session";
11
- import type { ToolRegistry } from "#src/tool-registry";
6
+ import {
7
+ getDecisionEvents,
8
+ makeCheckResult,
9
+ makeCtx,
10
+ makeHandler,
11
+ } from "#test/helpers/handler-fixtures";
12
12
 
13
13
  // ── helpers ────────────────────────────────────────────────────────────────
14
14
 
15
- function makeEvents() {
16
- return {
17
- emit: vi.fn(),
18
- on: vi.fn().mockReturnValue(() => undefined),
19
- };
20
- }
21
-
22
- function makeCtx(overrides: Partial<ExtensionContext> = {}): ExtensionContext {
23
- return {
24
- cwd: "/test/project",
25
- hasUI: true,
26
- ui: {
27
- setStatus: vi.fn(),
28
- notify: vi.fn(),
29
- select: vi.fn(),
30
- input: vi.fn(),
31
- },
32
- sessionManager: {
33
- getEntries: vi.fn().mockReturnValue([]),
34
- getSessionDir: vi.fn().mockReturnValue("/sessions/test"),
35
- addEntry: vi.fn(),
36
- },
37
- ...overrides,
38
- } as unknown as ExtensionContext;
39
- }
40
-
41
- function makeSession(
42
- state: "allow" | "deny" | "ask" = "allow",
43
- overrides: Partial<Record<keyof PermissionSession, unknown>> = {},
44
- ): PermissionSession {
45
- return {
46
- logger: { debug: vi.fn(), review: vi.fn(), warn: vi.fn() },
47
- activate: vi.fn(),
48
- resolveAgentName: vi.fn().mockReturnValue(null),
49
- checkPermission: vi.fn().mockReturnValue({
15
+ /** Build a checkPermission mock returning a skill-surface result. */
16
+ function makeSkillCheckPermission(state: "allow" | "deny" | "ask") {
17
+ return vi.fn().mockReturnValue(
18
+ makeCheckResult({
50
19
  state,
51
20
  toolName: "skill",
52
21
  source: "skill",
53
22
  origin: "global",
54
23
  matchedPattern: "*",
55
24
  }),
56
- getToolPermission: vi.fn().mockReturnValue("allow"),
57
- getSessionRuleset: vi.fn().mockReturnValue([]),
58
- approveSessionRule: vi.fn(),
59
- canPrompt: vi.fn().mockReturnValue(true),
60
- prompt: vi.fn().mockResolvedValue({ approved: true, state: "approved" }),
61
- createPermissionRequestId: vi.fn().mockReturnValue("req-id"),
62
- ...overrides,
63
- } as unknown as PermissionSession;
64
- }
65
-
66
- function makeToolRegistry(): ToolRegistry {
67
- return {
68
- getAll: vi.fn().mockReturnValue([]),
69
- setActive: vi.fn(),
70
- };
71
- }
72
-
73
- function makeHandler(
74
- state: "allow" | "deny" | "ask" = "allow",
75
- sessionOverrides: Partial<Record<keyof PermissionSession, unknown>> = {},
76
- ): {
77
- handler: PermissionGateHandler;
78
- events: ReturnType<typeof makeEvents>;
79
- } {
80
- const session = makeSession(state, sessionOverrides);
81
- const events = makeEvents();
82
- const handler = new PermissionGateHandler(
83
- session,
84
- events,
85
- makeToolRegistry(),
86
25
  );
87
- return { handler, events };
88
- }
89
-
90
- /** Extract all permissions:decision payloads from the events.emit mock. */
91
- function getDecisionEvents(
92
- events: ReturnType<typeof makeEvents>,
93
- ): PermissionDecisionEvent[] {
94
- return events.emit.mock.calls
95
- .filter(([channel]) => channel === PERMISSIONS_DECISION_CHANNEL)
96
- .map(([, payload]) => payload as PermissionDecisionEvent);
97
26
  }
98
27
 
99
28
  // ── tests ──────────────────────────────────────────────────────────────────
@@ -106,7 +35,9 @@ describe("handleInput decision events — skill gate", () => {
106
35
  });
107
36
 
108
37
  it("emits allow with policy_allow for an allowed skill", async () => {
109
- const { handler, events } = makeHandler("allow");
38
+ const { handler, events } = makeHandler({
39
+ session: { checkPermission: makeSkillCheckPermission("allow") },
40
+ });
110
41
  await handler.handleInput({ text: "/skill:librarian" }, makeCtx());
111
42
 
112
43
  const decisions = getDecisionEvents(events);
@@ -120,7 +51,9 @@ describe("handleInput decision events — skill gate", () => {
120
51
  });
121
52
 
122
53
  it("emits deny with policy_deny for a denied skill", async () => {
123
- const { handler, events } = makeHandler("deny");
54
+ const { handler, events } = makeHandler({
55
+ session: { checkPermission: makeSkillCheckPermission("deny") },
56
+ });
124
57
  await handler.handleInput({ text: "/skill:restricted" }, makeCtx());
125
58
 
126
59
  const decisions = getDecisionEvents(events);
@@ -134,8 +67,13 @@ describe("handleInput decision events — skill gate", () => {
134
67
  });
135
68
 
136
69
  it("emits allow with user_approved when state=ask and user approves", async () => {
137
- const { handler, events } = makeHandler("ask", {
138
- prompt: vi.fn().mockResolvedValue({ approved: true, state: "approved" }),
70
+ const { handler, events } = makeHandler({
71
+ session: {
72
+ checkPermission: makeSkillCheckPermission("ask"),
73
+ prompt: vi
74
+ .fn()
75
+ .mockResolvedValue({ approved: true, state: "approved" }),
76
+ },
139
77
  });
140
78
  await handler.handleInput({ text: "/skill:explorer" }, makeCtx());
141
79
 
@@ -150,8 +88,11 @@ describe("handleInput decision events — skill gate", () => {
150
88
  });
151
89
 
152
90
  it("emits deny with user_denied when state=ask and user denies", async () => {
153
- const { handler, events } = makeHandler("ask", {
154
- prompt: vi.fn().mockResolvedValue({ approved: false, state: "denied" }),
91
+ const { handler, events } = makeHandler({
92
+ session: {
93
+ checkPermission: makeSkillCheckPermission("ask"),
94
+ prompt: vi.fn().mockResolvedValue({ approved: false, state: "denied" }),
95
+ },
155
96
  });
156
97
  await handler.handleInput({ text: "/skill:explorer" }, makeCtx());
157
98
 
@@ -166,8 +107,11 @@ describe("handleInput decision events — skill gate", () => {
166
107
  });
167
108
 
168
109
  it("emits deny with confirmation_unavailable when state=ask but no UI", async () => {
169
- const { handler, events } = makeHandler("ask", {
170
- canPrompt: vi.fn().mockReturnValue(false),
110
+ const { handler, events } = makeHandler({
111
+ session: {
112
+ checkPermission: makeSkillCheckPermission("ask"),
113
+ canPrompt: vi.fn().mockReturnValue(false),
114
+ },
171
115
  });
172
116
  await handler.handleInput(
173
117
  { text: "/skill:explorer" },
@@ -185,12 +129,15 @@ describe("handleInput decision events — skill gate", () => {
185
129
  });
186
130
 
187
131
  it("emits allow with auto_approved when prompt returns autoApproved:true", async () => {
188
- const { handler, events } = makeHandler("ask", {
189
- prompt: vi.fn().mockResolvedValue({
190
- approved: true,
191
- state: "approved",
192
- autoApproved: true,
193
- }),
132
+ const { handler, events } = makeHandler({
133
+ session: {
134
+ checkPermission: makeSkillCheckPermission("ask"),
135
+ prompt: vi.fn().mockResolvedValue({
136
+ approved: true,
137
+ state: "approved",
138
+ autoApproved: true,
139
+ }),
140
+ },
194
141
  });
195
142
  await handler.handleInput({ text: "/skill:explorer" }, makeCtx());
196
143
 
@@ -1,83 +1,15 @@
1
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
1
  import { describe, expect, it, vi } from "vitest";
3
2
 
4
- import {
5
- extractSkillNameFromInput,
6
- PermissionGateHandler,
7
- } from "#src/handlers/permission-gate-handler";
8
- import type { PermissionSession } from "#src/permission-session";
9
- import type { ToolRegistry } from "#src/tool-registry";
3
+ import { extractSkillNameFromInput } from "#src/handlers/permission-gate-handler";
10
4
 
11
- // ── helpers ────────────────────────────────────────────────────────────────
5
+ import { makeCtx, makeHandler } from "#test/helpers/handler-fixtures";
12
6
 
13
- function makeCtx(overrides: Partial<ExtensionContext> = {}): ExtensionContext {
14
- return {
15
- cwd: "/test/project",
16
- hasUI: true,
17
- ui: {
18
- setStatus: vi.fn(),
19
- notify: vi.fn(),
20
- select: vi.fn(),
21
- input: vi.fn(),
22
- },
23
- sessionManager: {
24
- getEntries: vi.fn().mockReturnValue([]),
25
- getSessionDir: vi.fn().mockReturnValue("/sessions/test"),
26
- addEntry: vi.fn(),
27
- },
28
- ...overrides,
29
- } as unknown as ExtensionContext;
30
- }
7
+ // ── helpers ────────────────────────────────────────────────────────────────
31
8
 
32
9
  function makeInputEvent(text: string) {
33
10
  return { text };
34
11
  }
35
12
 
36
- function makeSession(
37
- overrides: Partial<Record<keyof PermissionSession, unknown>> = {},
38
- ): PermissionSession {
39
- return {
40
- logger: { debug: vi.fn(), review: vi.fn(), warn: vi.fn() },
41
- activate: vi.fn(),
42
- resolveAgentName: vi.fn().mockReturnValue(null),
43
- checkPermission: vi.fn().mockReturnValue({ state: "allow" }),
44
- getToolPermission: vi.fn().mockReturnValue("allow"),
45
- getSessionRuleset: vi.fn().mockReturnValue([]),
46
- approveSessionRule: vi.fn(),
47
- canPrompt: vi.fn().mockReturnValue(true),
48
- prompt: vi.fn().mockResolvedValue({ approved: true, state: "approved" }),
49
- createPermissionRequestId: vi.fn().mockReturnValue("req-id"),
50
- ...overrides,
51
- } as unknown as PermissionSession;
52
- }
53
-
54
- function makeEvents() {
55
- return {
56
- emit: vi.fn(),
57
- on: vi.fn().mockReturnValue(() => undefined),
58
- };
59
- }
60
-
61
- function makeToolRegistry(): ToolRegistry {
62
- return {
63
- getAll: vi.fn().mockReturnValue([]),
64
- setActive: vi.fn(),
65
- };
66
- }
67
-
68
- function makeHandler(overrides?: {
69
- session?: Partial<Record<keyof PermissionSession, unknown>>;
70
- }): {
71
- handler: PermissionGateHandler;
72
- session: PermissionSession;
73
- } {
74
- const session = makeSession(overrides?.session);
75
- const events = makeEvents();
76
- const toolRegistry = makeToolRegistry();
77
- const handler = new PermissionGateHandler(session, events, toolRegistry);
78
- return { handler, session };
79
- }
80
-
81
13
  // ── extractSkillNameFromInput ──────────────────────────────────────────────
82
14
 
83
15
  describe("extractSkillNameFromInput", () => {
@@ -1,8 +1,10 @@
1
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
1
  import { describe, expect, it, vi } from "vitest";
2
+
3
3
  import { SessionLifecycleHandler } from "#src/handlers/lifecycle";
4
4
  import type { PermissionSession } from "#src/permission-session";
5
5
 
6
+ import { makeCtx } from "#test/helpers/handler-fixtures";
7
+
6
8
  // ── status stub ────────────────────────────────────────────────────────────
7
9
  vi.mock("../../src/status", () => ({
8
10
  PERMISSION_SYSTEM_STATUS_KEY: "permission-system",
@@ -12,25 +14,6 @@ vi.mock("../../src/status", () => ({
12
14
 
13
15
  // ── helpers ────────────────────────────────────────────────────────────────
14
16
 
15
- function makeCtx(overrides: Partial<ExtensionContext> = {}): ExtensionContext {
16
- return {
17
- cwd: "/test/project",
18
- hasUI: true,
19
- ui: {
20
- setStatus: vi.fn(),
21
- notify: vi.fn(),
22
- select: vi.fn(),
23
- input: vi.fn(),
24
- },
25
- sessionManager: {
26
- getEntries: vi.fn().mockReturnValue([]),
27
- getSessionDir: vi.fn().mockReturnValue("/sessions/test"),
28
- addEntry: vi.fn(),
29
- },
30
- ...overrides,
31
- } as unknown as ExtensionContext;
32
- }
33
-
34
17
  function makeSession(
35
18
  overrides: Partial<Record<keyof PermissionSession, unknown>> = {},
36
19
  ): PermissionSession {