@gotgenes/pi-permission-system 3.5.0 → 3.6.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 CHANGED
@@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.6.0](https://github.com/gotgenes/pi-permission-system/compare/v3.5.0...v3.6.0) (2026-05-03)
9
+
10
+
11
+ ### Features
12
+
13
+ * add Rule, Ruleset, getDefaultAction, and evaluate() in src/rule.ts ([482e00a](https://github.com/gotgenes/pi-permission-system/commit/482e00a04289f46f14c4b94486fbd98232159d66))
14
+ * add wildcardMatch convenience function to wildcard-matcher ([fa65219](https://github.com/gotgenes/pi-permission-system/commit/fa6521954a71ab146f14b87dc0723434a6dfd5ae))
15
+
16
+
17
+ ### Bug Fixes
18
+
19
+ * replace findLast with manual backwards loop in evaluate() ([1911f37](https://github.com/gotgenes/pi-permission-system/commit/1911f37dd6074d926f959aeefa3d795b29d1681c))
20
+
21
+
22
+ ### Documentation
23
+
24
+ * mark [#55](https://github.com/gotgenes/pi-permission-system/issues/55) complete in target architecture refactoring sequence ([0c87289](https://github.com/gotgenes/pi-permission-system/commit/0c87289d053a7f8c33aaa21a515db28d097a1925))
25
+ * plan extract pure evaluate() function ([#55](https://github.com/gotgenes/pi-permission-system/issues/55)) ([fd11860](https://github.com/gotgenes/pi-permission-system/commit/fd118606ba90af83d2d9eb5e752e76353beaf0ba))
26
+ * **retro:** add retro notes for issue [#54](https://github.com/gotgenes/pi-permission-system/issues/54) ([d7c5e8a](https://github.com/gotgenes/pi-permission-system/commit/d7c5e8aaae31fa658bcfb235547662bf226e6855))
27
+
8
28
  ## [3.5.0](https://github.com/gotgenes/pi-permission-system/compare/v3.4.0...v3.5.0) (2026-05-03)
9
29
 
10
30
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "3.5.0",
3
+ "version": "3.6.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "files": [
@@ -12,6 +12,8 @@ import {
12
12
  } from "./common";
13
13
  import { loadUnifiedConfig, stripJsonComments } from "./config-loader";
14
14
  import { getGlobalConfigPath } from "./config-paths";
15
+ import type { Rule, Ruleset } from "./rule";
16
+ import { evaluate } from "./rule";
15
17
  import type {
16
18
  AgentPermissions,
17
19
  BashPermissions,
@@ -375,6 +377,8 @@ type ResolvedPermissions = {
375
377
  globalConfig: GlobalPermissionConfig;
376
378
  agentConfig: AgentPermissions;
377
379
  merged: GlobalPermissionConfig;
380
+ compiledBash: CompiledPermissionPatterns;
381
+ bashDefault: PermissionState;
378
382
  compiledSpecial: CompiledPermissionPatterns;
379
383
  compiledSkills: CompiledPermissionPatterns;
380
384
  compiledMcp: CompiledPermissionPatterns;
@@ -403,6 +407,21 @@ function compilePermissionPatternsFromSources(
403
407
  return compileWildcardPatternEntries(entries);
404
408
  }
405
409
 
410
+ /**
411
+ * Convert compiled wildcard patterns into a Ruleset for use with evaluate().
412
+ * The returned Rule objects are the same references as the input; evaluate()
413
+ * uses reference equality to distinguish an explicit match from the synthetic
414
+ * default it returns when nothing matches.
415
+ */
416
+ function compiledToRuleset(
417
+ surface: string,
418
+ patterns: CompiledPermissionPatterns,
419
+ ): Ruleset {
420
+ return patterns.map(
421
+ (p): Rule => ({ surface, pattern: p.pattern, action: p.state }),
422
+ );
423
+ }
424
+
406
425
  function findCompiledPermissionMatch(
407
426
  patterns: CompiledPermissionPatterns,
408
427
  name: string,
@@ -701,10 +720,18 @@ export class PermissionManager {
701
720
  projectConfig.tools?.bash ||
702
721
  merged.tools?.bash ||
703
722
  merged.defaultPolicy.bash;
723
+ const compiledBash = compilePermissionPatternsFromSources(
724
+ globalConfig.bash,
725
+ projectConfig.bash,
726
+ agentConfig.bash,
727
+ projectAgentConfig.bash,
728
+ );
704
729
  const value: ResolvedPermissions = {
705
730
  globalConfig,
706
731
  agentConfig,
707
732
  merged,
733
+ compiledBash,
734
+ bashDefault,
708
735
  compiledSpecial: compilePermissionPatternsFromSources(
709
736
  globalConfig.special,
710
737
  projectConfig.special,
@@ -723,15 +750,7 @@ export class PermissionManager {
723
750
  agentConfig.mcp,
724
751
  projectAgentConfig.mcp,
725
752
  ),
726
- bashFilter: new BashFilter(
727
- compilePermissionPatternsFromSources(
728
- globalConfig.bash,
729
- projectConfig.bash,
730
- agentConfig.bash,
731
- projectAgentConfig.bash,
732
- ),
733
- bashDefault,
734
- ),
753
+ bashFilter: new BashFilter(compiledBash, bashDefault),
735
754
  };
736
755
 
737
756
  this.resolvedPermissionsCache.set(cacheKey, { stamp, value });
@@ -804,22 +823,23 @@ export class PermissionManager {
804
823
  const {
805
824
  agentConfig: _agentConfig,
806
825
  merged,
826
+ compiledBash,
827
+ bashDefault,
807
828
  compiledSpecial,
808
829
  compiledSkills,
809
830
  compiledMcp,
810
- bashFilter,
831
+ bashFilter: _bashFilter,
811
832
  } = this.resolvePermissions(agentName);
812
833
  const normalizedToolName = toolName.trim();
813
834
 
814
835
  if (SPECIAL_PERMISSION_KEYS.has(normalizedToolName)) {
815
- const result = findCompiledPermissionMatch(
816
- compiledSpecial,
817
- normalizedToolName,
818
- );
836
+ const specialRuleset = compiledToRuleset("special", compiledSpecial);
837
+ const rule = evaluate("special", normalizedToolName, specialRuleset);
838
+ const explicit = specialRuleset.includes(rule);
819
839
  return {
820
840
  toolName,
821
- state: result?.state || merged.defaultPolicy.special,
822
- matchedPattern: result?.matchedPattern,
841
+ state: explicit ? rule.action : merged.defaultPolicy.special,
842
+ matchedPattern: explicit ? rule.pattern : undefined,
823
843
  source: "special",
824
844
  };
825
845
  }
@@ -827,15 +847,16 @@ export class PermissionManager {
827
847
  if (normalizedToolName === "skill") {
828
848
  const skillName = toRecord(input).name;
829
849
  if (typeof skillName === "string") {
830
- const result = findCompiledPermissionMatch(compiledSkills, skillName);
850
+ const skillRuleset = compiledToRuleset("skill", compiledSkills);
851
+ const rule = evaluate("skill", skillName, skillRuleset);
852
+ const explicit = skillRuleset.includes(rule);
831
853
  return {
832
854
  toolName,
833
- state: result?.state || merged.defaultPolicy.skills,
834
- matchedPattern: result?.matchedPattern,
855
+ state: explicit ? rule.action : merged.defaultPolicy.skills,
856
+ matchedPattern: explicit ? rule.pattern : undefined,
835
857
  source: "skill",
836
858
  };
837
859
  }
838
-
839
860
  return {
840
861
  toolName,
841
862
  state: merged.defaultPolicy.skills,
@@ -846,13 +867,14 @@ export class PermissionManager {
846
867
  if (normalizedToolName === "bash") {
847
868
  const record = toRecord(input);
848
869
  const command = typeof record.command === "string" ? record.command : "";
849
- const result = bashFilter.check(command);
850
-
870
+ const bashRuleset = compiledToRuleset("bash", compiledBash);
871
+ const rule = evaluate("bash", command, bashRuleset);
872
+ const explicit = bashRuleset.includes(rule);
851
873
  return {
852
874
  toolName,
853
- state: result.state,
854
- command: result.command,
855
- matchedPattern: result.matchedPattern,
875
+ state: explicit ? rule.action : bashDefault,
876
+ command,
877
+ matchedPattern: explicit ? rule.pattern : undefined,
856
878
  source: "bash",
857
879
  };
858
880
  }
@@ -868,16 +890,21 @@ export class PermissionManager {
868
890
  const fallbackTarget = mcpTargets[0] || "mcp";
869
891
  const toolLevelMcpState = merged.tools?.mcp;
870
892
 
871
- const mcpMatch = findCompiledPermissionMatchForNames(
872
- compiledMcp,
873
- mcpTargets,
874
- );
875
- if (mcpMatch) {
893
+ const mcpRuleset = compiledToRuleset("mcp", compiledMcp);
894
+ let mcpExplicitMatch: { target: string; rule: Rule } | null = null;
895
+ for (const target of mcpTargets) {
896
+ const rule = evaluate("mcp", target, mcpRuleset);
897
+ if (mcpRuleset.includes(rule)) {
898
+ mcpExplicitMatch = { target, rule };
899
+ break;
900
+ }
901
+ }
902
+ if (mcpExplicitMatch) {
876
903
  return {
877
904
  toolName,
878
- state: mcpMatch.state,
879
- matchedPattern: mcpMatch.matchedPattern,
880
- target: mcpMatch.matchedName,
905
+ state: mcpExplicitMatch.rule.action,
906
+ matchedPattern: mcpExplicitMatch.rule.pattern,
907
+ target: mcpExplicitMatch.target,
881
908
  source: "mcp",
882
909
  };
883
910
  }
@@ -916,19 +943,24 @@ export class PermissionManager {
916
943
  };
917
944
  }
918
945
 
946
+ const toolRuleset: Ruleset = Object.entries(merged.tools ?? {}).map(
947
+ ([name, action]) => ({ surface: "tool", pattern: name, action }),
948
+ );
949
+ const toolRule = evaluate("tool", normalizedToolName, toolRuleset);
950
+ const explicitTool = toolRuleset.includes(toolRule);
951
+
919
952
  if (BUILT_IN_TOOL_PERMISSION_NAMES.has(normalizedToolName)) {
920
953
  return {
921
954
  toolName,
922
- state: merged.tools?.[normalizedToolName] || merged.defaultPolicy.tools,
955
+ state: explicitTool ? toolRule.action : merged.defaultPolicy.tools,
923
956
  source: "tool",
924
957
  };
925
958
  }
926
959
 
927
- const explicitToolPermission = merged.tools?.[normalizedToolName];
928
- if (explicitToolPermission) {
960
+ if (explicitTool) {
929
961
  return {
930
962
  toolName,
931
- state: explicitToolPermission,
963
+ state: toolRule.action,
932
964
  source: "tool",
933
965
  };
934
966
  }
package/src/rule.ts ADDED
@@ -0,0 +1,58 @@
1
+ import type { PermissionState } from "./types";
2
+ import { wildcardMatch } from "./wildcard-matcher";
3
+
4
+ /** A single permission rule — the atomic unit of policy. */
5
+ export interface Rule {
6
+ /** The permission surface: "bash", "read", "mcp", "skill", "external_directory", etc. */
7
+ surface: string;
8
+ /** The match pattern: a command glob, tool name, skill name, or "*". */
9
+ pattern: string;
10
+ /** The permission decision. */
11
+ action: PermissionState;
12
+ }
13
+
14
+ /** An ordered list of rules. Later rules take priority (last-match-wins). */
15
+ export type Ruleset = Rule[];
16
+
17
+ const SURFACE_DEFAULTS: Record<string, PermissionState> = {
18
+ tools: "ask",
19
+ bash: "ask",
20
+ mcp: "ask",
21
+ skill: "ask",
22
+ special: "ask",
23
+ };
24
+
25
+ /**
26
+ * Returns the default action for a surface when no rules match.
27
+ * Defaults to "ask" for unknown surfaces (least privilege).
28
+ */
29
+ export function getDefaultAction(surface: string): PermissionState {
30
+ return SURFACE_DEFAULTS[surface] ?? "ask";
31
+ }
32
+
33
+ /**
34
+ * Pure permission evaluation.
35
+ *
36
+ * Flattens all provided rulesets and returns the last rule whose surface and
37
+ * pattern both wildcard-match the supplied values (last-match-wins, so later
38
+ * rulesets / later entries have higher priority).
39
+ *
40
+ * When no rule matches, returns a synthetic rule using getDefaultAction().
41
+ */
42
+ export function evaluate(
43
+ surface: string,
44
+ pattern: string,
45
+ ...rulesets: Ruleset[]
46
+ ): Rule {
47
+ const rules = rulesets.flat();
48
+ for (let i = rules.length - 1; i >= 0; i -= 1) {
49
+ const rule = rules[i];
50
+ if (
51
+ wildcardMatch(rule.surface, surface) &&
52
+ wildcardMatch(rule.pattern, pattern)
53
+ ) {
54
+ return rule;
55
+ }
56
+ }
57
+ return { surface, pattern, action: getDefaultAction(surface) };
58
+ }
@@ -62,6 +62,15 @@ export function findCompiledWildcardMatch<TState>(
62
62
  return null;
63
63
  }
64
64
 
65
+ /**
66
+ * Test whether `value` matches `pattern` using wildcard rules.
67
+ * `*` in the pattern matches any sequence of characters (including empty).
68
+ * Used by evaluate() for rule matching.
69
+ */
70
+ export function wildcardMatch(pattern: string, value: string): boolean {
71
+ return compileWildcardPattern(pattern, null).regex.test(value);
72
+ }
73
+
65
74
  export function findCompiledWildcardMatchForNames<TState>(
66
75
  patterns: readonly CompiledWildcardPattern<TState>[],
67
76
  names: readonly string[],
@@ -0,0 +1,158 @@
1
+ import { afterEach, describe, expect, test, vi } from "vitest";
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
+ });
36
+
37
+ describe("evaluate", () => {
38
+ const allowBashGit: Rule = {
39
+ surface: "bash",
40
+ pattern: "git *",
41
+ action: "allow",
42
+ };
43
+ const denyBashGitPush: Rule = {
44
+ surface: "bash",
45
+ pattern: "git push *",
46
+ action: "deny",
47
+ };
48
+ const allowRead: Rule = { surface: "read", pattern: "*", action: "allow" };
49
+ const askMcp: Rule = { surface: "mcp", pattern: "*", action: "ask" };
50
+ const allowSkillLibrarian: Rule = {
51
+ surface: "skill",
52
+ pattern: "librarian",
53
+ action: "allow",
54
+ };
55
+ const askSpecialExtDir: Rule = {
56
+ surface: "special",
57
+ pattern: "external_directory",
58
+ action: "ask",
59
+ };
60
+
61
+ test("returns matching rule when a rule matches", () => {
62
+ const ruleset: Ruleset = [allowBashGit];
63
+ const result = evaluate("bash", "git status", ruleset);
64
+ expect(result).toEqual(allowBashGit);
65
+ });
66
+
67
+ test("returns synthetic rule with default action when no rules match", () => {
68
+ const result = evaluate("bash", "npm install", [allowBashGit]);
69
+ expect(result.surface).toBe("bash");
70
+ expect(result.pattern).toBe("npm install");
71
+ expect(result.action).toBe("ask"); // getDefaultAction("bash")
72
+ });
73
+
74
+ test("returns synthetic rule for empty ruleset", () => {
75
+ const result = evaluate("mcp", "exa_search", []);
76
+ expect(result.surface).toBe("mcp");
77
+ expect(result.pattern).toBe("exa_search");
78
+ expect(result.action).toBe("ask");
79
+ });
80
+
81
+ test("matches rules for all permission surfaces", () => {
82
+ expect(evaluate("read", "src/foo.ts", [allowRead]).action).toBe("allow");
83
+ expect(evaluate("mcp", "exa_search", [askMcp]).action).toBe("ask");
84
+ expect(evaluate("skill", "librarian", [allowSkillLibrarian]).action).toBe(
85
+ "allow",
86
+ );
87
+ expect(
88
+ evaluate("special", "external_directory", [askSpecialExtDir]).action,
89
+ ).toBe("ask");
90
+ });
91
+
92
+ test("last-match-wins: later conflicting rule overrides earlier", () => {
93
+ const ruleset: Ruleset = [allowBashGit, denyBashGitPush];
94
+ const result = evaluate("bash", "git push origin main", ruleset);
95
+ expect(result).toEqual(denyBashGitPush);
96
+ });
97
+
98
+ test("last-match-wins: broad deny followed by specific allow", () => {
99
+ const denyAll: Rule = { surface: "bash", pattern: "*", action: "deny" };
100
+ const allowStatus: Rule = {
101
+ surface: "bash",
102
+ pattern: "git status",
103
+ action: "allow",
104
+ };
105
+ const result = evaluate("bash", "git status", [denyAll, allowStatus]);
106
+ expect(result).toEqual(allowStatus);
107
+ });
108
+
109
+ test("wildcard surface in rule matches any surface value", () => {
110
+ const universalAllow: Rule = {
111
+ surface: "*",
112
+ pattern: "*",
113
+ action: "allow",
114
+ };
115
+ expect(evaluate("bash", "anything", [universalAllow]).action).toBe("allow");
116
+ expect(evaluate("mcp", "something", [universalAllow]).action).toBe("allow");
117
+ expect(evaluate("skill", "librarian", [universalAllow]).action).toBe(
118
+ "allow",
119
+ );
120
+ });
121
+
122
+ test("specific surface rule does not match a different surface", () => {
123
+ const ruleset: Ruleset = [allowBashGit];
124
+ // bash rule should not match mcp surface
125
+ const result = evaluate("mcp", "git status", ruleset);
126
+ expect(result.action).toBe("ask"); // falls back to default
127
+ });
128
+
129
+ test("multiple rulesets: rules from later rulesets take priority", () => {
130
+ const globalRules: Ruleset = [
131
+ { surface: "bash", pattern: "git *", action: "ask" },
132
+ ];
133
+ const agentRules: Ruleset = [
134
+ { surface: "bash", pattern: "git *", action: "allow" },
135
+ ];
136
+ const result = evaluate("bash", "git status", globalRules, agentRules);
137
+ expect(result.action).toBe("allow"); // agent rule wins
138
+ });
139
+
140
+ test("multiple rulesets: earlier rulesets used when later rulesets have no match", () => {
141
+ const globalRules: Ruleset = [
142
+ { surface: "bash", pattern: "git *", action: "allow" },
143
+ ];
144
+ const agentRules: Ruleset = [
145
+ { surface: "bash", pattern: "npm *", action: "deny" },
146
+ ];
147
+ // git status matches global but not agent rule
148
+ const result = evaluate("bash", "git status", globalRules, agentRules);
149
+ expect(result.action).toBe("allow"); // global rule is the last match for this pattern
150
+ });
151
+
152
+ test("no rulesets at all returns synthetic default", () => {
153
+ const result = evaluate("bash", "git status");
154
+ expect(result.surface).toBe("bash");
155
+ expect(result.pattern).toBe("git status");
156
+ expect(result.action).toBe("ask");
157
+ });
158
+ });
@@ -5,6 +5,7 @@ import {
5
5
  compileWildcardPatternEntries,
6
6
  findCompiledWildcardMatch,
7
7
  findCompiledWildcardMatchForNames,
8
+ wildcardMatch,
8
9
  } from "../src/wildcard-matcher";
9
10
 
10
11
  afterEach(() => {
@@ -178,3 +179,41 @@ describe("findCompiledWildcardMatchForNames", () => {
178
179
  expect(compiled.regex.test("echo hello")).toBe(false);
179
180
  });
180
181
  });
182
+
183
+ describe("wildcardMatch", () => {
184
+ test("'*' pattern matches any value", () => {
185
+ expect(wildcardMatch("*", "anything")).toBe(true);
186
+ expect(wildcardMatch("*", "")).toBe(true);
187
+ expect(wildcardMatch("*", "bash")).toBe(true);
188
+ });
189
+
190
+ test("exact pattern matches identical value", () => {
191
+ expect(wildcardMatch("read", "read")).toBe(true);
192
+ expect(wildcardMatch("external_directory", "external_directory")).toBe(
193
+ true,
194
+ );
195
+ });
196
+
197
+ test("exact pattern does not match a different value", () => {
198
+ expect(wildcardMatch("read", "write")).toBe(false);
199
+ expect(wildcardMatch("read", "readonly")).toBe(false);
200
+ expect(wildcardMatch("read", "read ")).toBe(false);
201
+ });
202
+
203
+ test("glob pattern matches with wildcard", () => {
204
+ expect(wildcardMatch("git *", "git status")).toBe(true);
205
+ expect(wildcardMatch("git *", "git push origin main")).toBe(true);
206
+ expect(wildcardMatch("git *", "npm install")).toBe(false);
207
+ });
208
+
209
+ test("glob with no trailing space matches longer string", () => {
210
+ expect(wildcardMatch("git*", "git")).toBe(true);
211
+ expect(wildcardMatch("git*", "git status")).toBe(true);
212
+ expect(wildcardMatch("git*", "npm")).toBe(false);
213
+ });
214
+
215
+ test("regex special characters in pattern are treated as literals", () => {
216
+ expect(wildcardMatch("tool.name", "tool.name")).toBe(true);
217
+ expect(wildcardMatch("tool.name", "toolXname")).toBe(false);
218
+ });
219
+ });