@gotgenes/pi-permission-system 4.9.0 → 5.1.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.
- package/CHANGELOG.md +37 -0
- package/package.json +1 -1
- package/src/config-modal.ts +25 -3
- package/src/external-directory.ts +238 -14
- package/src/index.ts +4 -0
- package/src/normalize.ts +2 -2
- package/src/permission-manager.ts +72 -17
- package/src/rule.ts +26 -2
- package/src/session-rules.ts +7 -1
- package/src/synthesize.ts +7 -2
- package/src/tool-input-preview.ts +7 -1
- package/src/types.ts +6 -0
- package/tests/bash-external-directory.test.ts +227 -0
- package/tests/config-modal.test.ts +83 -0
- package/tests/handlers/tool-call.test.ts +2 -1
- package/tests/normalize.test.ts +64 -22
- package/tests/permission-manager-unified.test.ts +215 -0
- package/tests/permission-prompts.test.ts +8 -1
- package/tests/permission-system.test.ts +12 -0
- package/tests/rule.test.ts +76 -8
- package/tests/session-rules.test.ts +7 -1
- package/tests/skill-prompt-sanitizer.test.ts +1 -1
- package/tests/synthesize.test.ts +64 -4
- package/tests/tool-input-preview.test.ts +29 -0
|
@@ -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 {
|
|
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
|
|
package/tests/rule.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "vitest";
|
|
2
|
-
import type { Rule, Ruleset } from "../src/rule";
|
|
2
|
+
import type { Rule, RuleOrigin, Ruleset } from "../src/rule";
|
|
3
3
|
import { evaluate, evaluateFirst } from "../src/rule";
|
|
4
4
|
|
|
5
5
|
describe("evaluate", () => {
|
|
@@ -7,23 +7,37 @@ describe("evaluate", () => {
|
|
|
7
7
|
surface: "bash",
|
|
8
8
|
pattern: "git *",
|
|
9
9
|
action: "allow",
|
|
10
|
+
origin: "global",
|
|
10
11
|
};
|
|
11
12
|
const denyBashGitPush: Rule = {
|
|
12
13
|
surface: "bash",
|
|
13
14
|
pattern: "git push *",
|
|
14
15
|
action: "deny",
|
|
16
|
+
origin: "global",
|
|
17
|
+
};
|
|
18
|
+
const allowRead: Rule = {
|
|
19
|
+
surface: "read",
|
|
20
|
+
pattern: "*",
|
|
21
|
+
action: "allow",
|
|
22
|
+
origin: "global",
|
|
23
|
+
};
|
|
24
|
+
const askMcp: Rule = {
|
|
25
|
+
surface: "mcp",
|
|
26
|
+
pattern: "*",
|
|
27
|
+
action: "ask",
|
|
28
|
+
origin: "global",
|
|
15
29
|
};
|
|
16
|
-
const allowRead: Rule = { surface: "read", pattern: "*", action: "allow" };
|
|
17
|
-
const askMcp: Rule = { surface: "mcp", pattern: "*", action: "ask" };
|
|
18
30
|
const allowSkillLibrarian: Rule = {
|
|
19
31
|
surface: "skill",
|
|
20
32
|
pattern: "librarian",
|
|
21
33
|
action: "allow",
|
|
34
|
+
origin: "global",
|
|
22
35
|
};
|
|
23
36
|
const askSpecialExtDir: Rule = {
|
|
24
37
|
surface: "special",
|
|
25
38
|
pattern: "external_directory",
|
|
26
39
|
action: "ask",
|
|
40
|
+
origin: "global",
|
|
27
41
|
};
|
|
28
42
|
|
|
29
43
|
test("returns matching rule when a rule matches", () => {
|
|
@@ -76,11 +90,17 @@ describe("evaluate", () => {
|
|
|
76
90
|
});
|
|
77
91
|
|
|
78
92
|
test("last-match-wins: broad deny followed by specific allow", () => {
|
|
79
|
-
const denyAll: Rule = {
|
|
93
|
+
const denyAll: Rule = {
|
|
94
|
+
surface: "bash",
|
|
95
|
+
pattern: "*",
|
|
96
|
+
action: "deny",
|
|
97
|
+
origin: "global",
|
|
98
|
+
};
|
|
80
99
|
const allowStatus: Rule = {
|
|
81
100
|
surface: "bash",
|
|
82
101
|
pattern: "git status",
|
|
83
102
|
action: "allow",
|
|
103
|
+
origin: "global",
|
|
84
104
|
};
|
|
85
105
|
const result = evaluate("bash", "git status", [denyAll, allowStatus]);
|
|
86
106
|
expect(result).toEqual(allowStatus);
|
|
@@ -91,6 +111,7 @@ describe("evaluate", () => {
|
|
|
91
111
|
surface: "*",
|
|
92
112
|
pattern: "*",
|
|
93
113
|
action: "allow",
|
|
114
|
+
origin: "global",
|
|
94
115
|
};
|
|
95
116
|
expect(evaluate("bash", "anything", [universalAllow]).action).toBe("allow");
|
|
96
117
|
expect(evaluate("mcp", "something", [universalAllow]).action).toBe("allow");
|
|
@@ -108,10 +129,10 @@ describe("evaluate", () => {
|
|
|
108
129
|
|
|
109
130
|
test("merged rulesets: rules from later scope take priority", () => {
|
|
110
131
|
const globalRules: Ruleset = [
|
|
111
|
-
{ surface: "bash", pattern: "git *", action: "ask" },
|
|
132
|
+
{ surface: "bash", pattern: "git *", action: "ask", origin: "global" },
|
|
112
133
|
];
|
|
113
134
|
const agentRules: Ruleset = [
|
|
114
|
-
{ surface: "bash", pattern: "git *", action: "allow" },
|
|
135
|
+
{ surface: "bash", pattern: "git *", action: "allow", origin: "agent" },
|
|
115
136
|
];
|
|
116
137
|
const merged = [...globalRules, ...agentRules];
|
|
117
138
|
const result = evaluate("bash", "git status", merged);
|
|
@@ -120,10 +141,10 @@ describe("evaluate", () => {
|
|
|
120
141
|
|
|
121
142
|
test("merged rulesets: earlier scope used when later scope has no match", () => {
|
|
122
143
|
const globalRules: Ruleset = [
|
|
123
|
-
{ surface: "bash", pattern: "git *", action: "allow" },
|
|
144
|
+
{ surface: "bash", pattern: "git *", action: "allow", origin: "global" },
|
|
124
145
|
];
|
|
125
146
|
const agentRules: Ruleset = [
|
|
126
|
-
{ surface: "bash", pattern: "npm *", action: "deny" },
|
|
147
|
+
{ surface: "bash", pattern: "npm *", action: "deny", origin: "agent" },
|
|
127
148
|
];
|
|
128
149
|
// git status matches global but not agent rule
|
|
129
150
|
const merged = [...globalRules, ...agentRules];
|
|
@@ -144,17 +165,20 @@ describe("evaluate", () => {
|
|
|
144
165
|
pattern: "git *",
|
|
145
166
|
action: "allow",
|
|
146
167
|
layer: "config",
|
|
168
|
+
origin: "global",
|
|
147
169
|
};
|
|
148
170
|
const withoutLayer: Rule = {
|
|
149
171
|
surface: "bash",
|
|
150
172
|
pattern: "git *",
|
|
151
173
|
action: "allow",
|
|
174
|
+
origin: "global",
|
|
152
175
|
};
|
|
153
176
|
const withDefault: Rule = {
|
|
154
177
|
surface: "bash",
|
|
155
178
|
pattern: "*",
|
|
156
179
|
action: "ask",
|
|
157
180
|
layer: "default",
|
|
181
|
+
origin: "builtin",
|
|
158
182
|
};
|
|
159
183
|
// Both rules with and without layer field produce the same match.
|
|
160
184
|
expect(evaluate("bash", "git status", [withLayer]).action).toBe("allow");
|
|
@@ -168,6 +192,46 @@ describe("evaluate", () => {
|
|
|
168
192
|
withDefault,
|
|
169
193
|
);
|
|
170
194
|
});
|
|
195
|
+
|
|
196
|
+
test("evaluate() preserves origin on a matched rule", () => {
|
|
197
|
+
const origin: RuleOrigin = "project";
|
|
198
|
+
const rule: Rule = {
|
|
199
|
+
surface: "bash",
|
|
200
|
+
pattern: "git *",
|
|
201
|
+
action: "allow",
|
|
202
|
+
layer: "config",
|
|
203
|
+
origin,
|
|
204
|
+
};
|
|
205
|
+
const result = evaluate("bash", "git status", [rule]);
|
|
206
|
+
expect(result.origin).toBe("project");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("evaluate() synthetic fallback rule has origin 'builtin'", () => {
|
|
210
|
+
const result = evaluate("bash", "npm install", []);
|
|
211
|
+
expect(result.origin).toBe("builtin");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("RuleOrigin covers all seven provenance values", () => {
|
|
215
|
+
const origins: RuleOrigin[] = [
|
|
216
|
+
"global",
|
|
217
|
+
"project",
|
|
218
|
+
"agent",
|
|
219
|
+
"project-agent",
|
|
220
|
+
"builtin",
|
|
221
|
+
"baseline",
|
|
222
|
+
"session",
|
|
223
|
+
];
|
|
224
|
+
for (const origin of origins) {
|
|
225
|
+
const rule: Rule = {
|
|
226
|
+
surface: "read",
|
|
227
|
+
pattern: "*",
|
|
228
|
+
action: "allow",
|
|
229
|
+
layer: "config",
|
|
230
|
+
origin,
|
|
231
|
+
};
|
|
232
|
+
expect(evaluate("read", "*", [rule]).origin).toBe(origin);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
171
235
|
});
|
|
172
236
|
|
|
173
237
|
describe("evaluateFirst", () => {
|
|
@@ -176,18 +240,21 @@ describe("evaluateFirst", () => {
|
|
|
176
240
|
pattern: "*",
|
|
177
241
|
action: "ask",
|
|
178
242
|
layer: "default",
|
|
243
|
+
origin: "builtin",
|
|
179
244
|
};
|
|
180
245
|
const allowBash: Rule = {
|
|
181
246
|
surface: "bash",
|
|
182
247
|
pattern: "git *",
|
|
183
248
|
action: "allow",
|
|
184
249
|
layer: "config",
|
|
250
|
+
origin: "global",
|
|
185
251
|
};
|
|
186
252
|
const denyMcp: Rule = {
|
|
187
253
|
surface: "mcp",
|
|
188
254
|
pattern: "exa_search",
|
|
189
255
|
action: "deny",
|
|
190
256
|
layer: "config",
|
|
257
|
+
origin: "global",
|
|
191
258
|
};
|
|
192
259
|
|
|
193
260
|
test("returns the first candidate that matches a non-default rule", () => {
|
|
@@ -220,6 +287,7 @@ describe("evaluateFirst", () => {
|
|
|
220
287
|
pattern: "mcp",
|
|
221
288
|
action: "allow",
|
|
222
289
|
layer: "config",
|
|
290
|
+
origin: "global",
|
|
223
291
|
};
|
|
224
292
|
const rules: Ruleset = [defaultRule, denyMcp, allowMcpCatchAll];
|
|
225
293
|
const result = evaluateFirst("mcp", ["exa_search", "mcp"], rules);
|
|
@@ -21,6 +21,7 @@ describe("SessionRules", () => {
|
|
|
21
21
|
pattern: "/other/project/*",
|
|
22
22
|
action: "allow",
|
|
23
23
|
layer: "session",
|
|
24
|
+
origin: "session",
|
|
24
25
|
},
|
|
25
26
|
]);
|
|
26
27
|
});
|
|
@@ -29,7 +30,12 @@ describe("SessionRules", () => {
|
|
|
29
30
|
const rules = new SessionRules();
|
|
30
31
|
rules.approve("external_directory", "/other/project/*");
|
|
31
32
|
const copy = rules.getRuleset();
|
|
32
|
-
copy.push({
|
|
33
|
+
copy.push({
|
|
34
|
+
surface: "bash",
|
|
35
|
+
pattern: "*",
|
|
36
|
+
action: "deny",
|
|
37
|
+
origin: "session",
|
|
38
|
+
});
|
|
33
39
|
expect(rules.getRuleset()).toHaveLength(1);
|
|
34
40
|
});
|
|
35
41
|
|
|
@@ -23,7 +23,7 @@ function makeManager(
|
|
|
23
23
|
(_surface: string, input: { name?: string }): PermissionCheckResult => {
|
|
24
24
|
const name = input.name ?? "";
|
|
25
25
|
const state = overrides[name] ?? defaultState;
|
|
26
|
-
return { toolName: "skill", state, source: "tool" };
|
|
26
|
+
return { toolName: "skill", state, source: "tool", origin: "builtin" };
|
|
27
27
|
},
|
|
28
28
|
),
|
|
29
29
|
} as unknown as PermissionManager;
|