@gotgenes/pi-permission-system 5.2.1 → 5.3.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.
@@ -0,0 +1,499 @@
1
+ import { createEventBus } from "@mariozechner/pi-coding-agent";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import {
4
+ type PermissionRpcDeps,
5
+ registerPermissionRpcHandlers,
6
+ } from "../src/permission-event-rpc";
7
+ import type {
8
+ PermissionsCheckReplyData,
9
+ PermissionsRpcReply,
10
+ } from "../src/permission-events";
11
+ import {
12
+ PERMISSIONS_PROTOCOL_VERSION,
13
+ PERMISSIONS_RPC_CHECK_CHANNEL,
14
+ PERMISSIONS_RPC_PROMPT_CHANNEL,
15
+ } from "../src/permission-events";
16
+
17
+ // ── Helpers ────────────────────────────────────────────────────────────────
18
+
19
+ function makeCheckResult(
20
+ state: "allow" | "deny" | "ask",
21
+ overrides: Record<string, unknown> = {},
22
+ ) {
23
+ return {
24
+ toolName: "bash",
25
+ state,
26
+ matchedPattern: "*",
27
+ source: "bash" as const,
28
+ origin: "global" as const,
29
+ ...overrides,
30
+ };
31
+ }
32
+
33
+ function makeDeps(
34
+ overrides: Partial<PermissionRpcDeps> = {},
35
+ ): PermissionRpcDeps {
36
+ return {
37
+ getPermissionManager: vi.fn().mockReturnValue({
38
+ checkPermission: vi.fn().mockReturnValue(makeCheckResult("allow")),
39
+ }),
40
+ getSessionRules: vi.fn().mockReturnValue([]),
41
+ getRuntimeContext: vi.fn().mockReturnValue(null),
42
+ requestPermissionDecisionFromUi: vi.fn(),
43
+ writeReviewLog: vi.fn(),
44
+ ...overrides,
45
+ };
46
+ }
47
+
48
+ /** Wait for a single event on the bus reply channel. */
49
+ function waitForReply<T>(
50
+ bus: ReturnType<typeof createEventBus>,
51
+ channel: string,
52
+ ): Promise<T> {
53
+ return new Promise((resolve) => {
54
+ const unsub = bus.on(channel, (data) => {
55
+ unsub();
56
+ resolve(data as T);
57
+ });
58
+ });
59
+ }
60
+
61
+ // ── registerPermissionRpcHandlers — check RPC ──────────────────────────────
62
+
63
+ describe("registerPermissionRpcHandlers — permissions:rpc:check", () => {
64
+ it("returns unsubscribe handles", () => {
65
+ const bus = createEventBus();
66
+ const handles = registerPermissionRpcHandlers(bus, makeDeps());
67
+ expect(typeof handles.unsubCheck).toBe("function");
68
+ expect(typeof handles.unsubPrompt).toBe("function");
69
+ });
70
+
71
+ it("replies allow for an allowed surface/value", async () => {
72
+ const bus = createEventBus();
73
+ const deps = makeDeps({
74
+ getPermissionManager: vi.fn().mockReturnValue({
75
+ checkPermission: vi.fn().mockReturnValue(makeCheckResult("allow")),
76
+ }),
77
+ });
78
+ registerPermissionRpcHandlers(bus, deps);
79
+
80
+ const replyPromise = waitForReply<
81
+ PermissionsRpcReply<PermissionsCheckReplyData>
82
+ >(bus, `${PERMISSIONS_RPC_CHECK_CHANNEL}:reply:req-allow`);
83
+ bus.emit(PERMISSIONS_RPC_CHECK_CHANNEL, {
84
+ requestId: "req-allow",
85
+ surface: "bash",
86
+ value: "git status",
87
+ });
88
+
89
+ const reply = await replyPromise;
90
+ expect(reply.success).toBe(true);
91
+ expect(reply.protocolVersion).toBe(PERMISSIONS_PROTOCOL_VERSION);
92
+ if (reply.success) {
93
+ expect(reply.data?.result).toBe("allow");
94
+ expect(reply.data?.origin).toBe("global");
95
+ }
96
+ });
97
+
98
+ it("replies deny for a denied surface/value", async () => {
99
+ const bus = createEventBus();
100
+ const deps = makeDeps({
101
+ getPermissionManager: vi.fn().mockReturnValue({
102
+ checkPermission: vi.fn().mockReturnValue(
103
+ makeCheckResult("deny", {
104
+ origin: "project",
105
+ matchedPattern: "rm *",
106
+ }),
107
+ ),
108
+ }),
109
+ });
110
+ registerPermissionRpcHandlers(bus, deps);
111
+
112
+ const replyPromise = waitForReply<
113
+ PermissionsRpcReply<PermissionsCheckReplyData>
114
+ >(bus, `${PERMISSIONS_RPC_CHECK_CHANNEL}:reply:req-deny`);
115
+ bus.emit(PERMISSIONS_RPC_CHECK_CHANNEL, {
116
+ requestId: "req-deny",
117
+ surface: "bash",
118
+ value: "rm -rf /tmp",
119
+ });
120
+
121
+ const reply = await replyPromise;
122
+ expect(reply.success).toBe(true);
123
+ if (reply.success) {
124
+ expect(reply.data?.result).toBe("deny");
125
+ expect(reply.data?.matchedPattern).toBe("rm *");
126
+ }
127
+ });
128
+
129
+ it("replies ask for an ask surface/value", async () => {
130
+ const bus = createEventBus();
131
+ const deps = makeDeps({
132
+ getPermissionManager: vi.fn().mockReturnValue({
133
+ checkPermission: vi
134
+ .fn()
135
+ .mockReturnValue(
136
+ makeCheckResult("ask", { matchedPattern: undefined }),
137
+ ),
138
+ }),
139
+ });
140
+ registerPermissionRpcHandlers(bus, deps);
141
+
142
+ const replyPromise = waitForReply<
143
+ PermissionsRpcReply<PermissionsCheckReplyData>
144
+ >(bus, `${PERMISSIONS_RPC_CHECK_CHANNEL}:reply:req-ask`);
145
+ bus.emit(PERMISSIONS_RPC_CHECK_CHANNEL, {
146
+ requestId: "req-ask",
147
+ surface: "mcp",
148
+ value: "exa:search",
149
+ });
150
+
151
+ const reply = await replyPromise;
152
+ expect(reply.success).toBe(true);
153
+ if (reply.success) {
154
+ expect(reply.data?.result).toBe("ask");
155
+ }
156
+ });
157
+
158
+ it("passes agentName to checkPermission when provided", async () => {
159
+ const checkPermission = vi.fn().mockReturnValue(makeCheckResult("allow"));
160
+ const bus = createEventBus();
161
+ const deps = makeDeps({
162
+ getPermissionManager: vi.fn().mockReturnValue({ checkPermission }),
163
+ });
164
+ registerPermissionRpcHandlers(bus, deps);
165
+
166
+ const replyPromise = waitForReply(
167
+ bus,
168
+ `${PERMISSIONS_RPC_CHECK_CHANNEL}:reply:req-agent`,
169
+ );
170
+ bus.emit(PERMISSIONS_RPC_CHECK_CHANNEL, {
171
+ requestId: "req-agent",
172
+ surface: "bash",
173
+ value: "git push",
174
+ agentName: "Worker",
175
+ });
176
+ await replyPromise;
177
+
178
+ expect(checkPermission).toHaveBeenCalledWith(
179
+ "bash",
180
+ expect.anything(),
181
+ "Worker",
182
+ expect.anything(),
183
+ );
184
+ });
185
+
186
+ it("includes session rules in the check", async () => {
187
+ const sessionRules = [
188
+ {
189
+ surface: "bash",
190
+ pattern: "git *",
191
+ action: "allow" as const,
192
+ origin: "session" as const,
193
+ },
194
+ ];
195
+ const checkPermission = vi.fn().mockReturnValue(makeCheckResult("allow"));
196
+ const bus = createEventBus();
197
+ const deps = makeDeps({
198
+ getPermissionManager: vi.fn().mockReturnValue({ checkPermission }),
199
+ getSessionRules: vi.fn().mockReturnValue(sessionRules),
200
+ });
201
+ registerPermissionRpcHandlers(bus, deps);
202
+
203
+ const replyPromise = waitForReply(
204
+ bus,
205
+ `${PERMISSIONS_RPC_CHECK_CHANNEL}:reply:req-session`,
206
+ );
207
+ bus.emit(PERMISSIONS_RPC_CHECK_CHANNEL, {
208
+ requestId: "req-session",
209
+ surface: "bash",
210
+ value: "git status",
211
+ });
212
+ await replyPromise;
213
+
214
+ expect(checkPermission).toHaveBeenCalledWith(
215
+ "bash",
216
+ expect.anything(),
217
+ undefined,
218
+ sessionRules,
219
+ );
220
+ });
221
+
222
+ it("replies with error envelope when requestId is missing", async () => {
223
+ const bus = createEventBus();
224
+ registerPermissionRpcHandlers(bus, makeDeps());
225
+
226
+ // No reply channel to wait on — emit without requestId and confirm
227
+ // no throw / crash. We check indirectly via a timeout-free approach:
228
+ // emit an immediately-followable good request and ensure both succeed.
229
+ const replyPromise = waitForReply<PermissionsRpcReply>(
230
+ bus,
231
+ `${PERMISSIONS_RPC_CHECK_CHANNEL}:reply:req-good`,
232
+ );
233
+ bus.emit(PERMISSIONS_RPC_CHECK_CHANNEL, {}); // missing requestId — should not crash
234
+ bus.emit(PERMISSIONS_RPC_CHECK_CHANNEL, {
235
+ requestId: "req-good",
236
+ surface: "bash",
237
+ });
238
+
239
+ const reply = await replyPromise;
240
+ expect(reply.success).toBe(true); // good request still handled
241
+ });
242
+
243
+ it("unsubCheck stops the handler from firing", async () => {
244
+ const checkPermission = vi.fn().mockReturnValue(makeCheckResult("allow"));
245
+ const bus = createEventBus();
246
+ const deps = makeDeps({
247
+ getPermissionManager: vi.fn().mockReturnValue({ checkPermission }),
248
+ });
249
+ const handles = registerPermissionRpcHandlers(bus, deps);
250
+ handles.unsubCheck();
251
+
252
+ bus.emit(PERMISSIONS_RPC_CHECK_CHANNEL, {
253
+ requestId: "req-unsub",
254
+ surface: "bash",
255
+ });
256
+
257
+ // Give async handlers a chance to fire
258
+ await new Promise((resolve) => setTimeout(resolve, 10));
259
+ expect(checkPermission).not.toHaveBeenCalled();
260
+ });
261
+ });
262
+
263
+ // ── registerPermissionRpcHandlers — prompt RPC ──────────────────────────
264
+
265
+ describe("registerPermissionRpcHandlers — permissions:rpc:prompt", () => {
266
+ function makeUi() {
267
+ return {
268
+ select: vi.fn(),
269
+ input: vi.fn(),
270
+ notify: vi.fn(),
271
+ setStatus: vi.fn(),
272
+ };
273
+ }
274
+
275
+ function makeCtxWithUi() {
276
+ return {
277
+ hasUI: true,
278
+ ui: makeUi(),
279
+ cwd: "/test/project",
280
+ sessionManager: {
281
+ getSessionDir: vi.fn().mockReturnValue("/sessions/test"),
282
+ },
283
+ };
284
+ }
285
+
286
+ it("replies with approval when user approves", async () => {
287
+ const bus = createEventBus();
288
+ const ctx = makeCtxWithUi();
289
+ const approvedDecision = { approved: true, state: "approved" as const };
290
+ const deps = makeDeps({
291
+ getRuntimeContext: vi.fn().mockReturnValue(ctx),
292
+ requestPermissionDecisionFromUi: vi
293
+ .fn()
294
+ .mockResolvedValue(approvedDecision),
295
+ });
296
+ registerPermissionRpcHandlers(bus, deps);
297
+
298
+ const replyPromise = waitForReply<
299
+ PermissionsRpcReply<
300
+ import("../src/permission-events").PermissionsPromptReplyData
301
+ >
302
+ >(bus, `${PERMISSIONS_RPC_PROMPT_CHANNEL}:reply:req-prompt-1`);
303
+ bus.emit(PERMISSIONS_RPC_PROMPT_CHANNEL, {
304
+ requestId: "req-prompt-1",
305
+ surface: "bash",
306
+ value: "rm -rf /tmp",
307
+ message: "Allow rm -rf /tmp?",
308
+ });
309
+
310
+ const reply = await replyPromise;
311
+ expect(reply.success).toBe(true);
312
+ expect(reply.protocolVersion).toBe(PERMISSIONS_PROTOCOL_VERSION);
313
+ if (reply.success) {
314
+ expect(reply.data?.approved).toBe(true);
315
+ expect(reply.data?.state).toBe("approved");
316
+ }
317
+ });
318
+
319
+ it("passes the message to requestPermissionDecisionFromUi", async () => {
320
+ const bus = createEventBus();
321
+ const ctx = makeCtxWithUi();
322
+ const requestUi = vi
323
+ .fn()
324
+ .mockResolvedValue({ approved: true, state: "approved" as const });
325
+ const deps = makeDeps({
326
+ getRuntimeContext: vi.fn().mockReturnValue(ctx),
327
+ requestPermissionDecisionFromUi: requestUi,
328
+ });
329
+ registerPermissionRpcHandlers(bus, deps);
330
+
331
+ const replyPromise = waitForReply(
332
+ bus,
333
+ `${PERMISSIONS_RPC_PROMPT_CHANNEL}:reply:req-prompt-2`,
334
+ );
335
+ bus.emit(PERMISSIONS_RPC_PROMPT_CHANNEL, {
336
+ requestId: "req-prompt-2",
337
+ surface: "bash",
338
+ value: "git push",
339
+ message: "Allow git push?",
340
+ agentName: "Worker",
341
+ sessionLabel: "Allow git *",
342
+ });
343
+ await replyPromise;
344
+
345
+ expect(requestUi).toHaveBeenCalledWith(
346
+ ctx.ui,
347
+ expect.stringContaining("Worker"),
348
+ "Allow git push?",
349
+ { sessionLabel: "Allow git *" },
350
+ );
351
+ });
352
+
353
+ it("replies with denied when user denies", async () => {
354
+ const bus = createEventBus();
355
+ const ctx = makeCtxWithUi();
356
+ const deniedDecision = {
357
+ approved: false,
358
+ state: "denied_with_reason" as const,
359
+ denialReason: "Too risky",
360
+ };
361
+ const deps = makeDeps({
362
+ getRuntimeContext: vi.fn().mockReturnValue(ctx),
363
+ requestPermissionDecisionFromUi: vi
364
+ .fn()
365
+ .mockResolvedValue(deniedDecision),
366
+ });
367
+ registerPermissionRpcHandlers(bus, deps);
368
+
369
+ const replyPromise = waitForReply<
370
+ PermissionsRpcReply<
371
+ import("../src/permission-events").PermissionsPromptReplyData
372
+ >
373
+ >(bus, `${PERMISSIONS_RPC_PROMPT_CHANNEL}:reply:req-denied`);
374
+ bus.emit(PERMISSIONS_RPC_PROMPT_CHANNEL, {
375
+ requestId: "req-denied",
376
+ surface: "bash",
377
+ value: "rm -rf /",
378
+ message: "Allow rm -rf /?",
379
+ });
380
+
381
+ const reply = await replyPromise;
382
+ expect(reply.success).toBe(true);
383
+ if (reply.success) {
384
+ expect(reply.data?.approved).toBe(false);
385
+ expect(reply.data?.state).toBe("denied_with_reason");
386
+ expect(reply.data?.denialReason).toBe("Too risky");
387
+ }
388
+ });
389
+
390
+ it("replies with no_ui error when context has no UI", async () => {
391
+ const bus = createEventBus();
392
+ const deps = makeDeps({
393
+ getRuntimeContext: vi.fn().mockReturnValue(null),
394
+ });
395
+ registerPermissionRpcHandlers(bus, deps);
396
+
397
+ const replyPromise = waitForReply<PermissionsRpcReply>(
398
+ bus,
399
+ `${PERMISSIONS_RPC_PROMPT_CHANNEL}:reply:req-no-ui`,
400
+ );
401
+ bus.emit(PERMISSIONS_RPC_PROMPT_CHANNEL, {
402
+ requestId: "req-no-ui",
403
+ surface: "bash",
404
+ value: "git push",
405
+ message: "Allow git push?",
406
+ });
407
+
408
+ const reply = await replyPromise;
409
+ expect(reply.success).toBe(false);
410
+ expect((reply as { success: false; error: string }).error).toBe("no_ui");
411
+ });
412
+
413
+ it("replies with no_ui error when context hasUI is false", async () => {
414
+ const bus = createEventBus();
415
+ const deps = makeDeps({
416
+ getRuntimeContext: vi
417
+ .fn()
418
+ .mockReturnValue({ hasUI: false, ui: makeUi() }),
419
+ });
420
+ registerPermissionRpcHandlers(bus, deps);
421
+
422
+ const replyPromise = waitForReply<PermissionsRpcReply>(
423
+ bus,
424
+ `${PERMISSIONS_RPC_PROMPT_CHANNEL}:reply:req-headless`,
425
+ );
426
+ bus.emit(PERMISSIONS_RPC_PROMPT_CHANNEL, {
427
+ requestId: "req-headless",
428
+ surface: "bash",
429
+ value: "git push",
430
+ message: "Allow git push?",
431
+ });
432
+
433
+ const reply = await replyPromise;
434
+ expect(reply.success).toBe(false);
435
+ expect((reply as { success: false; error: string }).error).toBe("no_ui");
436
+ });
437
+
438
+ it("writes to the review log after a prompt decision", async () => {
439
+ const bus = createEventBus();
440
+ const ctx = makeCtxWithUi();
441
+ const writeReviewLog = vi.fn();
442
+ const deps = makeDeps({
443
+ getRuntimeContext: vi.fn().mockReturnValue(ctx),
444
+ requestPermissionDecisionFromUi: vi
445
+ .fn()
446
+ .mockResolvedValue({ approved: true, state: "approved" as const }),
447
+ writeReviewLog,
448
+ });
449
+ registerPermissionRpcHandlers(bus, deps);
450
+
451
+ const replyPromise = waitForReply(
452
+ bus,
453
+ `${PERMISSIONS_RPC_PROMPT_CHANNEL}:reply:req-log`,
454
+ );
455
+ bus.emit(PERMISSIONS_RPC_PROMPT_CHANNEL, {
456
+ requestId: "req-log",
457
+ surface: "bash",
458
+ value: "git push",
459
+ message: "Allow git push?",
460
+ agentName: "Worker",
461
+ });
462
+ await replyPromise;
463
+
464
+ expect(writeReviewLog).toHaveBeenCalledWith(
465
+ "permission_request.rpc_prompt",
466
+ expect.objectContaining({
467
+ requestId: "req-log",
468
+ surface: "bash",
469
+ value: "git push",
470
+ agentName: "Worker",
471
+ approved: true,
472
+ }),
473
+ );
474
+ });
475
+
476
+ it("unsubPrompt stops the handler from firing", async () => {
477
+ const requestUi = vi
478
+ .fn()
479
+ .mockResolvedValue({ approved: true, state: "approved" as const });
480
+ const bus = createEventBus();
481
+ const ctx = makeCtxWithUi();
482
+ const deps = makeDeps({
483
+ getRuntimeContext: vi.fn().mockReturnValue(ctx),
484
+ requestPermissionDecisionFromUi: requestUi,
485
+ });
486
+ const handles = registerPermissionRpcHandlers(bus, deps);
487
+ handles.unsubPrompt();
488
+
489
+ bus.emit(PERMISSIONS_RPC_PROMPT_CHANNEL, {
490
+ requestId: "req-unsub-prompt",
491
+ surface: "bash",
492
+ value: "git push",
493
+ message: "Allow?",
494
+ });
495
+
496
+ await new Promise((resolve) => setTimeout(resolve, 10));
497
+ expect(requestUi).not.toHaveBeenCalled();
498
+ });
499
+ });