@gotgenes/pi-permission-system 3.6.0 → 3.7.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,418 @@
1
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import { describe, expect, it, vi } from "vitest";
3
+
4
+ import { getEventInput, handleToolCall } from "../../src/handlers/tool-call";
5
+ import type { HandlerDeps } from "../../src/handlers/types";
6
+ import type { PermissionCheckResult } from "../../src/types";
7
+
8
+ // ── SDK stubs ──────────────────────────────────────────────────────────────
9
+ vi.mock("@mariozechner/pi-coding-agent", async (importOriginal) => {
10
+ const original =
11
+ await importOriginal<typeof import("@mariozechner/pi-coding-agent")>();
12
+ return { ...original };
13
+ });
14
+
15
+ // ── helpers ────────────────────────────────────────────────────────────────
16
+
17
+ function makeCtx(
18
+ overrides: Partial<ExtensionContext> & { cwd?: string } = {},
19
+ ): ExtensionContext {
20
+ return {
21
+ cwd: "/test/project",
22
+ hasUI: true,
23
+ ui: {
24
+ setStatus: vi.fn(),
25
+ notify: vi.fn(),
26
+ select: vi.fn(),
27
+ input: vi.fn(),
28
+ },
29
+ sessionManager: {
30
+ getEntries: vi.fn().mockReturnValue([]),
31
+ getSessionDir: vi.fn().mockReturnValue("/sessions/test"),
32
+ addEntry: vi.fn(),
33
+ },
34
+ ...overrides,
35
+ } as unknown as ExtensionContext;
36
+ }
37
+
38
+ function makeToolCallEvent(
39
+ toolName: string,
40
+ extraFields: Record<string, unknown> = {},
41
+ ) {
42
+ return {
43
+ type: "tool_call",
44
+ toolCallId: "tc-1",
45
+ name: toolName,
46
+ input: {},
47
+ ...extraFields,
48
+ };
49
+ }
50
+
51
+ function makePermissionResult(
52
+ state: "allow" | "deny" | "ask",
53
+ ): PermissionCheckResult {
54
+ return { state, toolName: "read", source: "tool" };
55
+ }
56
+
57
+ function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
58
+ return {
59
+ getPermissionManager: vi.fn().mockReturnValue({
60
+ checkPermission: vi.fn().mockReturnValue(makePermissionResult("allow")),
61
+ }),
62
+ setPermissionManager: vi.fn(),
63
+ getRuntimeContext: vi.fn().mockReturnValue(null),
64
+ setRuntimeContext: vi.fn(),
65
+ getActiveSkillEntries: vi.fn().mockReturnValue([]),
66
+ setActiveSkillEntries: vi.fn(),
67
+ getLastKnownActiveAgentName: vi.fn().mockReturnValue(null),
68
+ setLastKnownActiveAgentName: vi.fn(),
69
+ getLastActiveToolsCacheKey: vi.fn().mockReturnValue(null),
70
+ setLastActiveToolsCacheKey: vi.fn(),
71
+ getLastPromptStateCacheKey: vi.fn().mockReturnValue(null),
72
+ setLastPromptStateCacheKey: vi.fn(),
73
+ sessionApprovalCache: {
74
+ approve: vi.fn(),
75
+ has: vi.fn().mockReturnValue(false),
76
+ findMatchingPrefix: vi.fn().mockReturnValue(null),
77
+ clear: vi.fn(),
78
+ } as unknown as HandlerDeps["sessionApprovalCache"],
79
+ createPermissionManagerForCwd: vi.fn(),
80
+ refreshExtensionConfig: vi.fn(),
81
+ notifyWarning: vi.fn(),
82
+ logResolvedConfigPaths: vi.fn(),
83
+ resolveAgentName: vi.fn().mockReturnValue(null),
84
+ canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
85
+ promptPermission: vi
86
+ .fn()
87
+ .mockResolvedValue({ approved: true, state: "approved" }),
88
+ createPermissionRequestId: vi.fn().mockReturnValue("req-id"),
89
+ startForwardedPermissionPolling: vi.fn(),
90
+ stopForwardedPermissionPolling: vi.fn(),
91
+ writeReviewLog: vi.fn(),
92
+ writeDebugLog: vi.fn(),
93
+ getAllTools: vi.fn().mockReturnValue([{ name: "read" }, { name: "bash" }]),
94
+ setActiveTools: vi.fn(),
95
+ ...overrides,
96
+ };
97
+ }
98
+
99
+ // ── getEventInput ──────────────────────────────────────────────────────────
100
+
101
+ describe("getEventInput", () => {
102
+ it("returns the input field when present", () => {
103
+ expect(getEventInput({ input: { path: "/foo" } })).toEqual({
104
+ path: "/foo",
105
+ });
106
+ });
107
+
108
+ it("returns the arguments field when input is absent", () => {
109
+ expect(getEventInput({ arguments: { command: "ls" } })).toEqual({
110
+ command: "ls",
111
+ });
112
+ });
113
+
114
+ it("returns empty object when neither field is present", () => {
115
+ expect(getEventInput({ type: "tool_call" })).toEqual({});
116
+ });
117
+
118
+ it("prefers input over arguments when both are present", () => {
119
+ expect(getEventInput({ input: { a: 1 }, arguments: { b: 2 } })).toEqual({
120
+ a: 1,
121
+ });
122
+ });
123
+ });
124
+
125
+ // ── handleToolCall ─────────────────────────────────────────────────────────
126
+
127
+ describe("handleToolCall", () => {
128
+ it("sets runtime context", async () => {
129
+ const ctx = makeCtx();
130
+ const deps = makeDeps();
131
+ await handleToolCall(deps, makeToolCallEvent("read"), ctx);
132
+ expect(deps.setRuntimeContext).toHaveBeenCalledWith(ctx);
133
+ });
134
+
135
+ it("starts forwarded permission polling", async () => {
136
+ const ctx = makeCtx();
137
+ const deps = makeDeps();
138
+ await handleToolCall(deps, makeToolCallEvent("read"), ctx);
139
+ expect(deps.startForwardedPermissionPolling).toHaveBeenCalledWith(ctx);
140
+ });
141
+
142
+ it("blocks when tool name cannot be resolved", async () => {
143
+ const deps = makeDeps();
144
+ // An event with no recognisable name field
145
+ const result = await handleToolCall(deps, { type: "tool_call" }, makeCtx());
146
+ expect(result).toEqual({
147
+ block: true,
148
+ reason: expect.stringContaining("tool"),
149
+ });
150
+ });
151
+
152
+ it("blocks when tool is not registered", async () => {
153
+ const deps = makeDeps({
154
+ getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
155
+ });
156
+ const result = await handleToolCall(
157
+ deps,
158
+ makeToolCallEvent("unknown-tool"),
159
+ makeCtx(),
160
+ );
161
+ expect(result).toMatchObject({ block: true });
162
+ });
163
+
164
+ it("returns empty object when tool is allowed", async () => {
165
+ const deps = makeDeps({
166
+ getPermissionManager: vi.fn().mockReturnValue({
167
+ checkPermission: vi.fn().mockReturnValue(makePermissionResult("allow")),
168
+ }),
169
+ });
170
+ const result = await handleToolCall(
171
+ deps,
172
+ makeToolCallEvent("read"),
173
+ makeCtx(),
174
+ );
175
+ expect(result).toEqual({});
176
+ });
177
+
178
+ it("blocks when tool is denied by policy", async () => {
179
+ const deps = makeDeps({
180
+ getPermissionManager: vi.fn().mockReturnValue({
181
+ checkPermission: vi.fn().mockReturnValue(makePermissionResult("deny")),
182
+ }),
183
+ });
184
+ const result = await handleToolCall(
185
+ deps,
186
+ makeToolCallEvent("read"),
187
+ makeCtx(),
188
+ );
189
+ expect(result).toMatchObject({ block: true });
190
+ });
191
+
192
+ it("blocks when tool ask has no UI available", async () => {
193
+ const deps = makeDeps({
194
+ getPermissionManager: vi.fn().mockReturnValue({
195
+ checkPermission: vi.fn().mockReturnValue(makePermissionResult("ask")),
196
+ }),
197
+ canRequestPermissionConfirmation: vi.fn().mockReturnValue(false),
198
+ });
199
+ const result = await handleToolCall(
200
+ deps,
201
+ makeToolCallEvent("read"),
202
+ makeCtx(),
203
+ );
204
+ expect(result).toMatchObject({ block: true });
205
+ });
206
+
207
+ it("allows when user approves the ask prompt", async () => {
208
+ const deps = makeDeps({
209
+ getPermissionManager: vi.fn().mockReturnValue({
210
+ checkPermission: vi.fn().mockReturnValue(makePermissionResult("ask")),
211
+ }),
212
+ canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
213
+ promptPermission: vi
214
+ .fn()
215
+ .mockResolvedValue({ approved: true, state: "approved" }),
216
+ });
217
+ const result = await handleToolCall(
218
+ deps,
219
+ makeToolCallEvent("read"),
220
+ makeCtx(),
221
+ );
222
+ expect(result).toEqual({});
223
+ });
224
+
225
+ it("blocks when user denies the ask prompt", async () => {
226
+ const deps = makeDeps({
227
+ getPermissionManager: vi.fn().mockReturnValue({
228
+ checkPermission: vi.fn().mockReturnValue(makePermissionResult("ask")),
229
+ }),
230
+ canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
231
+ promptPermission: vi
232
+ .fn()
233
+ .mockResolvedValue({ approved: false, state: "denied" }),
234
+ });
235
+ const result = await handleToolCall(
236
+ deps,
237
+ makeToolCallEvent("read"),
238
+ makeCtx(),
239
+ );
240
+ expect(result).toMatchObject({ block: true });
241
+ });
242
+ });
243
+
244
+ // ── skill-read gate ────────────────────────────────────────────────────────
245
+
246
+ describe("handleToolCall — skill-read gate", () => {
247
+ it("blocks a read of a denied skill path", async () => {
248
+ const skillEntry = {
249
+ name: "librarian",
250
+ description: "Research skills",
251
+ location: "/skills/librarian/SKILL.md",
252
+ state: "deny" as const,
253
+ normalizedLocation: "/skills/librarian/SKILL.md",
254
+ normalizedBaseDir: "/skills/librarian",
255
+ };
256
+ const deps = makeDeps({
257
+ getActiveSkillEntries: vi.fn().mockReturnValue([skillEntry]),
258
+ getAllTools: vi.fn().mockReturnValue([{ toolName: "read" }]),
259
+ getPermissionManager: vi.fn().mockReturnValue({
260
+ checkPermission: vi.fn().mockReturnValue(makePermissionResult("allow")),
261
+ }),
262
+ });
263
+ const event = {
264
+ type: "tool_call",
265
+ toolCallId: "tc-skill",
266
+ toolName: "read",
267
+ input: { path: "/skills/librarian/SKILL.md" },
268
+ };
269
+ const result = await handleToolCall(deps, event, makeCtx());
270
+ expect(result).toMatchObject({ block: true });
271
+ });
272
+
273
+ it("allows a read of a non-skill path even when skill entries are present", async () => {
274
+ const skillEntry = {
275
+ name: "librarian",
276
+ description: "Research skills",
277
+ location: "/skills/librarian/SKILL.md",
278
+ state: "deny" as const,
279
+ normalizedLocation: "/skills/librarian/SKILL.md",
280
+ normalizedBaseDir: "/skills/librarian",
281
+ };
282
+ const deps = makeDeps({
283
+ getActiveSkillEntries: vi.fn().mockReturnValue([skillEntry]),
284
+ getAllTools: vi.fn().mockReturnValue([{ toolName: "read" }]),
285
+ getPermissionManager: vi.fn().mockReturnValue({
286
+ checkPermission: vi.fn().mockReturnValue(makePermissionResult("allow")),
287
+ }),
288
+ });
289
+ const event = {
290
+ type: "tool_call",
291
+ toolCallId: "tc-ok",
292
+ toolName: "read",
293
+ input: { path: "/test/project/src/index.ts" },
294
+ };
295
+ const result = await handleToolCall(deps, event, makeCtx());
296
+ expect(result).toEqual({});
297
+ });
298
+ });
299
+
300
+ // ── external-directory gate ────────────────────────────────────────────────
301
+
302
+ describe("handleToolCall — external-directory gate", () => {
303
+ it("blocks a read of a path outside cwd when policy is deny", async () => {
304
+ const deps = makeDeps({
305
+ getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
306
+ getPermissionManager: vi.fn().mockReturnValue({
307
+ checkPermission: vi.fn().mockReturnValue(makePermissionResult("deny")),
308
+ }),
309
+ });
310
+ const event = {
311
+ type: "tool_call",
312
+ toolCallId: "tc-ext",
313
+ name: "read",
314
+ input: { path: "/outside/project/file.ts" },
315
+ };
316
+ const result = await handleToolCall(deps, event, makeCtx());
317
+ expect(result).toMatchObject({ block: true });
318
+ });
319
+
320
+ it("allows when session has an existing approval for the external path", async () => {
321
+ const deps = makeDeps({
322
+ getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
323
+ getPermissionManager: vi.fn().mockReturnValue({
324
+ checkPermission: vi.fn().mockReturnValue(makePermissionResult("allow")),
325
+ }),
326
+ sessionApprovalCache: {
327
+ approve: vi.fn(),
328
+ has: vi.fn().mockReturnValue(false),
329
+ findMatchingPrefix: vi.fn().mockReturnValue("/outside/project/"),
330
+ clear: vi.fn(),
331
+ } as unknown as HandlerDeps["sessionApprovalCache"],
332
+ });
333
+ const event = {
334
+ type: "tool_call",
335
+ toolCallId: "tc-session",
336
+ name: "read",
337
+ input: { path: "/outside/project/file.ts" },
338
+ };
339
+ const result = await handleToolCall(deps, event, makeCtx());
340
+ expect(result).toEqual({});
341
+ });
342
+
343
+ it("approves session when user selects approved_for_session", async () => {
344
+ const approveCache = {
345
+ approve: vi.fn(),
346
+ has: vi.fn().mockReturnValue(false),
347
+ findMatchingPrefix: vi.fn().mockReturnValue(null),
348
+ clear: vi.fn(),
349
+ } as unknown as HandlerDeps["sessionApprovalCache"];
350
+ const deps = makeDeps({
351
+ getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
352
+ getPermissionManager: vi.fn().mockReturnValue({
353
+ checkPermission: vi.fn().mockReturnValue(makePermissionResult("ask")),
354
+ }),
355
+ canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
356
+ promptPermission: vi
357
+ .fn()
358
+ .mockResolvedValue({ approved: true, state: "approved_for_session" }),
359
+ sessionApprovalCache: approveCache,
360
+ });
361
+ const event = {
362
+ type: "tool_call",
363
+ toolCallId: "tc-sess-approve",
364
+ name: "read",
365
+ input: { path: "/outside/project/file.ts" },
366
+ };
367
+ await handleToolCall(deps, event, makeCtx());
368
+ expect(approveCache.approve).toHaveBeenCalledWith(
369
+ "external_directory",
370
+ expect.any(String),
371
+ );
372
+ });
373
+ });
374
+
375
+ // ── bash external-directory gate ──────────────────────────────────────────
376
+
377
+ describe("handleToolCall — bash external-directory gate", () => {
378
+ it("blocks a bash command referencing an external path when policy is deny", async () => {
379
+ const deps = makeDeps({
380
+ getAllTools: vi.fn().mockReturnValue([{ name: "bash" }]),
381
+ getPermissionManager: vi.fn().mockReturnValue({
382
+ checkPermission: vi.fn().mockReturnValue(makePermissionResult("deny")),
383
+ }),
384
+ });
385
+ const event = {
386
+ type: "tool_call",
387
+ toolCallId: "tc-bash-ext",
388
+ name: "bash",
389
+ input: { command: "cat /outside/project/file.ts" },
390
+ };
391
+ const result = await handleToolCall(deps, event, makeCtx());
392
+ expect(result).toMatchObject({ block: true });
393
+ });
394
+
395
+ it("skips bash external gate when all referenced paths are session-approved", async () => {
396
+ const deps = makeDeps({
397
+ getAllTools: vi.fn().mockReturnValue([{ name: "bash" }]),
398
+ getPermissionManager: vi.fn().mockReturnValue({
399
+ checkPermission: vi.fn().mockReturnValue(makePermissionResult("allow")),
400
+ }),
401
+ sessionApprovalCache: {
402
+ approve: vi.fn(),
403
+ // All paths are covered
404
+ has: vi.fn().mockReturnValue(true),
405
+ findMatchingPrefix: vi.fn().mockReturnValue(null),
406
+ clear: vi.fn(),
407
+ } as unknown as HandlerDeps["sessionApprovalCache"],
408
+ });
409
+ const event = {
410
+ type: "tool_call",
411
+ toolCallId: "tc-bash-sess",
412
+ name: "bash",
413
+ input: { command: "cat /outside/project/file.ts" },
414
+ };
415
+ const result = await handleToolCall(deps, event, makeCtx());
416
+ expect(result).toEqual({});
417
+ });
418
+ });