@gotgenes/pi-permission-system 4.8.0 → 5.0.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.
@@ -6,27 +6,34 @@ describe("normalizeFlatConfig", () => {
6
6
  test("string value produces a single catch-all rule for the surface", () => {
7
7
  const result = normalizeFlatConfig({ read: "allow" });
8
8
  expect(result).toEqual([
9
- { surface: "read", pattern: "*", action: "allow" },
9
+ { surface: "read", pattern: "*", action: "allow", origin: "builtin" },
10
10
  ]);
11
11
  });
12
12
 
13
13
  test("string shorthand works for multiple surfaces", () => {
14
14
  const result = normalizeFlatConfig({ read: "allow", write: "deny" });
15
15
  expect(result).toEqual([
16
- { surface: "read", pattern: "*", action: "allow" },
17
- { surface: "write", pattern: "*", action: "deny" },
16
+ { surface: "read", pattern: "*", action: "allow", origin: "builtin" },
17
+ { surface: "write", pattern: "*", action: "deny", origin: "builtin" },
18
18
  ]);
19
19
  });
20
20
 
21
21
  test("universal fallback '*' becomes a catch-all rule with surface '*'", () => {
22
22
  const result = normalizeFlatConfig({ "*": "ask" });
23
- expect(result).toEqual([{ surface: "*", pattern: "*", action: "ask" }]);
23
+ expect(result).toEqual([
24
+ { surface: "*", pattern: "*", action: "ask", origin: "builtin" },
25
+ ]);
24
26
  });
25
27
 
26
28
  test("external_directory string shorthand maps directly to its surface", () => {
27
29
  const result = normalizeFlatConfig({ external_directory: "ask" });
28
30
  expect(result).toEqual([
29
- { surface: "external_directory", pattern: "*", action: "ask" },
31
+ {
32
+ surface: "external_directory",
33
+ pattern: "*",
34
+ action: "ask",
35
+ origin: "builtin",
36
+ },
30
37
  ]);
31
38
  });
32
39
 
@@ -36,7 +43,7 @@ describe("normalizeFlatConfig", () => {
36
43
  write: "invalid" as never,
37
44
  });
38
45
  expect(result).toEqual([
39
- { surface: "read", pattern: "*", action: "allow" },
46
+ { surface: "read", pattern: "*", action: "allow", origin: "builtin" },
40
47
  ]);
41
48
  });
42
49
  });
@@ -47,8 +54,13 @@ describe("normalizeFlatConfig", () => {
47
54
  bash: { "*": "ask", "git *": "allow" },
48
55
  });
49
56
  expect(result).toEqual([
50
- { surface: "bash", pattern: "*", action: "ask" },
51
- { surface: "bash", pattern: "git *", action: "allow" },
57
+ { surface: "bash", pattern: "*", action: "ask", origin: "builtin" },
58
+ {
59
+ surface: "bash",
60
+ pattern: "git *",
61
+ action: "allow",
62
+ origin: "builtin",
63
+ },
52
64
  ]);
53
65
  });
54
66
 
@@ -57,8 +69,13 @@ describe("normalizeFlatConfig", () => {
57
69
  mcp: { "*": "ask", mcp_status: "allow" },
58
70
  });
59
71
  expect(result).toEqual([
60
- { surface: "mcp", pattern: "*", action: "ask" },
61
- { surface: "mcp", pattern: "mcp_status", action: "allow" },
72
+ { surface: "mcp", pattern: "*", action: "ask", origin: "builtin" },
73
+ {
74
+ surface: "mcp",
75
+ pattern: "mcp_status",
76
+ action: "allow",
77
+ origin: "builtin",
78
+ },
62
79
  ]);
63
80
  });
64
81
 
@@ -67,8 +84,13 @@ describe("normalizeFlatConfig", () => {
67
84
  skill: { "*": "ask", librarian: "allow" },
68
85
  });
69
86
  expect(result).toEqual([
70
- { surface: "skill", pattern: "*", action: "ask" },
71
- { surface: "skill", pattern: "librarian", action: "allow" },
87
+ { surface: "skill", pattern: "*", action: "ask", origin: "builtin" },
88
+ {
89
+ surface: "skill",
90
+ pattern: "librarian",
91
+ action: "allow",
92
+ origin: "builtin",
93
+ },
72
94
  ]);
73
95
  });
74
96
 
@@ -77,7 +99,12 @@ describe("normalizeFlatConfig", () => {
77
99
  bash: { "git *": "allow", "rm -rf *": "bad" as never },
78
100
  });
79
101
  expect(result).toEqual([
80
- { surface: "bash", pattern: "git *", action: "allow" },
102
+ {
103
+ surface: "bash",
104
+ pattern: "git *",
105
+ action: "allow",
106
+ origin: "builtin",
107
+ },
81
108
  ]);
82
109
  });
83
110
  });
@@ -94,14 +121,29 @@ describe("normalizeFlatConfig", () => {
94
121
  external_directory: "ask",
95
122
  });
96
123
  expect(result).toEqual([
97
- { surface: "*", pattern: "*", action: "ask" },
98
- { surface: "read", pattern: "*", action: "allow" },
99
- { surface: "write", pattern: "*", action: "deny" },
100
- { surface: "bash", pattern: "*", action: "ask" },
101
- { surface: "bash", pattern: "git *", action: "allow" },
102
- { surface: "mcp", pattern: "mcp_status", action: "allow" },
103
- { surface: "skill", pattern: "*", action: "ask" },
104
- { surface: "external_directory", pattern: "*", action: "ask" },
124
+ { surface: "*", pattern: "*", action: "ask", origin: "builtin" },
125
+ { surface: "read", pattern: "*", action: "allow", origin: "builtin" },
126
+ { surface: "write", pattern: "*", action: "deny", origin: "builtin" },
127
+ { surface: "bash", pattern: "*", action: "ask", origin: "builtin" },
128
+ {
129
+ surface: "bash",
130
+ pattern: "git *",
131
+ action: "allow",
132
+ origin: "builtin",
133
+ },
134
+ {
135
+ surface: "mcp",
136
+ pattern: "mcp_status",
137
+ action: "allow",
138
+ origin: "builtin",
139
+ },
140
+ { surface: "skill", pattern: "*", action: "ask", origin: "builtin" },
141
+ {
142
+ surface: "external_directory",
143
+ pattern: "*",
144
+ action: "ask",
145
+ origin: "builtin",
146
+ },
105
147
  ]);
106
148
  });
107
149
  });
@@ -117,7 +159,7 @@ describe("normalizeFlatConfig", () => {
117
159
  read: "allow",
118
160
  });
119
161
  expect(result).toEqual([
120
- { surface: "read", pattern: "*", action: "allow" },
162
+ { surface: "read", pattern: "*", action: "allow", origin: "builtin" },
121
163
  ]);
122
164
  });
123
165
  });
@@ -55,6 +55,7 @@ const sessionAllow = (surface: string, pattern: string): Rule => ({
55
55
  pattern,
56
56
  action: "allow",
57
57
  layer: "session",
58
+ origin: "session",
58
59
  });
59
60
 
60
61
  // ---------------------------------------------------------------------------
@@ -446,3 +447,217 @@ describe("checkPermission — home path expansion in external_directory rules",
446
447
  }
447
448
  });
448
449
  });
450
+
451
+ // ---------------------------------------------------------------------------
452
+ // Rule origin provenance
453
+ // ---------------------------------------------------------------------------
454
+
455
+ /**
456
+ * Build a manager with a global config and an optional project config.
457
+ * Returns the manager and a cleanup function.
458
+ */
459
+ function makeManagerWithScopes(
460
+ globalPermission: Record<string, unknown>,
461
+ projectPermission?: Record<string, unknown>,
462
+ ): { manager: PermissionManager; cleanup: () => void } {
463
+ const baseDir = mkdtempSync(join(tmpdir(), "pm-provenance-test-"));
464
+ const agentsDir = join(baseDir, "agents");
465
+ mkdirSync(agentsDir, { recursive: true });
466
+ const globalConfigPath = join(baseDir, "global-config.json");
467
+ writeFileSync(
468
+ globalConfigPath,
469
+ JSON.stringify({ permission: globalPermission }, null, 2),
470
+ );
471
+
472
+ let projectGlobalConfigPath: string | undefined;
473
+ if (projectPermission !== undefined) {
474
+ projectGlobalConfigPath = join(baseDir, "project-config.json");
475
+ writeFileSync(
476
+ projectGlobalConfigPath,
477
+ JSON.stringify({ permission: projectPermission }, null, 2),
478
+ );
479
+ }
480
+
481
+ const manager = new PermissionManager({
482
+ globalConfigPath,
483
+ agentsDir,
484
+ projectGlobalConfigPath,
485
+ });
486
+ return {
487
+ manager,
488
+ cleanup: () => rmSync(baseDir, { recursive: true, force: true }),
489
+ };
490
+ }
491
+
492
+ describe("checkPermission — rule origin provenance", () => {
493
+ it("single-scope global: config rule has origin 'global'", () => {
494
+ const { manager, cleanup } = makeManagerWithScopes({ read: "allow" });
495
+ try {
496
+ const result = manager.checkPermission("read", {});
497
+ expect(result.state).toBe("allow");
498
+ expect(result.origin).toBe("global");
499
+ } finally {
500
+ cleanup();
501
+ }
502
+ });
503
+
504
+ it("single-scope global with pattern map: origin is 'global'", () => {
505
+ const { manager, cleanup } = makeManagerWithScopes({
506
+ bash: { "git *": "allow" },
507
+ });
508
+ try {
509
+ const result = manager.checkPermission("bash", { command: "git status" });
510
+ expect(result.state).toBe("allow");
511
+ expect(result.origin).toBe("global");
512
+ } finally {
513
+ cleanup();
514
+ }
515
+ });
516
+
517
+ it("project overrides global: winning rule has origin 'project'", () => {
518
+ const { manager, cleanup } = makeManagerWithScopes(
519
+ { read: "ask" },
520
+ { read: "allow" },
521
+ );
522
+ try {
523
+ const result = manager.checkPermission("read", {});
524
+ expect(result.state).toBe("allow");
525
+ expect(result.origin).toBe("project");
526
+ } finally {
527
+ cleanup();
528
+ }
529
+ });
530
+
531
+ it("both-object merge: patterns retain their own origins", () => {
532
+ // global defines bash["git *"] = allow; project adds bash["rm *"] = deny.
533
+ // Both patterns should survive with their own origins.
534
+ const { manager, cleanup } = makeManagerWithScopes(
535
+ { bash: { "git *": "allow" } },
536
+ { bash: { "rm *": "deny" } },
537
+ );
538
+ try {
539
+ const gitResult = manager.checkPermission("bash", {
540
+ command: "git status",
541
+ });
542
+ expect(gitResult.state).toBe("allow");
543
+ expect(gitResult.origin).toBe("global");
544
+
545
+ const rmResult = manager.checkPermission("bash", {
546
+ command: "rm -rf /",
547
+ });
548
+ expect(rmResult.state).toBe("deny");
549
+ expect(rmResult.origin).toBe("project");
550
+ } finally {
551
+ cleanup();
552
+ }
553
+ });
554
+
555
+ it("both-object merge: project pattern overrides global pattern for same key", () => {
556
+ // Both scopes define bash["git *"]; project wins for that pattern.
557
+ const { manager, cleanup } = makeManagerWithScopes(
558
+ { bash: { "git *": "ask" } },
559
+ { bash: { "git *": "allow" } },
560
+ );
561
+ try {
562
+ const result = manager.checkPermission("bash", {
563
+ command: "git status",
564
+ });
565
+ expect(result.state).toBe("allow");
566
+ expect(result.origin).toBe("project");
567
+ } finally {
568
+ cleanup();
569
+ }
570
+ });
571
+
572
+ it("string replaces object: all patterns from replacing scope get origin 'project'", () => {
573
+ // global defines bash as an object; project replaces with string "allow".
574
+ const { manager, cleanup } = makeManagerWithScopes(
575
+ { bash: { "git *": "ask", "npm *": "ask" } },
576
+ { bash: "allow" },
577
+ );
578
+ try {
579
+ // The catch-all "*" now comes from the project scope.
580
+ const result = manager.checkPermission("bash", {
581
+ command: "anything",
582
+ });
583
+ expect(result.state).toBe("allow");
584
+ expect(result.origin).toBe("project");
585
+ } finally {
586
+ cleanup();
587
+ }
588
+ });
589
+
590
+ it("object replaces string: all patterns from replacing scope get origin 'project'", () => {
591
+ // global defines read as a string "ask"; project replaces with object.
592
+ const { manager, cleanup } = makeManagerWithScopes(
593
+ { read: "ask" },
594
+ { read: { "*": "allow" } },
595
+ );
596
+ try {
597
+ const result = manager.checkPermission("read", {});
598
+ expect(result.state).toBe("allow");
599
+ expect(result.origin).toBe("project");
600
+ } finally {
601
+ cleanup();
602
+ }
603
+ });
604
+
605
+ it("no config match: origin is 'builtin' (default layer)", () => {
606
+ // No config — falls back to synthesized default.
607
+ const manager = makeManager();
608
+ const result = manager.checkPermission("read", {});
609
+ expect(result.state).toBe("ask");
610
+ expect(result.origin).toBe("builtin");
611
+ });
612
+
613
+ it("session rule: origin is 'session'", () => {
614
+ const manager = makeManager();
615
+ const sessionRules: Ruleset = [
616
+ {
617
+ surface: "read",
618
+ pattern: "*",
619
+ action: "allow",
620
+ layer: "session",
621
+ origin: "session",
622
+ },
623
+ ];
624
+ const result = manager.checkPermission("read", {}, undefined, sessionRules);
625
+ expect(result.state).toBe("allow");
626
+ expect(result.source).toBe("session");
627
+ expect(result.origin).toBe("session");
628
+ });
629
+
630
+ it("universal fallback (*) set in global config carries origin 'global'", () => {
631
+ const { manager, cleanup } = makeManagerWithScopes({ "*": "allow" });
632
+ try {
633
+ // No explicit surface rule — hits the synthesized default derived from "*".
634
+ const result = manager.checkPermission("read", {});
635
+ expect(result.state).toBe("allow");
636
+ expect(result.origin).toBe("global");
637
+ } finally {
638
+ cleanup();
639
+ }
640
+ });
641
+
642
+ it("universal fallback (*) overridden by project carries origin 'project'", () => {
643
+ const { manager, cleanup } = makeManagerWithScopes(
644
+ { "*": "ask" },
645
+ { "*": "allow" },
646
+ );
647
+ try {
648
+ const result = manager.checkPermission("read", {});
649
+ expect(result.state).toBe("allow");
650
+ expect(result.origin).toBe("project");
651
+ } finally {
652
+ cleanup();
653
+ }
654
+ });
655
+
656
+ it("built-in fallback (no * in any config): origin is 'builtin'", () => {
657
+ // Manager with no config file — built-in "ask" default.
658
+ const manager = makeManager();
659
+ const result = manager.checkPermission("read", {});
660
+ expect(result.state).toBe("ask");
661
+ expect(result.origin).toBe("builtin");
662
+ });
663
+ });
@@ -34,7 +34,13 @@ function toolResult(
34
34
  toolName: string,
35
35
  overrides: Partial<PermissionCheckResult> = {},
36
36
  ): PermissionCheckResult {
37
- return { toolName, state: "ask", source: "tool", ...overrides };
37
+ return {
38
+ toolName,
39
+ state: "ask",
40
+ source: "tool",
41
+ origin: "builtin",
42
+ ...overrides,
43
+ };
38
44
  }
39
45
 
40
46
  function mcpResult(
@@ -46,6 +52,7 @@ function mcpResult(
46
52
  target,
47
53
  state: "ask",
48
54
  source: "tool",
55
+ origin: "builtin",
49
56
  ...overrides,
50
57
  };
51
58
  }
@@ -2477,6 +2477,7 @@ test("checkPermission returns source 'session' when session rules cover the exte
2477
2477
  pattern: "/other/project/*",
2478
2478
  action: "allow" as const,
2479
2479
  layer: "session" as const,
2480
+ origin: "session" as const,
2480
2481
  },
2481
2482
  ];
2482
2483
 
@@ -2506,6 +2507,7 @@ test("checkPermission falls back to config policy when session rules do not cove
2506
2507
  pattern: "/other/project/*",
2507
2508
  action: "allow" as const,
2508
2509
  layer: "session" as const,
2510
+ origin: "session" as const,
2509
2511
  },
2510
2512
  ];
2511
2513
 
@@ -2543,6 +2545,7 @@ test("checkPermission with empty session rules is identical to call without sess
2543
2545
  state: "deny",
2544
2546
  matchedPattern: "*",
2545
2547
  source: "special",
2548
+ origin: "global",
2546
2549
  };
2547
2550
  assert.deepEqual(withEmpty, expected);
2548
2551
  assert.deepEqual(withoutArg, expected);
@@ -2564,6 +2567,7 @@ test("session rules for one surface do not affect checks on other surfaces", ()
2564
2567
  pattern: "/other/project/*",
2565
2568
  action: "allow" as const,
2566
2569
  layer: "session" as const,
2570
+ origin: "session" as const,
2567
2571
  },
2568
2572
  ];
2569
2573
 
@@ -2603,6 +2607,7 @@ test("session rules override config deny for external_directory", () => {
2603
2607
  pattern: "/other/project/*",
2604
2608
  action: "allow" as const,
2605
2609
  layer: "session" as const,
2610
+ origin: "session" as const,
2606
2611
  },
2607
2612
  ];
2608
2613
 
@@ -2632,6 +2637,7 @@ test("checkPermission returns source 'session' for bash when session rules match
2632
2637
  pattern: "git *",
2633
2638
  action: "allow" as const,
2634
2639
  layer: "session" as const,
2640
+ origin: "session" as const,
2635
2641
  },
2636
2642
  ];
2637
2643
 
@@ -2659,6 +2665,7 @@ test("checkPermission returns source 'session' for bash when session rule is exa
2659
2665
  pattern: "ls",
2660
2666
  action: "allow" as const,
2661
2667
  layer: "session" as const,
2668
+ origin: "session" as const,
2662
2669
  },
2663
2670
  ];
2664
2671
 
@@ -2685,6 +2692,7 @@ test("checkPermission falls back to config for bash when session rules do not ma
2685
2692
  pattern: "git *",
2686
2693
  action: "allow" as const,
2687
2694
  layer: "session" as const,
2695
+ origin: "session" as const,
2688
2696
  },
2689
2697
  ];
2690
2698
 
@@ -2711,6 +2719,7 @@ test("checkPermission returns source 'session' for mcp when session rules match
2711
2719
  pattern: "exa:*",
2712
2720
  action: "allow" as const,
2713
2721
  layer: "session" as const,
2722
+ origin: "session" as const,
2714
2723
  },
2715
2724
  ];
2716
2725
 
@@ -2737,6 +2746,7 @@ test("checkPermission returns source 'session' for skill when session rules matc
2737
2746
  pattern: "librarian",
2738
2747
  action: "allow" as const,
2739
2748
  layer: "session" as const,
2749
+ origin: "session" as const,
2740
2750
  },
2741
2751
  ];
2742
2752
 
@@ -2764,6 +2774,7 @@ test("checkPermission returns source 'session' for tool surface when session rul
2764
2774
  pattern: "*",
2765
2775
  action: "allow" as const,
2766
2776
  layer: "session" as const,
2777
+ origin: "session" as const,
2767
2778
  },
2768
2779
  ];
2769
2780
 
@@ -2785,6 +2796,7 @@ test("bash session rules do not bleed into mcp checks", () => {
2785
2796
  pattern: "git *",
2786
2797
  action: "allow" as const,
2787
2798
  layer: "session" as const,
2799
+ origin: "session" as const,
2788
2800
  },
2789
2801
  ];
2790
2802