@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 +20 -0
- package/package.json +1 -1
- package/src/permission-manager.ts +69 -37
- package/src/rule.ts +58 -0
- package/src/wildcard-matcher.ts +9 -0
- package/tests/rule.test.ts +158 -0
- package/tests/wildcard-matcher.test.ts +39 -0
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
|
@@ -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
|
|
816
|
-
|
|
817
|
-
|
|
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:
|
|
822
|
-
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
|
|
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:
|
|
834
|
-
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
|
|
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:
|
|
854
|
-
command
|
|
855
|
-
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
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
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:
|
|
879
|
-
matchedPattern:
|
|
880
|
-
target:
|
|
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:
|
|
955
|
+
state: explicitTool ? toolRule.action : merged.defaultPolicy.tools,
|
|
923
956
|
source: "tool",
|
|
924
957
|
};
|
|
925
958
|
}
|
|
926
959
|
|
|
927
|
-
|
|
928
|
-
if (explicitToolPermission) {
|
|
960
|
+
if (explicitTool) {
|
|
929
961
|
return {
|
|
930
962
|
toolName,
|
|
931
|
-
state:
|
|
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
|
+
}
|
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[],
|
|
@@ -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
|
+
});
|