@gotgenes/pi-permission-system 5.3.3 → 5.4.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,204 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { evaluateSkillReadGate } from "../../../src/handlers/gates/skill-read";
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 { SkillPromptEntry } from "../../../src/skill-prompt-sanitizer";
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 makeSkillEntry(
19
+ overrides: Partial<SkillPromptEntry> = {},
20
+ ): SkillPromptEntry {
21
+ return {
22
+ name: "librarian",
23
+ description: "Research skills",
24
+ location: "/skills/librarian/SKILL.md",
25
+ state: "ask",
26
+ normalizedLocation: "/skills/librarian/SKILL.md",
27
+ normalizedBaseDir: "/skills/librarian",
28
+ ...overrides,
29
+ };
30
+ }
31
+
32
+ function makeTcc(overrides: Partial<ToolCallContext> = {}): ToolCallContext {
33
+ return {
34
+ toolName: "read",
35
+ agentName: null,
36
+ input: { path: "/skills/librarian/SKILL.md" },
37
+ toolCallId: "tc-1",
38
+ cwd: "/test/project",
39
+ ...overrides,
40
+ };
41
+ }
42
+
43
+ function makeEvents(): PermissionEventBus {
44
+ return { emit: vi.fn(), on: vi.fn().mockReturnValue(() => undefined) };
45
+ }
46
+
47
+ function makeRuntime(
48
+ overrides: Record<string, unknown> = {},
49
+ ): HandlerDeps["runtime"] {
50
+ return {
51
+ activeSkillEntries: [],
52
+ writeReviewLog: vi.fn(),
53
+ ...overrides,
54
+ } as unknown as HandlerDeps["runtime"];
55
+ }
56
+
57
+ function makeDeps(overrides: Record<string, unknown> = {}): HandlerDeps {
58
+ const { runtime: runtimeOverrides, events, ...rest } = overrides;
59
+ return {
60
+ runtime: makeRuntime(runtimeOverrides as Record<string, unknown>),
61
+ events: events ?? makeEvents(),
62
+ canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
63
+ promptPermission: vi
64
+ .fn()
65
+ .mockResolvedValue({ approved: true, state: "approved" }),
66
+ ...rest,
67
+ } as unknown as HandlerDeps;
68
+ }
69
+
70
+ // ── tests ──────────────────────────────────────────────────────────────────
71
+
72
+ describe("evaluateSkillReadGate", () => {
73
+ it("returns null when tool is not read", async () => {
74
+ const tcc = makeTcc({ toolName: "write" });
75
+ const deps = makeDeps({
76
+ runtime: { activeSkillEntries: [makeSkillEntry()] },
77
+ });
78
+ const result = await evaluateSkillReadGate(tcc, deps);
79
+ expect(result).toBeNull();
80
+ });
81
+
82
+ it("returns null when no active skill entries", async () => {
83
+ const tcc = makeTcc();
84
+ const deps = makeDeps({ runtime: { activeSkillEntries: [] } });
85
+ const result = await evaluateSkillReadGate(tcc, deps);
86
+ expect(result).toBeNull();
87
+ });
88
+
89
+ it("returns null when read path does not match any skill", async () => {
90
+ const tcc = makeTcc({ input: { path: "/test/project/src/index.ts" } });
91
+ const deps = makeDeps({
92
+ runtime: { activeSkillEntries: [makeSkillEntry()] },
93
+ });
94
+ const result = await evaluateSkillReadGate(tcc, deps);
95
+ expect(result).toBeNull();
96
+ });
97
+
98
+ it("returns allow when skill state is allow", async () => {
99
+ const tcc = makeTcc();
100
+ const deps = makeDeps({
101
+ runtime: {
102
+ activeSkillEntries: [makeSkillEntry({ state: "allow" })],
103
+ },
104
+ });
105
+ const result = await evaluateSkillReadGate(tcc, deps);
106
+ expect(result).toEqual({ action: "allow" });
107
+ });
108
+
109
+ it("returns block when skill state is deny", async () => {
110
+ const tcc = makeTcc();
111
+ const deps = makeDeps({
112
+ runtime: {
113
+ activeSkillEntries: [makeSkillEntry({ state: "deny" })],
114
+ },
115
+ });
116
+ const result = await evaluateSkillReadGate(tcc, deps);
117
+ expect(result).toMatchObject({ action: "block" });
118
+ });
119
+
120
+ it("returns allow when state is ask and user approves", async () => {
121
+ const tcc = makeTcc();
122
+ const deps = makeDeps({
123
+ runtime: {
124
+ activeSkillEntries: [makeSkillEntry({ state: "ask" })],
125
+ },
126
+ promptPermission: vi
127
+ .fn()
128
+ .mockResolvedValue({ approved: true, state: "approved" }),
129
+ });
130
+ const result = await evaluateSkillReadGate(tcc, deps);
131
+ expect(result).toEqual({ action: "allow" });
132
+ });
133
+
134
+ it("returns block when state is ask and user denies", async () => {
135
+ const tcc = makeTcc();
136
+ const deps = makeDeps({
137
+ runtime: {
138
+ activeSkillEntries: [makeSkillEntry({ state: "ask" })],
139
+ },
140
+ promptPermission: vi
141
+ .fn()
142
+ .mockResolvedValue({ approved: false, state: "denied" }),
143
+ });
144
+ const result = await evaluateSkillReadGate(tcc, deps);
145
+ expect(result).toMatchObject({ action: "block" });
146
+ });
147
+
148
+ it("returns block when state is ask and no UI available", async () => {
149
+ const tcc = makeTcc();
150
+ const deps = makeDeps({
151
+ runtime: {
152
+ activeSkillEntries: [makeSkillEntry({ state: "ask" })],
153
+ },
154
+ canRequestPermissionConfirmation: vi.fn().mockReturnValue(false),
155
+ });
156
+ const result = await evaluateSkillReadGate(tcc, deps);
157
+ expect(result).toMatchObject({ action: "block" });
158
+ });
159
+
160
+ it("emits decision event with correct fields on deny", async () => {
161
+ const events = makeEvents();
162
+ const tcc = makeTcc({ agentName: "test-agent" });
163
+ const deps = makeDeps({
164
+ runtime: {
165
+ activeSkillEntries: [makeSkillEntry({ state: "deny" })],
166
+ },
167
+ events,
168
+ });
169
+ await evaluateSkillReadGate(tcc, deps);
170
+ expect(events.emit).toHaveBeenCalledWith(
171
+ "permissions:decision",
172
+ expect.objectContaining({
173
+ surface: "skill",
174
+ value: "librarian",
175
+ result: "deny",
176
+ resolution: "policy_deny",
177
+ origin: null,
178
+ agentName: "test-agent",
179
+ matchedPattern: null,
180
+ }),
181
+ );
182
+ });
183
+
184
+ it("emits decision event with correct fields on allow", async () => {
185
+ const events = makeEvents();
186
+ const tcc = makeTcc();
187
+ const deps = makeDeps({
188
+ runtime: {
189
+ activeSkillEntries: [makeSkillEntry({ state: "allow" })],
190
+ },
191
+ events,
192
+ });
193
+ await evaluateSkillReadGate(tcc, deps);
194
+ expect(events.emit).toHaveBeenCalledWith(
195
+ "permissions:decision",
196
+ expect.objectContaining({
197
+ surface: "skill",
198
+ value: "librarian",
199
+ result: "allow",
200
+ resolution: "policy_allow",
201
+ }),
202
+ );
203
+ });
204
+ });
@@ -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
+ });