@gotgenes/pi-permission-system 5.2.0 → 5.3.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.
@@ -0,0 +1,389 @@
1
+ /**
2
+ * Tests that handleToolCall emits permissions:decision events at every
3
+ * gate resolution and fast-path site.
4
+ */
5
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
6
+ import { describe, expect, it, vi } from "vitest";
7
+
8
+ import { handleToolCall } from "../../src/handlers/tool-call";
9
+ import type { HandlerDeps } from "../../src/handlers/types";
10
+ import type { PermissionDecisionEvent } from "../../src/permission-events";
11
+ import { PERMISSIONS_DECISION_CHANNEL } from "../../src/permission-events";
12
+ import type { ExtensionRuntime } from "../../src/runtime";
13
+ import type { PermissionCheckResult } from "../../src/types";
14
+
15
+ // ── helpers ────────────────────────────────────────────────────────────────
16
+
17
+ function makeEvents() {
18
+ return {
19
+ emit: vi.fn(),
20
+ on: vi.fn().mockReturnValue(() => undefined),
21
+ };
22
+ }
23
+
24
+ function makeCtx(overrides: Partial<ExtensionContext> = {}): ExtensionContext {
25
+ return {
26
+ cwd: "/test/project",
27
+ hasUI: true,
28
+ ui: {
29
+ setStatus: vi.fn(),
30
+ notify: vi.fn(),
31
+ select: vi.fn(),
32
+ input: vi.fn(),
33
+ },
34
+ sessionManager: {
35
+ getEntries: vi.fn().mockReturnValue([]),
36
+ getSessionDir: vi.fn().mockReturnValue("/sessions/test"),
37
+ addEntry: vi.fn(),
38
+ },
39
+ ...overrides,
40
+ } as unknown as ExtensionContext;
41
+ }
42
+
43
+ function makeToolCallEvent(
44
+ toolName: string,
45
+ extraFields: Record<string, unknown> = {},
46
+ ) {
47
+ return {
48
+ type: "tool_call",
49
+ toolCallId: "tc-1",
50
+ name: toolName,
51
+ input: {},
52
+ ...extraFields,
53
+ };
54
+ }
55
+
56
+ function makeCheckResult(
57
+ state: "allow" | "deny" | "ask",
58
+ overrides: Partial<PermissionCheckResult> = {},
59
+ ): PermissionCheckResult {
60
+ return {
61
+ state,
62
+ toolName: "read",
63
+ source: "tool",
64
+ origin: "builtin",
65
+ matchedPattern: "*",
66
+ ...overrides,
67
+ };
68
+ }
69
+
70
+ function makeRuntime(
71
+ overrides: Partial<ExtensionRuntime> = {},
72
+ ): ExtensionRuntime {
73
+ return {
74
+ agentDir: "/test/agent",
75
+ sessionsDir: "/test/agent/sessions",
76
+ subagentSessionsDir: "/test/agent/subagent-sessions",
77
+ forwardingDir: "/test/agent/sessions/permission-forwarding",
78
+ globalLogsDir: "/test/agent/extensions/pi-permission-system/logs",
79
+ piInfrastructureDirs: ["/test/agent", "/test/agent/git"],
80
+ config: { debugLog: false, permissionReviewLog: true, yoloMode: false },
81
+ runtimeContext: null,
82
+ permissionManager: {
83
+ checkPermission: vi.fn().mockReturnValue(makeCheckResult("allow")),
84
+ } as unknown as ExtensionRuntime["permissionManager"],
85
+ activeSkillEntries: [],
86
+ lastKnownActiveAgentName: null,
87
+ lastActiveToolsCacheKey: null,
88
+ lastPromptStateCacheKey: null,
89
+ lastConfigWarning: null,
90
+ sessionRules: {
91
+ approve: vi.fn(),
92
+ getRuleset: vi.fn().mockReturnValue([]),
93
+ clear: vi.fn(),
94
+ } as unknown as ExtensionRuntime["sessionRules"],
95
+ permissionForwardingContext: null,
96
+ permissionForwardingTimer: null,
97
+ isProcessingForwardedRequests: false,
98
+ writeDebugLog: vi.fn(),
99
+ writeReviewLog: vi.fn(),
100
+ ...overrides,
101
+ } as ExtensionRuntime;
102
+ }
103
+
104
+ function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
105
+ return {
106
+ runtime: makeRuntime(),
107
+ events: makeEvents(),
108
+ createPermissionManagerForCwd: vi.fn(),
109
+ refreshExtensionConfig: vi.fn(),
110
+ notifyWarning: vi.fn(),
111
+ logResolvedConfigPaths: vi.fn(),
112
+ resolveAgentName: vi.fn().mockReturnValue(null),
113
+ canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
114
+ promptPermission: vi
115
+ .fn()
116
+ .mockResolvedValue({ approved: true, state: "approved" }),
117
+ createPermissionRequestId: vi.fn().mockReturnValue("req-id"),
118
+ startForwardedPermissionPolling: vi.fn(),
119
+ stopForwardedPermissionPolling: vi.fn(),
120
+ stopPermissionRpcHandlers: vi.fn(),
121
+ getAllTools: vi.fn().mockReturnValue([{ name: "read" }, { name: "bash" }]),
122
+ setActiveTools: vi.fn(),
123
+ ...overrides,
124
+ };
125
+ }
126
+
127
+ /** Extract all permissions:decision payloads from the events.emit mock. */
128
+ function getDecisionEvents(deps: HandlerDeps): PermissionDecisionEvent[] {
129
+ const emitMock = (deps.events as ReturnType<typeof makeEvents>).emit;
130
+ return emitMock.mock.calls
131
+ .filter(([channel]) => channel === PERMISSIONS_DECISION_CHANNEL)
132
+ .map(([, payload]) => payload as PermissionDecisionEvent);
133
+ }
134
+
135
+ // ── policy_allow path ──────────────────────────────────────────────────────
136
+
137
+ describe("handleToolCall decision events — policy_allow", () => {
138
+ it("emits allow with policy_allow when checkPermission returns allow", async () => {
139
+ const deps = makeDeps({
140
+ runtime: makeRuntime({
141
+ permissionManager: {
142
+ checkPermission: vi.fn().mockReturnValue(
143
+ makeCheckResult("allow", {
144
+ origin: "global",
145
+ matchedPattern: "*",
146
+ }),
147
+ ),
148
+ } as unknown as ExtensionRuntime["permissionManager"],
149
+ }),
150
+ });
151
+
152
+ await handleToolCall(deps, makeToolCallEvent("read"), makeCtx());
153
+
154
+ const events = getDecisionEvents(deps);
155
+ expect(events).toHaveLength(1);
156
+ expect(events[0]).toMatchObject({
157
+ surface: "read",
158
+ result: "allow",
159
+ resolution: "policy_allow",
160
+ origin: "global",
161
+ matchedPattern: "*",
162
+ });
163
+ });
164
+ });
165
+
166
+ // ── policy_deny path ───────────────────────────────────────────────────────
167
+
168
+ describe("handleToolCall decision events — policy_deny", () => {
169
+ it("emits deny with policy_deny when checkPermission returns deny", async () => {
170
+ const deps = makeDeps({
171
+ runtime: makeRuntime({
172
+ permissionManager: {
173
+ checkPermission: vi.fn().mockReturnValue(
174
+ makeCheckResult("deny", {
175
+ origin: "project",
176
+ matchedPattern: "read",
177
+ }),
178
+ ),
179
+ } as unknown as ExtensionRuntime["permissionManager"],
180
+ }),
181
+ });
182
+
183
+ await handleToolCall(deps, makeToolCallEvent("read"), makeCtx());
184
+
185
+ const events = getDecisionEvents(deps);
186
+ expect(events).toHaveLength(1);
187
+ expect(events[0]).toMatchObject({
188
+ surface: "read",
189
+ result: "deny",
190
+ resolution: "policy_deny",
191
+ });
192
+ });
193
+ });
194
+
195
+ // ── session_approved fast path ─────────────────────────────────────────────
196
+
197
+ describe("handleToolCall decision events — session_approved", () => {
198
+ it("emits allow with session_approved when checkPermission returns source:session", async () => {
199
+ const deps = makeDeps({
200
+ runtime: makeRuntime({
201
+ permissionManager: {
202
+ checkPermission: vi.fn().mockReturnValue(
203
+ makeCheckResult("allow", {
204
+ source: "session",
205
+ matchedPattern: "git *",
206
+ }),
207
+ ),
208
+ } as unknown as ExtensionRuntime["permissionManager"],
209
+ }),
210
+ });
211
+
212
+ await handleToolCall(
213
+ deps,
214
+ makeToolCallEvent("bash", { input: { command: "git status" } }),
215
+ makeCtx(),
216
+ );
217
+
218
+ const events = getDecisionEvents(deps);
219
+ expect(events).toHaveLength(1);
220
+ expect(events[0]).toMatchObject({
221
+ surface: "bash",
222
+ result: "allow",
223
+ resolution: "session_approved",
224
+ });
225
+ });
226
+ });
227
+
228
+ // ── user_approved path ─────────────────────────────────────────────────────
229
+
230
+ describe("handleToolCall decision events — user_approved", () => {
231
+ it("emits allow with user_approved when state=ask and user approves once", async () => {
232
+ const deps = makeDeps({
233
+ runtime: makeRuntime({
234
+ permissionManager: {
235
+ checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
236
+ } as unknown as ExtensionRuntime["permissionManager"],
237
+ }),
238
+ promptPermission: vi
239
+ .fn()
240
+ .mockResolvedValue({ approved: true, state: "approved" }),
241
+ });
242
+
243
+ await handleToolCall(deps, makeToolCallEvent("read"), makeCtx());
244
+
245
+ const events = getDecisionEvents(deps);
246
+ expect(events).toHaveLength(1);
247
+ expect(events[0]).toMatchObject({
248
+ result: "allow",
249
+ resolution: "user_approved",
250
+ });
251
+ });
252
+
253
+ it("emits allow with user_approved_for_session when user approves for session", async () => {
254
+ const deps = makeDeps({
255
+ runtime: makeRuntime({
256
+ permissionManager: {
257
+ checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
258
+ } as unknown as ExtensionRuntime["permissionManager"],
259
+ }),
260
+ promptPermission: vi
261
+ .fn()
262
+ .mockResolvedValue({ approved: true, state: "approved_for_session" }),
263
+ });
264
+
265
+ await handleToolCall(deps, makeToolCallEvent("read"), makeCtx());
266
+
267
+ const events = getDecisionEvents(deps);
268
+ expect(events).toHaveLength(1);
269
+ expect(events[0]).toMatchObject({
270
+ result: "allow",
271
+ resolution: "user_approved_for_session",
272
+ });
273
+ });
274
+ });
275
+
276
+ // ── user_denied path ───────────────────────────────────────────────────────
277
+
278
+ describe("handleToolCall decision events — user_denied", () => {
279
+ it("emits deny with user_denied when state=ask and user denies", async () => {
280
+ const deps = makeDeps({
281
+ runtime: makeRuntime({
282
+ permissionManager: {
283
+ checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
284
+ } as unknown as ExtensionRuntime["permissionManager"],
285
+ }),
286
+ promptPermission: vi
287
+ .fn()
288
+ .mockResolvedValue({ approved: false, state: "denied" }),
289
+ });
290
+
291
+ await handleToolCall(deps, makeToolCallEvent("read"), makeCtx());
292
+
293
+ const events = getDecisionEvents(deps);
294
+ expect(events).toHaveLength(1);
295
+ expect(events[0]).toMatchObject({
296
+ result: "deny",
297
+ resolution: "user_denied",
298
+ });
299
+ });
300
+ });
301
+
302
+ // ── confirmation_unavailable path ──────────────────────────────────────────
303
+
304
+ describe("handleToolCall decision events — confirmation_unavailable", () => {
305
+ it("emits deny with confirmation_unavailable when state=ask but no UI", async () => {
306
+ const deps = makeDeps({
307
+ runtime: makeRuntime({
308
+ permissionManager: {
309
+ checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
310
+ } as unknown as ExtensionRuntime["permissionManager"],
311
+ }),
312
+ canRequestPermissionConfirmation: vi.fn().mockReturnValue(false),
313
+ });
314
+
315
+ await handleToolCall(
316
+ deps,
317
+ makeToolCallEvent("read"),
318
+ makeCtx({ hasUI: false }),
319
+ );
320
+
321
+ const events = getDecisionEvents(deps);
322
+ expect(events).toHaveLength(1);
323
+ expect(events[0]).toMatchObject({
324
+ result: "deny",
325
+ resolution: "confirmation_unavailable",
326
+ });
327
+ });
328
+ });
329
+
330
+ // ── infrastructure_auto_allowed path ──────────────────────────────────────
331
+
332
+ describe("handleToolCall decision events — infrastructure_auto_allowed", () => {
333
+ it("emits allow with infrastructure_auto_allowed for Pi infra reads", async () => {
334
+ const infraDir = "/test/agent";
335
+ const deps = makeDeps({
336
+ runtime: makeRuntime({
337
+ piInfrastructureDirs: [infraDir],
338
+ permissionManager: {
339
+ checkPermission: vi.fn().mockReturnValue(makeCheckResult("allow")),
340
+ } as unknown as ExtensionRuntime["permissionManager"],
341
+ }),
342
+ });
343
+
344
+ const event = makeToolCallEvent("read", {
345
+ input: { path: `${infraDir}/some-file.json` },
346
+ });
347
+ await handleToolCall(deps, event, makeCtx());
348
+
349
+ const events = getDecisionEvents(deps);
350
+ // One infrastructure_auto_allowed event + one policy_allow for the normal gate
351
+ const infraEvents = events.filter(
352
+ (e) => e.resolution === "infrastructure_auto_allowed",
353
+ );
354
+ expect(infraEvents).toHaveLength(1);
355
+ expect(infraEvents[0]).toMatchObject({
356
+ result: "allow",
357
+ resolution: "infrastructure_auto_allowed",
358
+ });
359
+ });
360
+ });
361
+
362
+ // ── auto_approved path (yolo mode) ───────────────────────────────────
363
+
364
+ describe("handleToolCall decision events — auto_approved", () => {
365
+ it("emits allow with auto_approved when promptPermission returns autoApproved:true", async () => {
366
+ const deps = makeDeps({
367
+ runtime: makeRuntime({
368
+ permissionManager: {
369
+ checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
370
+ } as unknown as ExtensionRuntime["permissionManager"],
371
+ }),
372
+ // Simulate what PermissionPrompter returns in yolo mode
373
+ promptPermission: vi.fn().mockResolvedValue({
374
+ approved: true,
375
+ state: "approved",
376
+ autoApproved: true,
377
+ }),
378
+ });
379
+
380
+ await handleToolCall(deps, makeToolCallEvent("read"), makeCtx());
381
+
382
+ const events = getDecisionEvents(deps);
383
+ expect(events).toHaveLength(1);
384
+ expect(events[0]).toMatchObject({
385
+ result: "allow",
386
+ resolution: "auto_approved",
387
+ });
388
+ });
389
+ });
@@ -102,8 +102,10 @@ function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
102
102
  .fn()
103
103
  .mockResolvedValue({ approved: true, state: "approved" }),
104
104
  createPermissionRequestId: vi.fn().mockReturnValue("req-id"),
105
+ events: { emit: vi.fn(), on: vi.fn().mockReturnValue(() => undefined) },
105
106
  startForwardedPermissionPolling: vi.fn(),
106
107
  stopForwardedPermissionPolling: vi.fn(),
108
+ stopPermissionRpcHandlers: vi.fn(),
107
109
  getAllTools: vi.fn().mockReturnValue([{ name: "read" }, { name: "bash" }]),
108
110
  setActiveTools: vi.fn(),
109
111
  ...overrides,