@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 +37 -0
- package/README.md +1 -3
- package/config/config.example.json +0 -1
- package/package.json +1 -1
- package/schemas/permissions.schema.json +2 -6
- package/src/config-loader.ts +2 -1
- package/src/extension-config.ts +0 -1
- package/src/permission-manager.ts +71 -38
- package/src/rule.ts +58 -0
- package/src/types.ts +1 -1
- package/src/wildcard-matcher.ts +9 -0
- package/tests/config-loader.test.ts +5 -4
- package/tests/extension-config.test.ts +7 -2
- package/tests/permission-system.test.ts +72 -8
- package/tests/rule.test.ts +158 -0
- package/tests/wildcard-matcher.test.ts +39 -0
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": { "
|
|
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
|
}
|
package/package.json
CHANGED
|
@@ -60,8 +60,8 @@
|
|
|
60
60
|
"default": "ask"
|
|
61
61
|
},
|
|
62
62
|
"special": {
|
|
63
|
-
"description": "Default permission for special checks (
|
|
64
|
-
"markdownDescription": "Default permission for special checks (`
|
|
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`.",
|
package/src/config-loader.ts
CHANGED
|
@@ -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(["
|
|
53
|
+
const SPECIAL_PERMISSION_KEYS = new Set(["external_directory"]);
|
|
53
54
|
|
|
54
55
|
export function stripJsonComments(input: string): string {
|
|
55
56
|
let output = "";
|
package/src/extension-config.ts
CHANGED
|
@@ -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(["
|
|
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
|
|
815
|
-
|
|
816
|
-
|
|
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:
|
|
821
|
-
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
|
|
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:
|
|
833
|
-
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
|
|
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:
|
|
853
|
-
command
|
|
854
|
-
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
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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:
|
|
878
|
-
matchedPattern:
|
|
879
|
-
target:
|
|
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:
|
|
955
|
+
state: explicitTool ? toolRule.action : merged.defaultPolicy.tools,
|
|
922
956
|
source: "tool",
|
|
923
957
|
};
|
|
924
958
|
}
|
|
925
959
|
|
|
926
|
-
|
|
927
|
-
if (explicitToolPermission) {
|
|
960
|
+
if (explicitTool) {
|
|
928
961
|
return {
|
|
929
962
|
toolName,
|
|
930
|
-
state:
|
|
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 = "
|
|
18
|
+
export type SpecialPermissionName = "external_directory";
|
|
19
19
|
|
|
20
20
|
export type SpecialPermissions = Record<string, PermissionState>;
|
|
21
21
|
|
package/src/wildcard-matcher.ts
CHANGED
|
@@ -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(
|
|
147
|
-
expect(result.issues
|
|
148
|
-
expect(result.
|
|
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
|
|
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, "
|
|
2136
|
-
assert.equal(doomResult.matchedPattern,
|
|
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
|
|
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,
|
|
2591
|
-
assert.
|
|
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
|
|
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: {
|
|
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
|
+
});
|