@gotgenes/pi-permission-system 5.3.4 → 5.5.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,417 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { evaluateToolGate } from "../../../src/handlers/gates/tool";
4
+ import type { ToolCallContext } from "../../../src/handlers/gates/types";
5
+ import type { HandlerDeps } from "../../../src/handlers/types";
6
+ import type { PermissionEventBus } from "../../../src/permission-events";
7
+ import type { PermissionCheckResult } from "../../../src/types";
8
+
9
+ // ── SDK stubs ──────────────────────────────────────────────────────────────
10
+ vi.mock("@mariozechner/pi-coding-agent", async (importOriginal) => {
11
+ const original =
12
+ await importOriginal<typeof import("@mariozechner/pi-coding-agent")>();
13
+ return { ...original };
14
+ });
15
+
16
+ // ── helpers ────────────────────────────────────────────────────────────────
17
+
18
+ function makeTcc(overrides: Partial<ToolCallContext> = {}): ToolCallContext {
19
+ return {
20
+ toolName: "read",
21
+ agentName: null,
22
+ input: { path: "/test/project/foo.ts" },
23
+ toolCallId: "tc-1",
24
+ cwd: "/test/project",
25
+ ...overrides,
26
+ };
27
+ }
28
+
29
+ function makeCheckResult(
30
+ state: "allow" | "deny" | "ask",
31
+ overrides: Partial<PermissionCheckResult> = {},
32
+ ): PermissionCheckResult {
33
+ return {
34
+ state,
35
+ toolName: "read",
36
+ source: "tool",
37
+ origin: "builtin",
38
+ ...overrides,
39
+ };
40
+ }
41
+
42
+ function makeEvents(): PermissionEventBus {
43
+ return { emit: vi.fn(), on: vi.fn().mockReturnValue(() => undefined) };
44
+ }
45
+
46
+ function makeRuntime(
47
+ overrides: Record<string, unknown> = {},
48
+ ): HandlerDeps["runtime"] {
49
+ return {
50
+ config: { debugLog: false, permissionReviewLog: true, yoloMode: false },
51
+ runtimeContext: {} as HandlerDeps["runtime"]["runtimeContext"],
52
+ permissionManager: {
53
+ checkPermission: vi.fn().mockReturnValue(makeCheckResult("allow")),
54
+ },
55
+ sessionRules: {
56
+ approve: vi.fn(),
57
+ getRuleset: vi.fn().mockReturnValue([]),
58
+ clear: vi.fn(),
59
+ },
60
+ writeReviewLog: vi.fn(),
61
+ ...overrides,
62
+ } as unknown as HandlerDeps["runtime"];
63
+ }
64
+
65
+ function makeDeps(overrides: Record<string, unknown> = {}): HandlerDeps {
66
+ const { runtime: runtimeOverrides, events, ...rest } = overrides;
67
+ return {
68
+ runtime: makeRuntime(runtimeOverrides as Record<string, unknown>),
69
+ events: events ?? makeEvents(),
70
+ canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
71
+ promptPermission: vi
72
+ .fn()
73
+ .mockResolvedValue({ approved: true, state: "approved" }),
74
+ ...rest,
75
+ } as unknown as HandlerDeps;
76
+ }
77
+
78
+ // ── tests ──────────────────────────────────────────────────────────────────
79
+
80
+ describe("evaluateToolGate", () => {
81
+ // ── Session-rule hit ─────────────────────────────────────────────────────
82
+
83
+ it("allows and emits session_approved on session hit", async () => {
84
+ const events = makeEvents();
85
+ const deps = makeDeps({
86
+ runtime: {
87
+ permissionManager: {
88
+ checkPermission: vi.fn().mockReturnValue(
89
+ makeCheckResult("allow", {
90
+ source: "session",
91
+ toolName: "bash",
92
+ command: "git status",
93
+ matchedPattern: "git *",
94
+ }),
95
+ ),
96
+ },
97
+ },
98
+ events,
99
+ });
100
+ const tcc = makeTcc({
101
+ toolName: "bash",
102
+ input: { command: "git status" },
103
+ });
104
+ const result = await evaluateToolGate(tcc, deps);
105
+ expect(result).toEqual({ action: "allow" });
106
+ expect(events.emit).toHaveBeenCalledWith(
107
+ "permissions:decision",
108
+ expect.objectContaining({
109
+ surface: "bash",
110
+ value: "git status",
111
+ result: "allow",
112
+ resolution: "session_approved",
113
+ matchedPattern: "git *",
114
+ }),
115
+ );
116
+ });
117
+
118
+ it("does NOT record session rule on session hit", async () => {
119
+ const sessionRules = {
120
+ approve: vi.fn(),
121
+ getRuleset: vi.fn().mockReturnValue([]),
122
+ clear: vi.fn(),
123
+ };
124
+ const deps = makeDeps({
125
+ runtime: {
126
+ permissionManager: {
127
+ checkPermission: vi.fn().mockReturnValue(
128
+ makeCheckResult("allow", {
129
+ source: "session",
130
+ matchedPattern: "git *",
131
+ }),
132
+ ),
133
+ },
134
+ sessionRules,
135
+ },
136
+ });
137
+ const tcc = makeTcc({
138
+ toolName: "bash",
139
+ input: { command: "git status" },
140
+ });
141
+ await evaluateToolGate(tcc, deps);
142
+ expect(sessionRules.approve).not.toHaveBeenCalled();
143
+ });
144
+
145
+ // ── Policy allow ─────────────────────────────────────────────────────────
146
+
147
+ it("allows and emits policy_allow", async () => {
148
+ const events = makeEvents();
149
+ const deps = makeDeps({ events });
150
+ const tcc = makeTcc();
151
+ const result = await evaluateToolGate(tcc, deps);
152
+ expect(result).toEqual({ action: "allow" });
153
+ expect(events.emit).toHaveBeenCalledWith(
154
+ "permissions:decision",
155
+ expect.objectContaining({
156
+ result: "allow",
157
+ resolution: "policy_allow",
158
+ }),
159
+ );
160
+ });
161
+
162
+ // ── Policy deny ──────────────────────────────────────────────────────────
163
+
164
+ it("blocks and emits policy_deny", async () => {
165
+ const events = makeEvents();
166
+ const deps = makeDeps({
167
+ runtime: {
168
+ permissionManager: {
169
+ checkPermission: vi.fn().mockReturnValue(makeCheckResult("deny")),
170
+ },
171
+ },
172
+ events,
173
+ });
174
+ const tcc = makeTcc();
175
+ const result = await evaluateToolGate(tcc, deps);
176
+ expect(result).toMatchObject({ action: "block" });
177
+ expect(events.emit).toHaveBeenCalledWith(
178
+ "permissions:decision",
179
+ expect.objectContaining({
180
+ result: "deny",
181
+ resolution: "policy_deny",
182
+ }),
183
+ );
184
+ });
185
+
186
+ // ── Policy ask — user approves once ──────────────────────────────────────
187
+
188
+ it("allows and emits user_approved when user approves once", async () => {
189
+ const events = makeEvents();
190
+ const sessionRules = {
191
+ approve: vi.fn(),
192
+ getRuleset: vi.fn().mockReturnValue([]),
193
+ clear: vi.fn(),
194
+ };
195
+ const deps = makeDeps({
196
+ runtime: {
197
+ permissionManager: {
198
+ checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
199
+ },
200
+ sessionRules,
201
+ },
202
+ events,
203
+ promptPermission: vi
204
+ .fn()
205
+ .mockResolvedValue({ approved: true, state: "approved" }),
206
+ });
207
+ const tcc = makeTcc();
208
+ const result = await evaluateToolGate(tcc, deps);
209
+ expect(result).toEqual({ action: "allow" });
210
+ expect(sessionRules.approve).not.toHaveBeenCalled();
211
+ expect(events.emit).toHaveBeenCalledWith(
212
+ "permissions:decision",
213
+ expect.objectContaining({
214
+ result: "allow",
215
+ resolution: "user_approved",
216
+ }),
217
+ );
218
+ });
219
+
220
+ // ── Policy ask — user approves for session ───────────────────────────────
221
+
222
+ it("records session rule when user approves for session", async () => {
223
+ const events = makeEvents();
224
+ const sessionRules = {
225
+ approve: vi.fn(),
226
+ getRuleset: vi.fn().mockReturnValue([]),
227
+ clear: vi.fn(),
228
+ };
229
+ const deps = makeDeps({
230
+ runtime: {
231
+ permissionManager: {
232
+ checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
233
+ },
234
+ sessionRules,
235
+ },
236
+ events,
237
+ promptPermission: vi
238
+ .fn()
239
+ .mockResolvedValue({ approved: true, state: "approved_for_session" }),
240
+ });
241
+ const tcc = makeTcc();
242
+ const result = await evaluateToolGate(tcc, deps);
243
+ expect(result).toEqual({ action: "allow" });
244
+ expect(sessionRules.approve).toHaveBeenCalledWith("read", "*");
245
+ expect(events.emit).toHaveBeenCalledWith(
246
+ "permissions:decision",
247
+ expect.objectContaining({
248
+ resolution: "user_approved_for_session",
249
+ }),
250
+ );
251
+ });
252
+
253
+ // ── Policy ask — user denies ─────────────────────────────────────────────
254
+
255
+ it("blocks and emits user_denied", async () => {
256
+ const events = makeEvents();
257
+ const deps = makeDeps({
258
+ runtime: {
259
+ permissionManager: {
260
+ checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
261
+ },
262
+ },
263
+ events,
264
+ promptPermission: vi
265
+ .fn()
266
+ .mockResolvedValue({ approved: false, state: "denied" }),
267
+ });
268
+ const tcc = makeTcc();
269
+ const result = await evaluateToolGate(tcc, deps);
270
+ expect(result).toMatchObject({ action: "block" });
271
+ expect(events.emit).toHaveBeenCalledWith(
272
+ "permissions:decision",
273
+ expect.objectContaining({
274
+ result: "deny",
275
+ resolution: "user_denied",
276
+ }),
277
+ );
278
+ });
279
+
280
+ // ── Policy ask — no UI ───────────────────────────────────────────────────
281
+
282
+ it("blocks and emits confirmation_unavailable when no UI", async () => {
283
+ const events = makeEvents();
284
+ const deps = makeDeps({
285
+ runtime: {
286
+ permissionManager: {
287
+ checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
288
+ },
289
+ },
290
+ events,
291
+ canRequestPermissionConfirmation: vi.fn().mockReturnValue(false),
292
+ });
293
+ const tcc = makeTcc();
294
+ const result = await evaluateToolGate(tcc, deps);
295
+ expect(result).toMatchObject({ action: "block" });
296
+ expect(events.emit).toHaveBeenCalledWith(
297
+ "permissions:decision",
298
+ expect.objectContaining({
299
+ result: "deny",
300
+ resolution: "confirmation_unavailable",
301
+ }),
302
+ );
303
+ });
304
+
305
+ // ── Auto-approved ────────────────────────────────────────────────────────
306
+
307
+ it("emits auto_approved resolution when decision is auto-approved", async () => {
308
+ const events = makeEvents();
309
+ const deps = makeDeps({
310
+ runtime: {
311
+ permissionManager: {
312
+ checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
313
+ },
314
+ },
315
+ events,
316
+ promptPermission: vi.fn().mockResolvedValue({
317
+ approved: true,
318
+ state: "approved",
319
+ autoApproved: true,
320
+ }),
321
+ });
322
+ const tcc = makeTcc();
323
+ const result = await evaluateToolGate(tcc, deps);
324
+ expect(result).toEqual({ action: "allow" });
325
+ expect(events.emit).toHaveBeenCalledWith(
326
+ "permissions:decision",
327
+ expect.objectContaining({
328
+ resolution: "auto_approved",
329
+ }),
330
+ );
331
+ });
332
+
333
+ // ── Bash-specific value ──────────────────────────────────────────────────
334
+
335
+ it("uses command as decision value for bash tool", async () => {
336
+ const events = makeEvents();
337
+ const deps = makeDeps({
338
+ runtime: {
339
+ permissionManager: {
340
+ checkPermission: vi.fn().mockReturnValue(
341
+ makeCheckResult("allow", {
342
+ toolName: "bash",
343
+ command: "git status",
344
+ }),
345
+ ),
346
+ },
347
+ },
348
+ events,
349
+ });
350
+ const tcc = makeTcc({
351
+ toolName: "bash",
352
+ input: { command: "git status" },
353
+ });
354
+ await evaluateToolGate(tcc, deps);
355
+ expect(events.emit).toHaveBeenCalledWith(
356
+ "permissions:decision",
357
+ expect.objectContaining({
358
+ surface: "bash",
359
+ value: "git status",
360
+ }),
361
+ );
362
+ });
363
+
364
+ // ── MCP-specific value ───────────────────────────────────────────────────
365
+
366
+ it("uses target as decision value for mcp tool", async () => {
367
+ const events = makeEvents();
368
+ const deps = makeDeps({
369
+ runtime: {
370
+ permissionManager: {
371
+ checkPermission: vi.fn().mockReturnValue(
372
+ makeCheckResult("allow", {
373
+ toolName: "mcp",
374
+ target: "exa:search",
375
+ }),
376
+ ),
377
+ },
378
+ },
379
+ events,
380
+ });
381
+ const tcc = makeTcc({ toolName: "mcp", input: { tool: "exa:search" } });
382
+ await evaluateToolGate(tcc, deps);
383
+ expect(events.emit).toHaveBeenCalledWith(
384
+ "permissions:decision",
385
+ expect.objectContaining({
386
+ surface: "mcp",
387
+ value: "exa:search",
388
+ }),
389
+ );
390
+ });
391
+
392
+ // ── Bash unavailable message ─────────────────────────────────────────────
393
+
394
+ it("includes command in unavailable message for bash", async () => {
395
+ const deps = makeDeps({
396
+ runtime: {
397
+ permissionManager: {
398
+ checkPermission: vi
399
+ .fn()
400
+ .mockReturnValue(
401
+ makeCheckResult("ask", { toolName: "bash", command: "rm -rf /" }),
402
+ ),
403
+ },
404
+ },
405
+ canRequestPermissionConfirmation: vi.fn().mockReturnValue(false),
406
+ });
407
+ const tcc = makeTcc({
408
+ toolName: "bash",
409
+ input: { command: "rm -rf /" },
410
+ });
411
+ const result = await evaluateToolGate(tcc, deps);
412
+ expect(result.action).toBe("block");
413
+ if (result.action === "block") {
414
+ expect(result.reason).toContain("rm -rf /");
415
+ }
416
+ });
417
+ });