@gotgenes/pi-permission-system 10.0.0 → 10.2.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.
Files changed (68) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/agent-prep-session.ts +28 -0
  5. package/src/decision-reporter.ts +41 -0
  6. package/src/denial-messages.ts +11 -0
  7. package/src/forwarded-permissions/permission-forwarder.ts +549 -0
  8. package/src/forwarding-manager.ts +3 -7
  9. package/src/gate-handler-session.ts +13 -0
  10. package/src/gate-prompter.ts +14 -0
  11. package/src/handlers/before-agent-start.ts +2 -3
  12. package/src/handlers/gates/bash-command.ts +4 -18
  13. package/src/handlers/gates/bash-external-directory.ts +3 -15
  14. package/src/handlers/gates/bash-path.ts +3 -16
  15. package/src/handlers/gates/descriptor.ts +0 -28
  16. package/src/handlers/gates/path.ts +3 -15
  17. package/src/handlers/gates/runner.ts +142 -105
  18. package/src/handlers/gates/skill-input-gate-pipeline.ts +104 -0
  19. package/src/handlers/gates/skill-input.ts +44 -0
  20. package/src/handlers/gates/tool-call-gate-pipeline.ts +120 -0
  21. package/src/handlers/lifecycle.ts +9 -9
  22. package/src/handlers/permission-gate-handler.ts +34 -238
  23. package/src/index.ts +53 -69
  24. package/src/mcp-targets.ts +56 -46
  25. package/src/permission-manager.ts +69 -3
  26. package/src/permission-prompter.ts +7 -58
  27. package/src/permission-resolver.ts +17 -0
  28. package/src/permission-session.ts +83 -27
  29. package/src/permissions-service.ts +53 -0
  30. package/src/runtime.ts +1 -37
  31. package/src/service-lifecycle.ts +49 -0
  32. package/src/session-approval-recorder.ts +6 -0
  33. package/src/session-lifecycle-session.ts +24 -0
  34. package/src/tool-input-preview.ts +0 -62
  35. package/src/tool-input-prompt-formatters.ts +63 -0
  36. package/src/tool-preview-formatter.ts +6 -4
  37. package/test/decision-reporter.test.ts +112 -0
  38. package/test/denial-messages.test.ts +62 -0
  39. package/test/forwarding-manager.test.ts +26 -44
  40. package/test/handlers/before-agent-start.test.ts +45 -21
  41. package/test/handlers/external-directory-integration.test.ts +83 -114
  42. package/test/handlers/external-directory-session-dedup.test.ts +102 -55
  43. package/test/handlers/gates/bash-command.test.ts +49 -90
  44. package/test/handlers/gates/bash-external-directory.test.ts +54 -95
  45. package/test/handlers/gates/bash-path.test.ts +54 -157
  46. package/test/handlers/gates/path.test.ts +38 -105
  47. package/test/handlers/gates/runner.test.ts +151 -186
  48. package/test/handlers/gates/skill-input-gate-pipeline.test.ts +176 -0
  49. package/test/handlers/gates/skill-input.test.ts +128 -0
  50. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +180 -0
  51. package/test/handlers/input.test.ts +1 -2
  52. package/test/handlers/lifecycle.test.ts +49 -33
  53. package/test/handlers/tool-call-events.test.ts +1 -1
  54. package/test/handlers/tool-call.test.ts +44 -153
  55. package/test/helpers/gate-fixtures.ts +212 -17
  56. package/test/helpers/handler-fixtures.ts +226 -29
  57. package/test/mcp-targets.test.ts +55 -0
  58. package/test/permission-forwarder.test.ts +295 -0
  59. package/test/permission-forwarding.test.ts +0 -282
  60. package/test/permission-manager-unified.test.ts +159 -1
  61. package/test/permission-prompter.test.ts +33 -44
  62. package/test/permission-session.test.ts +211 -105
  63. package/test/permissions-service.test.ts +151 -0
  64. package/test/runtime.test.ts +2 -86
  65. package/test/service-lifecycle.test.ts +162 -0
  66. package/test/tool-input-preview.test.ts +0 -111
  67. package/test/tool-input-prompt-formatters.test.ts +115 -0
  68. package/src/forwarded-permissions/polling.ts +0 -411
@@ -1,14 +1,5 @@
1
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
- import { tmpdir } from "node:os";
3
- import { join } from "node:path";
4
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
1
  import { afterEach, describe, expect, test, vi } from "vitest";
6
2
  import {
7
- confirmPermission,
8
- processForwardedPermissionRequests,
9
- } from "#src/forwarded-permissions/polling";
10
- import {
11
- createPermissionForwardingLocation,
12
3
  resolvePermissionForwardingTargetSessionId,
13
4
  SUBAGENT_PARENT_SESSION_ENV_CANDIDATES,
14
5
  SUBAGENT_PARENT_SESSION_ENV_KEY,
@@ -249,276 +240,3 @@ describe("resolvePermissionForwardingTargetSessionId — registry resolution", (
249
240
  ).toBe("parent-from-env");
250
241
  });
251
242
  });
252
-
253
- describe("processForwardedPermissionRequests", () => {
254
- test("emits a UI prompt event before showing a forwarded permission dialog", async () => {
255
- const root = mkdtempSync(join(tmpdir(), "permission-forwarding-"));
256
- try {
257
- const forwardingDir = join(root, "forwarding");
258
- const location = createPermissionForwardingLocation(
259
- forwardingDir,
260
- "parent-session",
261
- );
262
- mkdirSync(location.requestsDir, { recursive: true });
263
- mkdirSync(location.responsesDir, { recursive: true });
264
- writeFileSync(
265
- join(location.requestsDir, "req-forwarded.json"),
266
- JSON.stringify({
267
- id: "req-forwarded",
268
- createdAt: Date.now(),
269
- requesterSessionId: "child-session",
270
- targetSessionId: "parent-session",
271
- requesterAgentName: "Explore",
272
- message: "Allow git push?",
273
- }),
274
- "utf-8",
275
- );
276
-
277
- const events = {
278
- emit: vi.fn(),
279
- on: vi.fn().mockReturnValue(() => undefined),
280
- };
281
- const requestPermissionDecisionFromUi = vi
282
- .fn()
283
- .mockResolvedValue({ approved: true, state: "approved" as const });
284
-
285
- await processForwardedPermissionRequests(
286
- {
287
- hasUI: true,
288
- ui: { select: vi.fn(), input: vi.fn() },
289
- sessionManager: {
290
- getSessionId: vi.fn().mockReturnValue("parent-session"),
291
- },
292
- } as unknown as ExtensionContext,
293
- {
294
- forwardingDir,
295
- subagentSessionsDir: join(root, "subagents"),
296
- events,
297
- logger: { writeReviewLog: vi.fn(), writeDebugLog: vi.fn() },
298
- writeReviewLog: vi.fn(),
299
- requestPermissionDecisionFromUi,
300
- shouldAutoApprove: () => false,
301
- },
302
- );
303
-
304
- expect(events.emit).toHaveBeenCalledWith(
305
- "permissions:ui_prompt",
306
- expect.objectContaining({
307
- requestId: "req-forwarded",
308
- source: "tool_call",
309
- surface: null,
310
- value: null,
311
- agentName: "Explore",
312
- message: expect.stringContaining("Allow git push?"),
313
- forwarding: {
314
- requesterAgentName: "Explore",
315
- requesterSessionId: "child-session",
316
- },
317
- }),
318
- );
319
- expect(requestPermissionDecisionFromUi).toHaveBeenCalled();
320
- } finally {
321
- rmSync(root, { recursive: true, force: true });
322
- }
323
- });
324
-
325
- test("emits a non-degraded UI prompt event when the request carries display fields", async () => {
326
- const root = mkdtempSync(join(tmpdir(), "permission-forwarding-"));
327
- try {
328
- const forwardingDir = join(root, "forwarding");
329
- const location = createPermissionForwardingLocation(
330
- forwardingDir,
331
- "parent-session",
332
- );
333
- mkdirSync(location.requestsDir, { recursive: true });
334
- mkdirSync(location.responsesDir, { recursive: true });
335
- writeFileSync(
336
- join(location.requestsDir, "req-forwarded-rich.json"),
337
- JSON.stringify({
338
- id: "req-forwarded-rich",
339
- createdAt: Date.now(),
340
- requesterSessionId: "child-session",
341
- targetSessionId: "parent-session",
342
- requesterAgentName: "Explore",
343
- message: "Allow git push?",
344
- source: "tool_call",
345
- surface: "bash",
346
- value: "git push",
347
- }),
348
- "utf-8",
349
- );
350
-
351
- const events = {
352
- emit: vi.fn(),
353
- on: vi.fn().mockReturnValue(() => undefined),
354
- };
355
- const requestPermissionDecisionFromUi = vi
356
- .fn()
357
- .mockResolvedValue({ approved: true, state: "approved" as const });
358
-
359
- await processForwardedPermissionRequests(
360
- {
361
- hasUI: true,
362
- ui: { select: vi.fn(), input: vi.fn() },
363
- sessionManager: {
364
- getSessionId: vi.fn().mockReturnValue("parent-session"),
365
- },
366
- } as unknown as ExtensionContext,
367
- {
368
- forwardingDir,
369
- subagentSessionsDir: join(root, "subagents"),
370
- events,
371
- logger: { writeReviewLog: vi.fn(), writeDebugLog: vi.fn() },
372
- writeReviewLog: vi.fn(),
373
- requestPermissionDecisionFromUi,
374
- shouldAutoApprove: () => false,
375
- },
376
- );
377
-
378
- expect(events.emit).toHaveBeenCalledWith(
379
- "permissions:ui_prompt",
380
- expect.objectContaining({
381
- requestId: "req-forwarded-rich",
382
- source: "tool_call",
383
- surface: "bash",
384
- value: "git push",
385
- agentName: "Explore",
386
- message: expect.stringContaining("Allow git push?"),
387
- forwarding: {
388
- requesterAgentName: "Explore",
389
- requesterSessionId: "child-session",
390
- },
391
- }),
392
- );
393
- expect(requestPermissionDecisionFromUi).toHaveBeenCalled();
394
- } finally {
395
- rmSync(root, { recursive: true, force: true });
396
- }
397
- });
398
-
399
- test("does not emit a UI prompt event when forwarded permission auto-approves", async () => {
400
- const root = mkdtempSync(join(tmpdir(), "permission-forwarding-"));
401
- try {
402
- const forwardingDir = join(root, "forwarding");
403
- const location = createPermissionForwardingLocation(
404
- forwardingDir,
405
- "parent-session",
406
- );
407
- mkdirSync(location.requestsDir, { recursive: true });
408
- mkdirSync(location.responsesDir, { recursive: true });
409
- writeFileSync(
410
- join(location.requestsDir, "req-forwarded-auto.json"),
411
- JSON.stringify({
412
- id: "req-forwarded-auto",
413
- createdAt: Date.now(),
414
- requesterSessionId: "child-session",
415
- targetSessionId: "parent-session",
416
- requesterAgentName: "Explore",
417
- message: "Allow git push?",
418
- }),
419
- "utf-8",
420
- );
421
-
422
- const events = {
423
- emit: vi.fn(),
424
- on: vi.fn().mockReturnValue(() => undefined),
425
- };
426
- const requestPermissionDecisionFromUi = vi.fn();
427
-
428
- await processForwardedPermissionRequests(
429
- {
430
- hasUI: true,
431
- ui: { select: vi.fn(), input: vi.fn() },
432
- sessionManager: {
433
- getSessionId: vi.fn().mockReturnValue("parent-session"),
434
- },
435
- } as unknown as ExtensionContext,
436
- {
437
- forwardingDir,
438
- subagentSessionsDir: join(root, "subagents"),
439
- events,
440
- logger: { writeReviewLog: vi.fn(), writeDebugLog: vi.fn() },
441
- writeReviewLog: vi.fn(),
442
- requestPermissionDecisionFromUi,
443
- shouldAutoApprove: () => true,
444
- },
445
- );
446
-
447
- expect(events.emit).not.toHaveBeenCalledWith(
448
- "permissions:ui_prompt",
449
- expect.anything(),
450
- );
451
- expect(requestPermissionDecisionFromUi).not.toHaveBeenCalled();
452
- } finally {
453
- rmSync(root, { recursive: true, force: true });
454
- }
455
- });
456
- });
457
-
458
- describe("confirmPermission", () => {
459
- test("shows the UI dialog but does not emit a UI prompt event (the prompter does)", async () => {
460
- const events = {
461
- emit: vi.fn(),
462
- on: vi.fn().mockReturnValue(() => undefined),
463
- };
464
- const requestPermissionDecisionFromUi = vi
465
- .fn()
466
- .mockResolvedValue({ approved: true, state: "approved" as const });
467
-
468
- await confirmPermission(
469
- {
470
- hasUI: true,
471
- ui: { select: vi.fn(), input: vi.fn() },
472
- } as unknown as ExtensionContext,
473
- "Allow git push?",
474
- {
475
- forwardingDir: "/tmp/forwarding",
476
- subagentSessionsDir: "/tmp/subagents",
477
- events,
478
- logger: { writeReviewLog: vi.fn(), writeDebugLog: vi.fn() },
479
- writeReviewLog: vi.fn(),
480
- requestPermissionDecisionFromUi,
481
- shouldAutoApprove: () => false,
482
- },
483
- );
484
-
485
- expect(requestPermissionDecisionFromUi).toHaveBeenCalled();
486
- expect(events.emit).not.toHaveBeenCalledWith(
487
- "permissions:ui_prompt",
488
- expect.anything(),
489
- );
490
- });
491
-
492
- test("does not show a dialog or emit when there is no active UI", async () => {
493
- const events = {
494
- emit: vi.fn(),
495
- on: vi.fn().mockReturnValue(() => undefined),
496
- };
497
- const requestPermissionDecisionFromUi = vi.fn();
498
-
499
- await confirmPermission(
500
- {
501
- hasUI: false,
502
- sessionManager: {
503
- getSessionDir: vi.fn().mockReturnValue(null),
504
- },
505
- } as unknown as ExtensionContext,
506
- "Allow git push?",
507
- {
508
- forwardingDir: "/tmp/forwarding",
509
- subagentSessionsDir: "/tmp/subagents",
510
- events,
511
- logger: { writeReviewLog: vi.fn(), writeDebugLog: vi.fn() },
512
- writeReviewLog: vi.fn(),
513
- requestPermissionDecisionFromUi,
514
- shouldAutoApprove: () => false,
515
- },
516
- );
517
-
518
- expect(events.emit).not.toHaveBeenCalledWith(
519
- "permissions:ui_prompt",
520
- expect.anything(),
521
- );
522
- expect(requestPermissionDecisionFromUi).not.toHaveBeenCalled();
523
- });
524
- });
@@ -8,7 +8,11 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
8
8
  import { homedir, tmpdir } from "node:os";
9
9
  import { join } from "node:path";
10
10
  import { describe, expect, it } from "vitest";
11
- import { PermissionManager } from "#src/permission-manager";
11
+ import { getGlobalConfigPath, getProjectConfigPath } from "#src/config-paths";
12
+ import {
13
+ PermissionManager,
14
+ type ScopedPermissionManager,
15
+ } from "#src/permission-manager";
12
16
  import type { Rule, Ruleset } from "#src/rule";
13
17
 
14
18
  // ---------------------------------------------------------------------------
@@ -1349,3 +1353,157 @@ describe("cross-cutting path surface", () => {
1349
1353
  }
1350
1354
  });
1351
1355
  });
1356
+
1357
+ // ---------------------------------------------------------------------------
1358
+ // configureForCwd and agentDir construction
1359
+ // ---------------------------------------------------------------------------
1360
+
1361
+ describe("PermissionManager — configureForCwd and agentDir option", () => {
1362
+ /**
1363
+ * Build a temp agentDir with a global config and an optional cwd with a
1364
+ * project config. Returns the paths and a cleanup function.
1365
+ */
1366
+ function makeAgentDirSetup(opts: {
1367
+ globalPermission: Record<string, unknown>;
1368
+ projectPermission?: Record<string, unknown>;
1369
+ }): {
1370
+ agentDir: string;
1371
+ cwd: string;
1372
+ globalConfigPath: string;
1373
+ projectConfigPath: string;
1374
+ cleanup: () => void;
1375
+ } {
1376
+ const baseDir = mkdtempSync(join(tmpdir(), "pm-agent-dir-test-"));
1377
+ const agentDir = join(baseDir, "agent");
1378
+ const cwd = join(baseDir, "project");
1379
+
1380
+ // Write global config under getGlobalConfigPath(agentDir)
1381
+ const globalConfigPath = getGlobalConfigPath(agentDir);
1382
+ mkdirSync(join(agentDir, "extensions", "pi-permission-system"), {
1383
+ recursive: true,
1384
+ });
1385
+ writeFileSync(
1386
+ globalConfigPath,
1387
+ JSON.stringify({ permission: opts.globalPermission }, null, 2),
1388
+ );
1389
+
1390
+ // Write project config under getProjectConfigPath(cwd)
1391
+ const projectConfigPath = getProjectConfigPath(cwd);
1392
+ mkdirSync(join(cwd, ".pi", "extensions", "pi-permission-system"), {
1393
+ recursive: true,
1394
+ });
1395
+ if (opts.projectPermission) {
1396
+ writeFileSync(
1397
+ projectConfigPath,
1398
+ JSON.stringify({ permission: opts.projectPermission }, null, 2),
1399
+ );
1400
+ }
1401
+
1402
+ return {
1403
+ agentDir,
1404
+ cwd,
1405
+ globalConfigPath,
1406
+ projectConfigPath,
1407
+ cleanup: () => rmSync(baseDir, { recursive: true, force: true }),
1408
+ };
1409
+ }
1410
+
1411
+ it("ScopedPermissionManager is exported and PermissionManager satisfies it", () => {
1412
+ // Type-level assertion: assigning PermissionManager to ScopedPermissionManager compiles.
1413
+ const manager = new PermissionManager({
1414
+ globalConfigPath: "/nonexistent/config.json",
1415
+ agentsDir: "/nonexistent/agents",
1416
+ });
1417
+ const scoped: ScopedPermissionManager = manager;
1418
+ expect(typeof scoped.configureForCwd).toBe("function");
1419
+ expect(typeof scoped.checkPermission).toBe("function");
1420
+ expect(typeof scoped.getToolPermission).toBe("function");
1421
+ expect(typeof scoped.getConfigIssues).toBe("function");
1422
+ expect(typeof scoped.getPolicyCacheStamp).toBe("function");
1423
+ });
1424
+
1425
+ it("construction with { agentDir } reads global config from getGlobalConfigPath(agentDir)", () => {
1426
+ const { agentDir, cleanup } = makeAgentDirSetup({
1427
+ globalPermission: { read: "deny" },
1428
+ });
1429
+ try {
1430
+ const manager = new PermissionManager({ agentDir });
1431
+ const result = manager.checkPermission("read", { path: "foo.txt" });
1432
+ expect(result.state).toBe("deny");
1433
+ } finally {
1434
+ cleanup();
1435
+ }
1436
+ });
1437
+
1438
+ it("configureForCwd(cwd) applies project config (project overrides global)", () => {
1439
+ const { agentDir, cwd, cleanup } = makeAgentDirSetup({
1440
+ globalPermission: { read: "deny" },
1441
+ projectPermission: { read: "allow" },
1442
+ });
1443
+ try {
1444
+ const manager = new PermissionManager({ agentDir });
1445
+ // Before configureForCwd: global policy applies
1446
+ expect(manager.checkPermission("read", { path: "foo.txt" }).state).toBe(
1447
+ "deny",
1448
+ );
1449
+
1450
+ manager.configureForCwd(cwd);
1451
+
1452
+ // After configureForCwd: project override applies (last-match-wins)
1453
+ expect(manager.checkPermission("read", { path: "foo.txt" }).state).toBe(
1454
+ "allow",
1455
+ );
1456
+ } finally {
1457
+ cleanup();
1458
+ }
1459
+ });
1460
+
1461
+ it("configureForCwd(undefined) reverts to global-only", () => {
1462
+ const { agentDir, cwd, cleanup } = makeAgentDirSetup({
1463
+ globalPermission: { read: "deny" },
1464
+ projectPermission: { read: "allow" },
1465
+ });
1466
+ try {
1467
+ const manager = new PermissionManager({ agentDir });
1468
+ manager.configureForCwd(cwd);
1469
+ expect(manager.checkPermission("read", { path: "foo.txt" }).state).toBe(
1470
+ "allow",
1471
+ );
1472
+
1473
+ manager.configureForCwd(undefined);
1474
+
1475
+ // After reverting: global policy applies again
1476
+ expect(manager.checkPermission("read", { path: "foo.txt" }).state).toBe(
1477
+ "deny",
1478
+ );
1479
+ } finally {
1480
+ cleanup();
1481
+ }
1482
+ });
1483
+
1484
+ it("configureForCwd clears the resolved-permissions cache", () => {
1485
+ const { agentDir, globalConfigPath, cleanup } = makeAgentDirSetup({
1486
+ globalPermission: { read: "allow" },
1487
+ });
1488
+ try {
1489
+ const manager = new PermissionManager({ agentDir });
1490
+ // Warm the cache
1491
+ expect(manager.checkPermission("read", { path: "foo.txt" }).state).toBe(
1492
+ "allow",
1493
+ );
1494
+ // Update global config on disk to deny read
1495
+ writeFileSync(
1496
+ globalConfigPath,
1497
+ JSON.stringify({ permission: { read: "deny" } }, null, 2),
1498
+ );
1499
+ // configureForCwd clears cache + rebuilds loader
1500
+ manager.configureForCwd(undefined);
1501
+ // Should pick up the changed global config
1502
+ expect(manager.checkPermission("read", { path: "foo.txt" }).state).toBe(
1503
+ "deny",
1504
+ );
1505
+ } finally {
1506
+ cleanup();
1507
+ }
1508
+ });
1509
+ });