@gotgenes/pi-permission-system 3.7.0 → 3.9.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,121 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { normalizeConfig } from "../src/normalize";
3
+
4
+ describe("normalizeConfig", () => {
5
+ describe("tools entries", () => {
6
+ test("converts tools entries to tool-name-as-surface rules", () => {
7
+ const result = normalizeConfig({
8
+ tools: { read: "allow", write: "deny" },
9
+ });
10
+ expect(result).toEqual([
11
+ { surface: "read", pattern: "*", action: "allow" },
12
+ { surface: "write", pattern: "*", action: "deny" },
13
+ ]);
14
+ });
15
+
16
+ test("tools.bash is excluded (handled as fallback override)", () => {
17
+ const result = normalizeConfig({
18
+ tools: { bash: "allow", read: "allow" },
19
+ });
20
+ expect(result).toEqual([
21
+ { surface: "read", pattern: "*", action: "allow" },
22
+ ]);
23
+ });
24
+
25
+ test("tools.mcp is excluded (handled as fallback override)", () => {
26
+ const result = normalizeConfig({
27
+ tools: { mcp: "ask", read: "allow" },
28
+ });
29
+ expect(result).toEqual([
30
+ { surface: "read", pattern: "*", action: "allow" },
31
+ ]);
32
+ });
33
+ });
34
+
35
+ describe("bash entries", () => {
36
+ test("converts bash entries to surface 'bash' rules", () => {
37
+ const result = normalizeConfig({
38
+ bash: { "git *": "allow", "rm -rf *": "deny" },
39
+ });
40
+ expect(result).toEqual([
41
+ { surface: "bash", pattern: "git *", action: "allow" },
42
+ { surface: "bash", pattern: "rm -rf *", action: "deny" },
43
+ ]);
44
+ });
45
+ });
46
+
47
+ describe("mcp entries", () => {
48
+ test("converts mcp entries to surface 'mcp' rules", () => {
49
+ const result = normalizeConfig({
50
+ mcp: { "exa:*": "allow", mcp_status: "allow" },
51
+ });
52
+ expect(result).toEqual([
53
+ { surface: "mcp", pattern: "exa:*", action: "allow" },
54
+ { surface: "mcp", pattern: "mcp_status", action: "allow" },
55
+ ]);
56
+ });
57
+ });
58
+
59
+ describe("skills entries", () => {
60
+ test("converts skills entries to surface 'skill' rules", () => {
61
+ const result = normalizeConfig({
62
+ skills: { "*": "ask", librarian: "allow" },
63
+ });
64
+ expect(result).toEqual([
65
+ { surface: "skill", pattern: "*", action: "ask" },
66
+ { surface: "skill", pattern: "librarian", action: "allow" },
67
+ ]);
68
+ });
69
+ });
70
+
71
+ describe("special entries", () => {
72
+ test("converts special entries to surface 'special' with key as pattern", () => {
73
+ const result = normalizeConfig({
74
+ special: { external_directory: "ask" },
75
+ });
76
+ expect(result).toEqual([
77
+ { surface: "special", pattern: "external_directory", action: "ask" },
78
+ ]);
79
+ });
80
+ });
81
+
82
+ describe("ordering", () => {
83
+ test("tools.bash excluded; bash entries come after tools", () => {
84
+ const result = normalizeConfig({
85
+ tools: { bash: "allow", read: "deny" },
86
+ bash: { "git *": "ask" },
87
+ });
88
+ expect(result).toEqual([
89
+ { surface: "read", pattern: "*", action: "deny" },
90
+ { surface: "bash", pattern: "git *", action: "ask" },
91
+ ]);
92
+ });
93
+
94
+ test("full ordering: tools → bash → mcp → skills → special", () => {
95
+ const result = normalizeConfig({
96
+ tools: { read: "allow" },
97
+ bash: { "git *": "allow" },
98
+ mcp: { "exa:*": "allow" },
99
+ skills: { librarian: "allow" },
100
+ special: { external_directory: "ask" },
101
+ });
102
+ expect(result).toEqual([
103
+ { surface: "read", pattern: "*", action: "allow" },
104
+ { surface: "bash", pattern: "git *", action: "allow" },
105
+ { surface: "mcp", pattern: "exa:*", action: "allow" },
106
+ { surface: "skill", pattern: "librarian", action: "allow" },
107
+ { surface: "special", pattern: "external_directory", action: "ask" },
108
+ ]);
109
+ });
110
+ });
111
+
112
+ describe("empty and missing sections", () => {
113
+ test("empty config produces empty ruleset", () => {
114
+ expect(normalizeConfig({})).toEqual([]);
115
+ });
116
+
117
+ test("undefined sections are skipped", () => {
118
+ expect(normalizeConfig({ tools: undefined })).toEqual([]);
119
+ });
120
+ });
121
+ });
@@ -10,7 +10,7 @@ import {
10
10
  import { tmpdir } from "node:os";
11
11
  import { dirname, join, resolve } from "node:path";
12
12
  import { test } from "vitest";
13
- import { BashFilter } from "../src/bash-filter";
13
+
14
14
  import {
15
15
  createActiveToolsCacheKey,
16
16
  createBeforeAgentStartPromptStateKey,
@@ -46,11 +46,7 @@ import {
46
46
  checkRequestedToolRegistration,
47
47
  getToolNameFromValue,
48
48
  } from "../src/tool-registry";
49
- import type {
50
- AgentPermissions,
51
- GlobalPermissionConfig,
52
- PermissionState,
53
- } from "../src/types";
49
+ import type { PermissionState, ScopeConfig } from "../src/types";
54
50
  import {
55
51
  canResolveAskPermissionRequest,
56
52
  shouldAutoApprovePermissionState,
@@ -61,7 +57,7 @@ type CreateManagerOptions = {
61
57
  };
62
58
 
63
59
  function createManager(
64
- config: GlobalPermissionConfig,
60
+ config: ScopeConfig,
65
61
  agentFiles: Record<string, string> = {},
66
62
  options: CreateManagerOptions = {},
67
63
  ) {
@@ -146,7 +142,7 @@ async function withIsolatedSubagentEnv<T>(
146
142
  }
147
143
 
148
144
  function createToolCallHarness(
149
- config: GlobalPermissionConfig,
145
+ config: ScopeConfig,
150
146
  toolNames: readonly string[],
151
147
  options: ExtensionHarnessOptions = {},
152
148
  ): ExtensionHarness {
@@ -652,30 +648,6 @@ test("Permission-system logger respects debug toggle and keeps review log enable
652
648
  }
653
649
  });
654
650
 
655
- test("BashFilter uses opencode-style last-match hierarchy", () => {
656
- const filter = new BashFilter(
657
- {
658
- "*": "ask",
659
- "git *": "deny",
660
- "git status *": "ask",
661
- "git status": "allow",
662
- },
663
- "deny",
664
- );
665
-
666
- const exact = filter.check("git status");
667
- assert.equal(exact.state, "allow");
668
- assert.equal(exact.matchedPattern, "git status");
669
-
670
- const subcommand = filter.check("git status --short");
671
- assert.equal(subcommand.state, "ask");
672
- assert.equal(subcommand.matchedPattern, "git status *");
673
-
674
- const generic = filter.check("git commit -m test");
675
- assert.equal(generic.state, "deny");
676
- assert.equal(generic.matchedPattern, "git *");
677
- });
678
-
679
651
  test("PermissionManager canonical built-in permission checking", () => {
680
652
  const { manager, cleanup } = createManager({
681
653
  defaultPolicy: {
@@ -910,7 +882,7 @@ test("MCP server names in settings.json are not used — only mcp.json is consul
910
882
  // which matches this rule and returns "allow".
911
883
  // After the fix, settings.json is ignored, so no server name is derived and the
912
884
  // result falls through to the default mcp policy ("ask").
913
- const config: GlobalPermissionConfig = {
885
+ const config: ScopeConfig = {
914
886
  defaultPolicy: {
915
887
  tools: "ask",
916
888
  bash: "ask",
@@ -1510,12 +1482,12 @@ test("Permission forwarding rejects unresolved sentinel session ids", () => {
1510
1482
  });
1511
1483
 
1512
1484
  type CreateManagerWithProjectOptions = CreateManagerOptions & {
1513
- projectConfig?: AgentPermissions;
1485
+ projectConfig?: ScopeConfig;
1514
1486
  projectAgentFiles?: Record<string, string>;
1515
1487
  };
1516
1488
 
1517
1489
  function createManagerWithProject(
1518
- config: GlobalPermissionConfig,
1490
+ config: ScopeConfig,
1519
1491
  agentFiles: Record<string, string> = {},
1520
1492
  options: CreateManagerWithProjectOptions = {},
1521
1493
  ) {
@@ -1807,7 +1779,7 @@ test("PermissionManager reads config from PI_CODING_AGENT_DIR when set", () => {
1807
1779
  mkdirSync(agentsDir, { recursive: true });
1808
1780
  mkdirSync(dirname(newConfigPath), { recursive: true });
1809
1781
 
1810
- const config: GlobalPermissionConfig = {
1782
+ const config: ScopeConfig = {
1811
1783
  defaultPolicy: {
1812
1784
  tools: "deny",
1813
1785
  bash: "deny",
@@ -2609,7 +2581,7 @@ test("normalizeRawPermission emits no issues when special is absent", () => {
2609
2581
  });
2610
2582
 
2611
2583
  test("PermissionManager.getConfigIssues returns deprecation for tool_call_limit in global config", () => {
2612
- const config: GlobalPermissionConfig = {
2584
+ const config: ScopeConfig = {
2613
2585
  defaultPolicy: {
2614
2586
  tools: "ask",
2615
2587
  bash: "ask",
@@ -2634,7 +2606,7 @@ test("PermissionManager.getConfigIssues returns deprecation for tool_call_limit
2634
2606
  });
2635
2607
 
2636
2608
  test("PermissionManager.getConfigIssues returns empty array for clean config", () => {
2637
- const config: GlobalPermissionConfig = {
2609
+ const config: ScopeConfig = {
2638
2610
  defaultPolicy: {
2639
2611
  tools: "ask",
2640
2612
  bash: "ask",
@@ -2660,7 +2632,7 @@ test("PermissionManager.getConfigIssues returns empty array for clean config", (
2660
2632
  // --- doom_loop config-loader deprecation tests (#54) ---
2661
2633
 
2662
2634
  test("PermissionManager.getConfigIssues returns deprecation for doom_loop in global config", () => {
2663
- const config: GlobalPermissionConfig = {
2635
+ const config: ScopeConfig = {
2664
2636
  defaultPolicy: {
2665
2637
  tools: "ask",
2666
2638
  bash: "ask",
@@ -1,38 +1,6 @@
1
- import { afterEach, describe, expect, test, vi } from "vitest";
1
+ import { describe, expect, test } from "vitest";
2
2
  import type { Rule, Ruleset } from "../src/rule";
3
- import { evaluate, getDefaultAction } from "../src/rule";
4
-
5
- afterEach(() => {
6
- vi.restoreAllMocks();
7
- });
8
-
9
- describe("getDefaultAction", () => {
10
- test("returns 'ask' for bash surface", () => {
11
- expect(getDefaultAction("bash")).toBe("ask");
12
- });
13
-
14
- test("returns 'ask' for mcp surface", () => {
15
- expect(getDefaultAction("mcp")).toBe("ask");
16
- });
17
-
18
- test("returns 'ask' for skill surface", () => {
19
- expect(getDefaultAction("skill")).toBe("ask");
20
- });
21
-
22
- test("returns 'ask' for special surface", () => {
23
- expect(getDefaultAction("special")).toBe("ask");
24
- });
25
-
26
- test("returns 'ask' for tools surface", () => {
27
- expect(getDefaultAction("tools")).toBe("ask");
28
- });
29
-
30
- test("returns 'ask' for unknown surface (least privilege)", () => {
31
- expect(getDefaultAction("unknown_surface")).toBe("ask");
32
- expect(getDefaultAction("")).toBe("ask");
33
- expect(getDefaultAction("external_directory")).toBe("ask");
34
- });
35
- });
3
+ import { evaluate } from "../src/rule";
36
4
 
37
5
  describe("evaluate", () => {
38
6
  const allowBashGit: Rule = {
@@ -64,11 +32,23 @@ describe("evaluate", () => {
64
32
  expect(result).toEqual(allowBashGit);
65
33
  });
66
34
 
67
- test("returns synthetic rule with default action when no rules match", () => {
35
+ test("returns synthetic rule with 'ask' when no rules match and no defaultAction", () => {
68
36
  const result = evaluate("bash", "npm install", [allowBashGit]);
69
37
  expect(result.surface).toBe("bash");
70
38
  expect(result.pattern).toBe("npm install");
71
- expect(result.action).toBe("ask"); // getDefaultAction("bash")
39
+ expect(result.action).toBe("ask");
40
+ });
41
+
42
+ test("returns synthetic rule with custom defaultAction when no rules match", () => {
43
+ const result = evaluate("bash", "npm install", [allowBashGit], "deny");
44
+ expect(result.surface).toBe("bash");
45
+ expect(result.pattern).toBe("npm install");
46
+ expect(result.action).toBe("deny");
47
+ });
48
+
49
+ test("defaultAction does not affect matched rules", () => {
50
+ const result = evaluate("bash", "git status", [allowBashGit], "deny");
51
+ expect(result).toEqual(allowBashGit);
72
52
  });
73
53
 
74
54
  test("returns synthetic rule for empty ruleset", () => {
@@ -126,18 +106,19 @@ describe("evaluate", () => {
126
106
  expect(result.action).toBe("ask"); // falls back to default
127
107
  });
128
108
 
129
- test("multiple rulesets: rules from later rulesets take priority", () => {
109
+ test("merged rulesets: rules from later scope take priority", () => {
130
110
  const globalRules: Ruleset = [
131
111
  { surface: "bash", pattern: "git *", action: "ask" },
132
112
  ];
133
113
  const agentRules: Ruleset = [
134
114
  { surface: "bash", pattern: "git *", action: "allow" },
135
115
  ];
136
- const result = evaluate("bash", "git status", globalRules, agentRules);
116
+ const merged = [...globalRules, ...agentRules];
117
+ const result = evaluate("bash", "git status", merged);
137
118
  expect(result.action).toBe("allow"); // agent rule wins
138
119
  });
139
120
 
140
- test("multiple rulesets: earlier rulesets used when later rulesets have no match", () => {
121
+ test("merged rulesets: earlier scope used when later scope has no match", () => {
141
122
  const globalRules: Ruleset = [
142
123
  { surface: "bash", pattern: "git *", action: "allow" },
143
124
  ];
@@ -145,12 +126,13 @@ describe("evaluate", () => {
145
126
  { surface: "bash", pattern: "npm *", action: "deny" },
146
127
  ];
147
128
  // git status matches global but not agent rule
148
- const result = evaluate("bash", "git status", globalRules, agentRules);
129
+ const merged = [...globalRules, ...agentRules];
130
+ const result = evaluate("bash", "git status", merged);
149
131
  expect(result.action).toBe("allow"); // global rule is the last match for this pattern
150
132
  });
151
133
 
152
- test("no rulesets at all returns synthetic default", () => {
153
- const result = evaluate("bash", "git status");
134
+ test("empty ruleset returns synthetic default", () => {
135
+ const result = evaluate("bash", "git status", []);
154
136
  expect(result.surface).toBe("bash");
155
137
  expect(result.pattern).toBe("git status");
156
138
  expect(result.action).toBe("ask");