@gotgenes/pi-permission-system 5.9.0 → 5.11.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,607 @@
1
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ // ── Module mocks (hoisted) ─────────────────────────────────────────────────
5
+
6
+ const {
7
+ mockGetActiveAgentName,
8
+ mockGetActiveAgentNameFromSystemPrompt,
9
+ mockCreatePermissionManagerForCwd,
10
+ } = vi.hoisted(() => ({
11
+ mockGetActiveAgentName: vi.fn<(ctx: ExtensionContext) => string | null>(),
12
+ mockGetActiveAgentNameFromSystemPrompt:
13
+ vi.fn<(systemPrompt?: string) => string | null>(),
14
+ mockCreatePermissionManagerForCwd: vi.fn(),
15
+ }));
16
+
17
+ vi.mock("../src/active-agent", () => ({
18
+ getActiveAgentName: mockGetActiveAgentName,
19
+ getActiveAgentNameFromSystemPrompt: mockGetActiveAgentNameFromSystemPrompt,
20
+ }));
21
+
22
+ vi.mock("../src/runtime", async (importOriginal) => {
23
+ const original = await importOriginal<typeof import("../src/runtime")>();
24
+ return {
25
+ ...original,
26
+ createPermissionManagerForCwd: mockCreatePermissionManagerForCwd,
27
+ };
28
+ });
29
+
30
+ // ── Test helpers ───────────────────────────────────────────────────────────
31
+
32
+ import type { ExtensionPaths } from "../src/extension-paths";
33
+ import type { ForwardingController } from "../src/forwarding-manager";
34
+ import type { PermissionManager } from "../src/permission-manager";
35
+ import {
36
+ PermissionSession,
37
+ type PermissionSessionRuntimeDeps,
38
+ } from "../src/permission-session";
39
+ import type { SessionLogger } from "../src/session-logger";
40
+ import type { SkillPromptEntry } from "../src/skill-prompt-sanitizer";
41
+ import type { PermissionCheckResult } from "../src/types";
42
+
43
+ function makeSkillEntry(
44
+ name: string,
45
+ overrides: Partial<SkillPromptEntry> = {},
46
+ ): SkillPromptEntry {
47
+ return {
48
+ name,
49
+ description: `${name} skill`,
50
+ location: `/${name}/SKILL.md`,
51
+ state: "allow",
52
+ normalizedLocation: `/${name}/SKILL.md`,
53
+ normalizedBaseDir: `/${name}`,
54
+ ...overrides,
55
+ };
56
+ }
57
+
58
+ function makePaths(overrides: Partial<ExtensionPaths> = {}): ExtensionPaths {
59
+ return {
60
+ agentDir: "/test/agent",
61
+ sessionsDir: "/test/agent/sessions",
62
+ subagentSessionsDir: "/test/agent/subagent-sessions",
63
+ forwardingDir: "/test/agent/sessions/permission-forwarding",
64
+ globalLogsDir: "/test/agent/logs",
65
+ piInfrastructureDirs: ["/test/agent", "/test/agent/git"],
66
+ ...overrides,
67
+ };
68
+ }
69
+
70
+ function makeLogger(): SessionLogger {
71
+ return {
72
+ debug: vi.fn(),
73
+ review: vi.fn(),
74
+ warn: vi.fn(),
75
+ };
76
+ }
77
+
78
+ function makeRuntimeDeps(): PermissionSessionRuntimeDeps {
79
+ return {
80
+ refreshExtensionConfig: vi.fn(),
81
+ logResolvedConfigPaths: vi.fn(),
82
+ getConfig: vi.fn().mockReturnValue({}),
83
+ canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
84
+ promptPermission: vi
85
+ .fn()
86
+ .mockResolvedValue({ approved: true, state: "approved" }),
87
+ };
88
+ }
89
+
90
+ function makeForwarding(): ForwardingController {
91
+ return {
92
+ start: vi.fn(),
93
+ stop: vi.fn(),
94
+ };
95
+ }
96
+
97
+ function makeCtx(overrides: Partial<ExtensionContext> = {}): ExtensionContext {
98
+ return {
99
+ cwd: "/test/project",
100
+ hasUI: true,
101
+ ui: {
102
+ setStatus: vi.fn(),
103
+ notify: vi.fn(),
104
+ select: vi.fn(),
105
+ input: vi.fn(),
106
+ },
107
+ sessionManager: {
108
+ getEntries: vi.fn().mockReturnValue([]),
109
+ getSessionDir: vi.fn().mockReturnValue("/sessions/test"),
110
+ addEntry: vi.fn(),
111
+ },
112
+ ...overrides,
113
+ } as unknown as ExtensionContext;
114
+ }
115
+
116
+ function makePermissionManager(
117
+ overrides: Partial<PermissionManager> = {},
118
+ ): PermissionManager {
119
+ return {
120
+ checkPermission: vi.fn().mockReturnValue({
121
+ state: "allow",
122
+ toolName: "read",
123
+ source: "tool",
124
+ origin: "builtin",
125
+ } as PermissionCheckResult),
126
+ getToolPermission: vi.fn().mockReturnValue("allow"),
127
+ getConfigIssues: vi.fn().mockReturnValue([]),
128
+ getPolicyCacheStamp: vi.fn().mockReturnValue("stamp-1"),
129
+ getComposedConfigRules: vi.fn().mockReturnValue([]),
130
+ getResolvedPolicyPaths: vi.fn().mockReturnValue({}),
131
+ ...overrides,
132
+ } as unknown as PermissionManager;
133
+ }
134
+
135
+ function createSession(overrides?: {
136
+ paths?: Partial<ExtensionPaths>;
137
+ logger?: SessionLogger;
138
+ forwarding?: ForwardingController;
139
+ runtimeDeps?: PermissionSessionRuntimeDeps;
140
+ }): {
141
+ session: PermissionSession;
142
+ paths: ExtensionPaths;
143
+ logger: SessionLogger;
144
+ forwarding: ForwardingController;
145
+ runtimeDeps: PermissionSessionRuntimeDeps;
146
+ } {
147
+ const paths = makePaths(overrides?.paths);
148
+ const logger = overrides?.logger ?? makeLogger();
149
+ const forwarding = overrides?.forwarding ?? makeForwarding();
150
+ const runtimeDeps = overrides?.runtimeDeps ?? makeRuntimeDeps();
151
+ const session = new PermissionSession(paths, logger, forwarding, runtimeDeps);
152
+ return { session, paths, logger, forwarding, runtimeDeps };
153
+ }
154
+
155
+ // ── Tests ──────────────────────────────────────────────────────────────────
156
+
157
+ beforeEach(() => {
158
+ mockGetActiveAgentName.mockReset();
159
+ mockGetActiveAgentNameFromSystemPrompt.mockReset();
160
+ mockCreatePermissionManagerForCwd.mockReset();
161
+
162
+ // Default: createPermissionManagerForCwd returns a fresh mock PM
163
+ mockCreatePermissionManagerForCwd.mockReturnValue(makePermissionManager());
164
+ mockGetActiveAgentName.mockReturnValue(null);
165
+ mockGetActiveAgentNameFromSystemPrompt.mockReturnValue(null);
166
+ });
167
+
168
+ describe("PermissionSession", () => {
169
+ describe("constructor and delegation", () => {
170
+ it("delegates checkPermission to internal PermissionManager", () => {
171
+ const pm = makePermissionManager();
172
+ mockCreatePermissionManagerForCwd.mockReturnValue(pm);
173
+ const { session } = createSession();
174
+
175
+ const result = session.checkPermission("bash", { command: "ls" });
176
+
177
+ expect(pm.checkPermission).toHaveBeenCalledWith(
178
+ "bash",
179
+ { command: "ls" },
180
+ undefined,
181
+ undefined,
182
+ );
183
+ expect(result.state).toBe("allow");
184
+ });
185
+
186
+ it("delegates getToolPermission to internal PermissionManager", () => {
187
+ const pm = makePermissionManager();
188
+ mockCreatePermissionManagerForCwd.mockReturnValue(pm);
189
+ const { session } = createSession();
190
+
191
+ const result = session.getToolPermission("read");
192
+
193
+ expect(pm.getToolPermission).toHaveBeenCalledWith("read", undefined);
194
+ expect(result).toBe("allow");
195
+ });
196
+
197
+ it("delegates getConfigIssues to internal PermissionManager", () => {
198
+ const pm = makePermissionManager({
199
+ getConfigIssues: vi.fn().mockReturnValue(["issue1"]),
200
+ });
201
+ mockCreatePermissionManagerForCwd.mockReturnValue(pm);
202
+ const { session } = createSession();
203
+
204
+ expect(session.getConfigIssues("agent1")).toEqual(["issue1"]);
205
+ expect(pm.getConfigIssues).toHaveBeenCalledWith("agent1");
206
+ });
207
+
208
+ it("delegates getPolicyCacheStamp to internal PermissionManager", () => {
209
+ const pm = makePermissionManager();
210
+ mockCreatePermissionManagerForCwd.mockReturnValue(pm);
211
+ const { session } = createSession();
212
+
213
+ expect(session.getPolicyCacheStamp("agent1")).toBe("stamp-1");
214
+ expect(pm.getPolicyCacheStamp).toHaveBeenCalledWith("agent1");
215
+ });
216
+
217
+ it("delegates getSessionRuleset to internal SessionRules", () => {
218
+ const { session } = createSession();
219
+ const rules = session.getSessionRuleset();
220
+ expect(rules).toEqual([]);
221
+ });
222
+
223
+ it("delegates approveSessionRule to internal SessionRules", () => {
224
+ const { session } = createSession();
225
+ session.approveSessionRule("bash", "/usr/bin/*");
226
+ const rules = session.getSessionRuleset();
227
+ expect(rules).toHaveLength(1);
228
+ expect(rules[0]).toMatchObject({
229
+ surface: "bash",
230
+ pattern: "/usr/bin/*",
231
+ action: "allow",
232
+ });
233
+ });
234
+ });
235
+
236
+ describe("activate and deactivate", () => {
237
+ it("stores the context on activate", () => {
238
+ const { session, forwarding } = createSession();
239
+ const ctx = makeCtx();
240
+
241
+ session.activate(ctx);
242
+
243
+ expect(forwarding.start).toHaveBeenCalledWith(ctx);
244
+ });
245
+
246
+ it("clears context on deactivate", () => {
247
+ const { session, forwarding } = createSession();
248
+ session.activate(makeCtx());
249
+ session.deactivate();
250
+
251
+ expect(forwarding.stop).toHaveBeenCalled();
252
+ });
253
+ });
254
+
255
+ describe("resetForNewSession", () => {
256
+ it("creates a new PermissionManager for the context cwd", () => {
257
+ const pm2 = makePermissionManager({
258
+ checkPermission: vi.fn().mockReturnValue({
259
+ state: "deny",
260
+ toolName: "bash",
261
+ source: "bash",
262
+ origin: "global",
263
+ } as PermissionCheckResult),
264
+ });
265
+ mockCreatePermissionManagerForCwd.mockReturnValue(pm2);
266
+ const { session } = createSession();
267
+ const ctx = makeCtx({ cwd: "/new/project" });
268
+
269
+ session.resetForNewSession(ctx);
270
+
271
+ expect(mockCreatePermissionManagerForCwd).toHaveBeenCalledWith(
272
+ "/test/agent",
273
+ "/new/project",
274
+ );
275
+ // Verify the new PM is used for subsequent calls
276
+ const result = session.checkPermission("bash", { command: "rm" });
277
+ expect(result.state).toBe("deny");
278
+ });
279
+
280
+ it("clears cache keys", () => {
281
+ const { session } = createSession();
282
+ session.commitActiveToolsCacheKey("key-1");
283
+ session.commitPromptStateCacheKey("key-2");
284
+ expect(session.shouldUpdateActiveTools("key-1")).toBe(false);
285
+ expect(session.shouldUpdatePromptState("key-2")).toBe(false);
286
+
287
+ session.resetForNewSession(makeCtx());
288
+
289
+ // After reset, same keys should be treated as new
290
+ expect(session.shouldUpdateActiveTools("key-1")).toBe(true);
291
+ expect(session.shouldUpdatePromptState("key-2")).toBe(true);
292
+ });
293
+
294
+ it("clears skill entries", () => {
295
+ const { session } = createSession();
296
+ session.setActiveSkillEntries([makeSkillEntry("test")]);
297
+ expect(session.getActiveSkillEntries()).toHaveLength(1);
298
+
299
+ session.resetForNewSession(makeCtx());
300
+
301
+ expect(session.getActiveSkillEntries()).toEqual([]);
302
+ });
303
+
304
+ it("starts forwarding with the new context", () => {
305
+ const { session, forwarding } = createSession();
306
+ const ctx = makeCtx();
307
+
308
+ session.resetForNewSession(ctx);
309
+
310
+ expect(forwarding.start).toHaveBeenCalledWith(ctx);
311
+ });
312
+
313
+ it("activates the new context", () => {
314
+ const { session } = createSession();
315
+ const ctx = makeCtx();
316
+
317
+ session.resetForNewSession(ctx);
318
+
319
+ // Verify context is stored by calling resolveAgentName which needs it
320
+ mockGetActiveAgentName.mockReturnValue("test-agent");
321
+ const name = session.resolveAgentName(ctx);
322
+ expect(name).toBe("test-agent");
323
+ });
324
+ });
325
+
326
+ describe("shutdown", () => {
327
+ it("clears session rules", () => {
328
+ const { session } = createSession();
329
+ session.approveSessionRule("bash", "*");
330
+ expect(session.getSessionRuleset()).toHaveLength(1);
331
+
332
+ session.shutdown();
333
+
334
+ expect(session.getSessionRuleset()).toEqual([]);
335
+ });
336
+
337
+ it("clears cache keys", () => {
338
+ const { session } = createSession();
339
+ session.commitActiveToolsCacheKey("k1");
340
+ session.commitPromptStateCacheKey("k2");
341
+
342
+ session.shutdown();
343
+
344
+ expect(session.shouldUpdateActiveTools("k1")).toBe(true);
345
+ expect(session.shouldUpdatePromptState("k2")).toBe(true);
346
+ });
347
+
348
+ it("clears skill entries", () => {
349
+ const { session } = createSession();
350
+ session.setActiveSkillEntries([makeSkillEntry("s")]);
351
+
352
+ session.shutdown();
353
+
354
+ expect(session.getActiveSkillEntries()).toEqual([]);
355
+ });
356
+
357
+ it("stops forwarding and deactivates context", () => {
358
+ const { session, forwarding } = createSession();
359
+ session.activate(makeCtx());
360
+
361
+ session.shutdown();
362
+
363
+ expect(forwarding.stop).toHaveBeenCalled();
364
+ });
365
+ });
366
+
367
+ describe("cache key methods", () => {
368
+ it("shouldUpdateActiveTools returns true for new key", () => {
369
+ const { session } = createSession();
370
+ expect(session.shouldUpdateActiveTools("key-1")).toBe(true);
371
+ });
372
+
373
+ it("shouldUpdateActiveTools returns false for committed key", () => {
374
+ const { session } = createSession();
375
+ session.commitActiveToolsCacheKey("key-1");
376
+ expect(session.shouldUpdateActiveTools("key-1")).toBe(false);
377
+ });
378
+
379
+ it("shouldUpdateActiveTools returns true for different key", () => {
380
+ const { session } = createSession();
381
+ session.commitActiveToolsCacheKey("key-1");
382
+ expect(session.shouldUpdateActiveTools("key-2")).toBe(true);
383
+ });
384
+
385
+ it("shouldUpdatePromptState returns true for new key", () => {
386
+ const { session } = createSession();
387
+ expect(session.shouldUpdatePromptState("key-1")).toBe(true);
388
+ });
389
+
390
+ it("shouldUpdatePromptState returns false for committed key", () => {
391
+ const { session } = createSession();
392
+ session.commitPromptStateCacheKey("key-1");
393
+ expect(session.shouldUpdatePromptState("key-1")).toBe(false);
394
+ });
395
+ });
396
+
397
+ describe("skill entries", () => {
398
+ it("get/set skill entries", () => {
399
+ const { session } = createSession();
400
+ const entries = [makeSkillEntry("a"), makeSkillEntry("b")];
401
+ session.setActiveSkillEntries(entries);
402
+ expect(session.getActiveSkillEntries()).toEqual(entries);
403
+ });
404
+ });
405
+
406
+ describe("resolveAgentName", () => {
407
+ it("returns name from session context", () => {
408
+ mockGetActiveAgentName.mockReturnValue("ctx-agent");
409
+ const { session } = createSession();
410
+ const ctx = makeCtx();
411
+
412
+ expect(session.resolveAgentName(ctx)).toBe("ctx-agent");
413
+ });
414
+
415
+ it("falls back to system prompt", () => {
416
+ mockGetActiveAgentName.mockReturnValue(null);
417
+ mockGetActiveAgentNameFromSystemPrompt.mockReturnValue("prompt-agent");
418
+ const { session } = createSession();
419
+ const ctx = makeCtx();
420
+
421
+ expect(session.resolveAgentName(ctx, "system prompt")).toBe(
422
+ "prompt-agent",
423
+ );
424
+ });
425
+
426
+ it("falls back to last known name", () => {
427
+ const { session } = createSession();
428
+ const ctx = makeCtx();
429
+
430
+ // First call sets name
431
+ mockGetActiveAgentName.mockReturnValue("first-agent");
432
+ session.resolveAgentName(ctx);
433
+
434
+ // Second call with no name resolves to last known
435
+ mockGetActiveAgentName.mockReturnValue(null);
436
+ mockGetActiveAgentNameFromSystemPrompt.mockReturnValue(null);
437
+ expect(session.resolveAgentName(ctx)).toBe("first-agent");
438
+ });
439
+
440
+ it("exposes lastKnownActiveAgentName", () => {
441
+ const { session } = createSession();
442
+ expect(session.lastKnownActiveAgentName).toBeNull();
443
+
444
+ mockGetActiveAgentName.mockReturnValue("named");
445
+ session.resolveAgentName(makeCtx());
446
+ expect(session.lastKnownActiveAgentName).toBe("named");
447
+ });
448
+ });
449
+
450
+ describe("infrastructure paths", () => {
451
+ it("getInfrastructureDirs returns paths from ExtensionPaths", () => {
452
+ const { session } = createSession();
453
+ expect(session.getInfrastructureDirs()).toEqual([
454
+ "/test/agent",
455
+ "/test/agent/git",
456
+ ]);
457
+ });
458
+
459
+ it("getInfrastructureReadPaths returns config paths", () => {
460
+ const runtimeDeps = makeRuntimeDeps();
461
+ (runtimeDeps.getConfig as ReturnType<typeof vi.fn>).mockReturnValue({
462
+ piInfrastructureReadPaths: ["/extra/path"],
463
+ });
464
+ const { session } = createSession({ runtimeDeps });
465
+ expect(session.getInfrastructureReadPaths()).toEqual(["/extra/path"]);
466
+ });
467
+
468
+ it("getInfrastructureReadPaths returns empty when config omits the field", () => {
469
+ const { session } = createSession();
470
+ expect(session.getInfrastructureReadPaths()).toEqual([]);
471
+ });
472
+ });
473
+
474
+ describe("config delegation", () => {
475
+ it("refreshConfig delegates to runtimeDeps", () => {
476
+ const { session, runtimeDeps } = createSession();
477
+ const ctx = makeCtx();
478
+ session.refreshConfig(ctx);
479
+ expect(runtimeDeps.refreshExtensionConfig).toHaveBeenCalledWith(ctx);
480
+ });
481
+
482
+ it("logResolvedConfigPaths delegates to runtimeDeps", () => {
483
+ const { session, runtimeDeps } = createSession();
484
+ session.logResolvedConfigPaths();
485
+ expect(runtimeDeps.logResolvedConfigPaths).toHaveBeenCalled();
486
+ });
487
+
488
+ it("config getter delegates to runtimeDeps.getConfig", () => {
489
+ const runtimeDeps = makeRuntimeDeps();
490
+ const fakeConfig = { debugLog: true };
491
+ (runtimeDeps.getConfig as ReturnType<typeof vi.fn>).mockReturnValue(
492
+ fakeConfig,
493
+ );
494
+ const { session } = createSession({ runtimeDeps });
495
+ expect(session.config).toBe(fakeConfig);
496
+ });
497
+ });
498
+
499
+ describe("reload", () => {
500
+ it("recreates PermissionManager for current context cwd", () => {
501
+ const { session } = createSession();
502
+ const ctx = makeCtx({ cwd: "/project" });
503
+ session.activate(ctx);
504
+
505
+ const pm2 = makePermissionManager();
506
+ mockCreatePermissionManagerForCwd.mockReturnValue(pm2);
507
+
508
+ session.reload();
509
+
510
+ expect(mockCreatePermissionManagerForCwd).toHaveBeenCalledWith(
511
+ "/test/agent",
512
+ "/project",
513
+ );
514
+ });
515
+
516
+ it("clears caches and skill entries", () => {
517
+ const { session } = createSession();
518
+ session.commitActiveToolsCacheKey("k1");
519
+ session.commitPromptStateCacheKey("k2");
520
+ session.setActiveSkillEntries([makeSkillEntry("s")]);
521
+
522
+ session.reload();
523
+
524
+ expect(session.shouldUpdateActiveTools("k1")).toBe(true);
525
+ expect(session.shouldUpdatePromptState("k2")).toBe(true);
526
+ expect(session.getActiveSkillEntries()).toEqual([]);
527
+ });
528
+ });
529
+
530
+ describe("getRuntimeContext", () => {
531
+ it("returns null before activation", () => {
532
+ const { session } = createSession();
533
+ expect(session.getRuntimeContext()).toBeNull();
534
+ });
535
+
536
+ it("returns context after activation", () => {
537
+ const { session } = createSession();
538
+ const ctx = makeCtx();
539
+ session.activate(ctx);
540
+ expect(session.getRuntimeContext()).toBe(ctx);
541
+ });
542
+
543
+ it("returns null after deactivation", () => {
544
+ const { session } = createSession();
545
+ session.activate(makeCtx());
546
+ session.deactivate();
547
+ expect(session.getRuntimeContext()).toBeNull();
548
+ });
549
+ });
550
+
551
+ describe("canPrompt", () => {
552
+ it("delegates to runtimeDeps.canRequestPermissionConfirmation", () => {
553
+ const { session, runtimeDeps } = createSession();
554
+ const ctx = makeCtx();
555
+
556
+ const result = session.canPrompt(ctx);
557
+
558
+ expect(runtimeDeps.canRequestPermissionConfirmation).toHaveBeenCalledWith(
559
+ ctx,
560
+ );
561
+ expect(result).toBe(true);
562
+ });
563
+
564
+ it("returns false when runtimeDeps says no", () => {
565
+ const runtimeDeps = makeRuntimeDeps();
566
+ (
567
+ runtimeDeps.canRequestPermissionConfirmation as ReturnType<typeof vi.fn>
568
+ ).mockReturnValue(false);
569
+ const { session } = createSession({ runtimeDeps });
570
+
571
+ expect(session.canPrompt(makeCtx())).toBe(false);
572
+ });
573
+ });
574
+
575
+ describe("prompt", () => {
576
+ it("delegates to runtimeDeps.promptPermission", async () => {
577
+ const { session, runtimeDeps } = createSession();
578
+ const ctx = makeCtx();
579
+ const details = {
580
+ requestId: "req-1",
581
+ source: "tool_call" as const,
582
+ agentName: null,
583
+ message: "Allow?",
584
+ };
585
+
586
+ const result = await session.prompt(ctx, details);
587
+
588
+ expect(runtimeDeps.promptPermission).toHaveBeenCalledWith(ctx, details);
589
+ expect(result).toEqual({ approved: true, state: "approved" });
590
+ });
591
+ });
592
+
593
+ describe("createPermissionRequestId", () => {
594
+ it("starts with the given prefix", () => {
595
+ const { session } = createSession();
596
+ const id = session.createPermissionRequestId("skill-input");
597
+ expect(id.startsWith("skill-input-")).toBe(true);
598
+ });
599
+
600
+ it("generates unique IDs on repeated calls", () => {
601
+ const { session } = createSession();
602
+ const id1 = session.createPermissionRequestId("test");
603
+ const id2 = session.createPermissionRequestId("test");
604
+ expect(id1).not.toBe(id2);
605
+ });
606
+ });
607
+ });