@gotgenes/pi-permission-system 3.4.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,43 @@ 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
+
28
+ ## [3.5.0](https://github.com/gotgenes/pi-permission-system/compare/v3.4.0...v3.5.0) (2026-05-03)
29
+
30
+
31
+ ### Features
32
+
33
+ * deprecate doom_loop special permission key ([68e70e7](https://github.com/gotgenes/pi-permission-system/commit/68e70e71b68e5a76a071ef4613da356a91080158))
34
+ * remove doom_loop from type union and config-loader ([bf2f288](https://github.com/gotgenes/pi-permission-system/commit/bf2f2886a800187337e82954e812e6d05e9bd451))
35
+
36
+
37
+ ### Documentation
38
+
39
+ * add architecture documents for current and target permission model ([aab1ac5](https://github.com/gotgenes/pi-permission-system/commit/aab1ac50c4478d2e393c2a796bf6fcc4ec606f79))
40
+ * plan doom_loop deprecation ([#54](https://github.com/gotgenes/pi-permission-system/issues/54)) ([2e730f5](https://github.com/gotgenes/pi-permission-system/commit/2e730f52189dd2996ebbe90dd5d2b3206a45d1f6))
41
+ * plan handler extraction from piPermissionSystemExtension ([#42](https://github.com/gotgenes/pi-permission-system/issues/42)) ([6ecd419](https://github.com/gotgenes/pi-permission-system/commit/6ecd4190fb9a60009eb695b4998ab8a1d1419139))
42
+ * remove doom_loop from schema, example, and README ([7f422e0](https://github.com/gotgenes/pi-permission-system/commit/7f422e086f0052e0d9449dbd0122c57b923b053d))
43
+ * **retro:** add retro notes for issue [#45](https://github.com/gotgenes/pi-permission-system/issues/45) ([14c5559](https://github.com/gotgenes/pi-permission-system/commit/14c55595c5abfaa51f8ec83369452db5f457836c))
44
+
8
45
  ## [3.4.0](https://github.com/gotgenes/pi-permission-system/compare/v3.3.0...v3.4.0) (2026-05-03)
9
46
 
10
47
 
package/README.md CHANGED
@@ -137,7 +137,7 @@ The config file combines runtime knobs and permission policy in one object:
137
137
  "bash": { "git status": "allow", "git *": "ask" },
138
138
  "mcp": { "mcp_status": "allow" },
139
139
  "skills": { "*": "ask" },
140
- "special": { "doom_loop": "deny", "external_directory": "ask" }
140
+ "special": { "external_directory": "ask" }
141
141
  }
142
142
  ```
143
143
 
@@ -353,13 +353,11 @@ Reserved permission checks:
353
353
 
354
354
  | Key | Description |
355
355
  | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
356
- | `doom_loop` | Controls doom loop detection behavior |
357
356
  | `external_directory` | Enforces ask/allow/deny decisions for path-bearing tools and bash commands that reference paths outside the active working directory |
358
357
 
359
358
  ```jsonc
360
359
  {
361
360
  "special": {
362
- "doom_loop": "deny",
363
361
  "external_directory": "ask",
364
362
  },
365
363
  }
@@ -27,7 +27,6 @@
27
27
  "*": "ask"
28
28
  },
29
29
  "special": {
30
- "doom_loop": "deny",
31
30
  "external_directory": "ask"
32
31
  }
33
32
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "3.4.0",
3
+ "version": "3.6.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "files": [
@@ -60,8 +60,8 @@
60
60
  "default": "ask"
61
61
  },
62
62
  "special": {
63
- "description": "Default permission for special checks (doom_loop, external_directory) when no explicit rule matches.",
64
- "markdownDescription": "Default permission for special checks (`doom_loop`, `external_directory`) when no explicit rule matches.",
63
+ "description": "Default permission for special checks (external_directory) when no explicit rule matches.",
64
+ "markdownDescription": "Default permission for special checks (`external_directory`) when no explicit rule matches.",
65
65
  "$ref": "#/$defs/permissionState",
66
66
  "default": "ask"
67
67
  }
@@ -122,10 +122,6 @@
122
122
  "type": "object",
123
123
  "additionalProperties": false,
124
124
  "properties": {
125
- "doom_loop": {
126
- "description": "Controls doom loop detection behavior.",
127
- "$ref": "#/$defs/permissionState"
128
- },
129
125
  "external_directory": {
130
126
  "description": "Enforces permission checks for path-bearing file tools (read, write, edit, find, grep, ls) when they target paths outside the active working directory.",
131
127
  "markdownDescription": "Enforces permission checks for path-bearing file tools (`read`, `write`, `edit`, `find`, `grep`, `ls`) when they target paths outside the active working directory.\n\nEvaluated **before** the normal tool permission check — so `tools.read: \"allow\"` can permit ordinary reads while `external_directory: \"ask\"` still requires confirmation for paths outside `cwd`.",
@@ -36,6 +36,7 @@ export interface UnifiedConfigLoadResult {
36
36
  }
37
37
 
38
38
  const DEPRECATED_SPECIAL_KEYS: ReadonlySet<string> = new Set([
39
+ "doom_loop",
39
40
  "tool_call_limit",
40
41
  ]);
41
42
 
@@ -49,7 +50,7 @@ const BUILT_IN_TOOL_PERMISSION_NAMES = new Set([
49
50
  "ls",
50
51
  ]);
51
52
 
52
- const SPECIAL_PERMISSION_KEYS = new Set(["doom_loop", "external_directory"]);
53
+ const SPECIAL_PERMISSION_KEYS = new Set(["external_directory"]);
53
54
 
54
55
  export function stripJsonComments(input: string): string {
55
56
  let output = "";
@@ -69,7 +69,6 @@ const PERMISSION_POLICY_KEYS: ReadonlySet<string> = new Set([
69
69
  "skills",
70
70
  "special",
71
71
  "external_directory",
72
- "doom_loop",
73
72
  ]);
74
73
 
75
74
  export function detectMisplacedPermissionKeys(
@@ -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,
@@ -46,7 +48,7 @@ const BUILT_IN_TOOL_PERMISSION_NAMES = new Set([
46
48
  "find",
47
49
  "ls",
48
50
  ]);
49
- const SPECIAL_PERMISSION_KEYS = new Set(["doom_loop", "external_directory"]);
51
+ const SPECIAL_PERMISSION_KEYS = new Set(["external_directory"]);
50
52
  const MCP_BASELINE_TARGETS = new Set([
51
53
  "mcp_status",
52
54
  "mcp_list",
@@ -156,6 +158,7 @@ function getConfiguredMcpServerNamesFromPaths(
156
158
  }
157
159
 
158
160
  const DEPRECATED_SPECIAL_KEYS: ReadonlySet<string> = new Set([
161
+ "doom_loop",
159
162
  "tool_call_limit",
160
163
  ]);
161
164
 
@@ -374,6 +377,8 @@ type ResolvedPermissions = {
374
377
  globalConfig: GlobalPermissionConfig;
375
378
  agentConfig: AgentPermissions;
376
379
  merged: GlobalPermissionConfig;
380
+ compiledBash: CompiledPermissionPatterns;
381
+ bashDefault: PermissionState;
377
382
  compiledSpecial: CompiledPermissionPatterns;
378
383
  compiledSkills: CompiledPermissionPatterns;
379
384
  compiledMcp: CompiledPermissionPatterns;
@@ -402,6 +407,21 @@ function compilePermissionPatternsFromSources(
402
407
  return compileWildcardPatternEntries(entries);
403
408
  }
404
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
+
405
425
  function findCompiledPermissionMatch(
406
426
  patterns: CompiledPermissionPatterns,
407
427
  name: string,
@@ -700,10 +720,18 @@ export class PermissionManager {
700
720
  projectConfig.tools?.bash ||
701
721
  merged.tools?.bash ||
702
722
  merged.defaultPolicy.bash;
723
+ const compiledBash = compilePermissionPatternsFromSources(
724
+ globalConfig.bash,
725
+ projectConfig.bash,
726
+ agentConfig.bash,
727
+ projectAgentConfig.bash,
728
+ );
703
729
  const value: ResolvedPermissions = {
704
730
  globalConfig,
705
731
  agentConfig,
706
732
  merged,
733
+ compiledBash,
734
+ bashDefault,
707
735
  compiledSpecial: compilePermissionPatternsFromSources(
708
736
  globalConfig.special,
709
737
  projectConfig.special,
@@ -722,15 +750,7 @@ export class PermissionManager {
722
750
  agentConfig.mcp,
723
751
  projectAgentConfig.mcp,
724
752
  ),
725
- bashFilter: new BashFilter(
726
- compilePermissionPatternsFromSources(
727
- globalConfig.bash,
728
- projectConfig.bash,
729
- agentConfig.bash,
730
- projectAgentConfig.bash,
731
- ),
732
- bashDefault,
733
- ),
753
+ bashFilter: new BashFilter(compiledBash, bashDefault),
734
754
  };
735
755
 
736
756
  this.resolvedPermissionsCache.set(cacheKey, { stamp, value });
@@ -803,22 +823,23 @@ export class PermissionManager {
803
823
  const {
804
824
  agentConfig: _agentConfig,
805
825
  merged,
826
+ compiledBash,
827
+ bashDefault,
806
828
  compiledSpecial,
807
829
  compiledSkills,
808
830
  compiledMcp,
809
- bashFilter,
831
+ bashFilter: _bashFilter,
810
832
  } = this.resolvePermissions(agentName);
811
833
  const normalizedToolName = toolName.trim();
812
834
 
813
835
  if (SPECIAL_PERMISSION_KEYS.has(normalizedToolName)) {
814
- const result = findCompiledPermissionMatch(
815
- compiledSpecial,
816
- normalizedToolName,
817
- );
836
+ const specialRuleset = compiledToRuleset("special", compiledSpecial);
837
+ const rule = evaluate("special", normalizedToolName, specialRuleset);
838
+ const explicit = specialRuleset.includes(rule);
818
839
  return {
819
840
  toolName,
820
- state: result?.state || merged.defaultPolicy.special,
821
- matchedPattern: result?.matchedPattern,
841
+ state: explicit ? rule.action : merged.defaultPolicy.special,
842
+ matchedPattern: explicit ? rule.pattern : undefined,
822
843
  source: "special",
823
844
  };
824
845
  }
@@ -826,15 +847,16 @@ export class PermissionManager {
826
847
  if (normalizedToolName === "skill") {
827
848
  const skillName = toRecord(input).name;
828
849
  if (typeof skillName === "string") {
829
- const result = findCompiledPermissionMatch(compiledSkills, skillName);
850
+ const skillRuleset = compiledToRuleset("skill", compiledSkills);
851
+ const rule = evaluate("skill", skillName, skillRuleset);
852
+ const explicit = skillRuleset.includes(rule);
830
853
  return {
831
854
  toolName,
832
- state: result?.state || merged.defaultPolicy.skills,
833
- matchedPattern: result?.matchedPattern,
855
+ state: explicit ? rule.action : merged.defaultPolicy.skills,
856
+ matchedPattern: explicit ? rule.pattern : undefined,
834
857
  source: "skill",
835
858
  };
836
859
  }
837
-
838
860
  return {
839
861
  toolName,
840
862
  state: merged.defaultPolicy.skills,
@@ -845,13 +867,14 @@ export class PermissionManager {
845
867
  if (normalizedToolName === "bash") {
846
868
  const record = toRecord(input);
847
869
  const command = typeof record.command === "string" ? record.command : "";
848
- const result = bashFilter.check(command);
849
-
870
+ const bashRuleset = compiledToRuleset("bash", compiledBash);
871
+ const rule = evaluate("bash", command, bashRuleset);
872
+ const explicit = bashRuleset.includes(rule);
850
873
  return {
851
874
  toolName,
852
- state: result.state,
853
- command: result.command,
854
- matchedPattern: result.matchedPattern,
875
+ state: explicit ? rule.action : bashDefault,
876
+ command,
877
+ matchedPattern: explicit ? rule.pattern : undefined,
855
878
  source: "bash",
856
879
  };
857
880
  }
@@ -867,16 +890,21 @@ export class PermissionManager {
867
890
  const fallbackTarget = mcpTargets[0] || "mcp";
868
891
  const toolLevelMcpState = merged.tools?.mcp;
869
892
 
870
- const mcpMatch = findCompiledPermissionMatchForNames(
871
- compiledMcp,
872
- mcpTargets,
873
- );
874
- 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) {
875
903
  return {
876
904
  toolName,
877
- state: mcpMatch.state,
878
- matchedPattern: mcpMatch.matchedPattern,
879
- target: mcpMatch.matchedName,
905
+ state: mcpExplicitMatch.rule.action,
906
+ matchedPattern: mcpExplicitMatch.rule.pattern,
907
+ target: mcpExplicitMatch.target,
880
908
  source: "mcp",
881
909
  };
882
910
  }
@@ -915,19 +943,24 @@ export class PermissionManager {
915
943
  };
916
944
  }
917
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
+
918
952
  if (BUILT_IN_TOOL_PERMISSION_NAMES.has(normalizedToolName)) {
919
953
  return {
920
954
  toolName,
921
- state: merged.tools?.[normalizedToolName] || merged.defaultPolicy.tools,
955
+ state: explicitTool ? toolRule.action : merged.defaultPolicy.tools,
922
956
  source: "tool",
923
957
  };
924
958
  }
925
959
 
926
- const explicitToolPermission = merged.tools?.[normalizedToolName];
927
- if (explicitToolPermission) {
960
+ if (explicitTool) {
928
961
  return {
929
962
  toolName,
930
- state: explicitToolPermission,
963
+ state: toolRule.action,
931
964
  source: "tool",
932
965
  };
933
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
+ }
package/src/types.ts CHANGED
@@ -15,7 +15,7 @@ export type BashPermissions = Record<string, PermissionState>;
15
15
 
16
16
  export type SkillPermissions = Record<string, PermissionState>;
17
17
 
18
- export type SpecialPermissionName = "doom_loop" | "external_directory";
18
+ export type SpecialPermissionName = "external_directory";
19
19
 
20
20
  export type SpecialPermissions = Record<string, PermissionState>;
21
21
 
@@ -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[],
@@ -133,7 +133,7 @@ describe("loadUnifiedConfig", () => {
133
133
  expect(result.config.bash).toEqual({ "git *": "ask" });
134
134
  });
135
135
 
136
- it("collects deprecated special key issues", () => {
136
+ it("collects deprecated special key issues (doom_loop and tool_call_limit)", () => {
137
137
  const configPath = join(tempDir, "config.json");
138
138
  writeFileSync(
139
139
  configPath,
@@ -143,9 +143,10 @@ describe("loadUnifiedConfig", () => {
143
143
  );
144
144
 
145
145
  const result = loadUnifiedConfig(configPath);
146
- expect(result.issues).toHaveLength(1);
147
- expect(result.issues[0]).toContain("tool_call_limit");
148
- expect(result.config.special).toEqual({ doom_loop: "deny" });
146
+ expect(result.issues).toHaveLength(2);
147
+ expect(result.issues.some((i) => i.includes("doom_loop"))).toBe(true);
148
+ expect(result.issues.some((i) => i.includes("tool_call_limit"))).toBe(true);
149
+ expect(result.config.special).toBeUndefined();
149
150
  });
150
151
  });
151
152
 
@@ -42,7 +42,6 @@ describe("detectMisplacedPermissionKeys", () => {
42
42
  skills: {},
43
43
  special: {},
44
44
  external_directory: {},
45
- doom_loop: {},
46
45
  });
47
46
  expect(result).toEqual([
48
47
  "defaultPolicy",
@@ -52,10 +51,16 @@ describe("detectMisplacedPermissionKeys", () => {
52
51
  "skills",
53
52
  "special",
54
53
  "external_directory",
55
- "doom_loop",
56
54
  ]);
57
55
  });
58
56
 
57
+ it("does not detect doom_loop as a misplaced permission key (deprecated)", () => {
58
+ const result = detectMisplacedPermissionKeys({
59
+ doom_loop: {},
60
+ });
61
+ expect(result).toEqual([]);
62
+ });
63
+
59
64
  it("ignores unknown keys that are not permission-rule keys", () => {
60
65
  const result = detectMisplacedPermissionKeys({
61
66
  debugLog: true,
@@ -2115,7 +2115,7 @@ permission:
2115
2115
  }
2116
2116
  });
2117
2117
 
2118
- test("external_directory permission is independent of doom_loop in the same special config", () => {
2118
+ test("external_directory permission is unaffected when doom_loop key is present in config (deprecated and ignored)", () => {
2119
2119
  const { manager, cleanup } = createManager({
2120
2120
  defaultPolicy: {
2121
2121
  tools: "allow",
@@ -2131,10 +2131,12 @@ test("external_directory permission is independent of doom_loop in the same spec
2131
2131
  });
2132
2132
 
2133
2133
  try {
2134
+ // doom_loop is deprecated and stripped — falls through to defaultPolicy.tools
2134
2135
  const doomResult = manager.checkPermission("doom_loop", {});
2135
- assert.equal(doomResult.state, "deny");
2136
- assert.equal(doomResult.matchedPattern, "doom_loop");
2136
+ assert.equal(doomResult.state, "allow"); // defaultPolicy.tools, not the stripped doom_loop: "deny"
2137
+ assert.equal(doomResult.matchedPattern, undefined);
2137
2138
 
2139
+ // external_directory still resolves from its own entry
2138
2140
  const extResult = manager.checkPermission("external_directory", {});
2139
2141
  assert.equal(extResult.state, "allow");
2140
2142
  assert.equal(extResult.matchedPattern, "external_directory");
@@ -2583,12 +2585,22 @@ test("normalizeRawPermission emits deprecation issue for special.tool_call_limit
2583
2585
  assert.equal(result.permissions.special?.tool_call_limit, undefined);
2584
2586
  });
2585
2587
 
2586
- test("normalizeRawPermission emits no issues for valid special keys", () => {
2588
+ test("normalizeRawPermission emits deprecation issue for special.doom_loop (string)", () => {
2589
+ const result = normalizeRawPermission({
2590
+ special: { doom_loop: "ask" },
2591
+ });
2592
+ assert.equal(result.configIssues.length, 1);
2593
+ assert.ok(result.configIssues[0].includes("doom_loop"));
2594
+ assert.equal(result.permissions.special?.doom_loop, undefined);
2595
+ });
2596
+
2597
+ test("normalizeRawPermission emits deprecation issue for special.doom_loop (deny)", () => {
2587
2598
  const result = normalizeRawPermission({
2588
2599
  special: { doom_loop: "deny" },
2589
2600
  });
2590
- assert.equal(result.configIssues.length, 0);
2591
- assert.equal(result.permissions.special?.doom_loop, "deny");
2601
+ assert.equal(result.configIssues.length, 1);
2602
+ assert.ok(result.configIssues[0].includes("doom_loop"));
2603
+ assert.equal(result.permissions.special?.doom_loop, undefined);
2592
2604
  });
2593
2605
 
2594
2606
  test("normalizeRawPermission emits no issues when special is absent", () => {
@@ -2609,7 +2621,7 @@ test("PermissionManager.getConfigIssues returns deprecation for tool_call_limit
2609
2621
  bash: {},
2610
2622
  mcp: {},
2611
2623
  skills: {},
2612
- special: { tool_call_limit: "allow" as PermissionState, doom_loop: "deny" },
2624
+ special: { tool_call_limit: "allow" as PermissionState },
2613
2625
  };
2614
2626
  const { manager, cleanup } = createManager(config);
2615
2627
  try {
@@ -2634,7 +2646,7 @@ test("PermissionManager.getConfigIssues returns empty array for clean config", (
2634
2646
  bash: {},
2635
2647
  mcp: {},
2636
2648
  skills: {},
2637
- special: { doom_loop: "deny" },
2649
+ special: { external_directory: "ask" },
2638
2650
  };
2639
2651
  const { manager, cleanup } = createManager(config);
2640
2652
  try {
@@ -2645,6 +2657,58 @@ test("PermissionManager.getConfigIssues returns empty array for clean config", (
2645
2657
  }
2646
2658
  });
2647
2659
 
2660
+ // --- doom_loop config-loader deprecation tests (#54) ---
2661
+
2662
+ test("PermissionManager.getConfigIssues returns deprecation for doom_loop in global config", () => {
2663
+ const config: GlobalPermissionConfig = {
2664
+ defaultPolicy: {
2665
+ tools: "ask",
2666
+ bash: "ask",
2667
+ mcp: "ask",
2668
+ skills: "ask",
2669
+ special: "ask",
2670
+ },
2671
+ tools: {},
2672
+ bash: {},
2673
+ mcp: {},
2674
+ skills: {},
2675
+ special: { doom_loop: "deny" },
2676
+ };
2677
+ const { manager, cleanup } = createManager(config);
2678
+ try {
2679
+ const issues = manager.getConfigIssues();
2680
+ assert.equal(issues.length, 1);
2681
+ assert.ok(issues[0].includes("doom_loop"));
2682
+ } finally {
2683
+ cleanup();
2684
+ }
2685
+ });
2686
+
2687
+ test("checkPermission doom_loop falls through to defaultPolicy.tools when stripped by config-loader", () => {
2688
+ const { manager, cleanup } = createManager({
2689
+ defaultPolicy: {
2690
+ tools: "allow",
2691
+ bash: "ask",
2692
+ mcp: "ask",
2693
+ skills: "ask",
2694
+ special: "deny",
2695
+ },
2696
+ tools: {},
2697
+ bash: {},
2698
+ mcp: {},
2699
+ skills: {},
2700
+ special: { doom_loop: "ask" },
2701
+ });
2702
+ try {
2703
+ const result = manager.checkPermission("doom_loop", {});
2704
+ // doom_loop stripped by config-loader — falls through to defaultPolicy.tools
2705
+ assert.equal(result.state, "allow");
2706
+ assert.equal(result.matchedPattern, undefined);
2707
+ } finally {
2708
+ cleanup();
2709
+ }
2710
+ });
2711
+
2648
2712
  // --- session-scoped approval tests (#45) ---
2649
2713
 
2650
2714
  test("session approval: first prompt with 'Yes, for this session' skips subsequent prompts under same prefix", async () => {
@@ -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
+ });