@gotgenes/pi-permission-system 3.9.0 → 3.11.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 +36 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/defaults.ts +6 -0
- package/src/handlers/lifecycle.ts +1 -1
- package/src/handlers/tool-call.ts +20 -19
- package/src/permission-manager.ts +110 -128
- package/src/rule.ts +5 -0
- package/src/runtime.ts +3 -3
- package/src/session-rules.ts +54 -0
- package/src/synthesize.ts +152 -0
- package/src/types.ts +1 -1
- package/tests/handlers/before-agent-start.test.ts +3 -4
- package/tests/handlers/input.test.ts +3 -4
- package/tests/handlers/lifecycle.test.ts +7 -8
- package/tests/handlers/tool-call.test.ts +27 -19
- package/tests/permission-system.test.ts +195 -1
- package/tests/rule.test.ts +31 -0
- package/tests/runtime.test.ts +5 -4
- package/tests/session-rules.test.ts +226 -0
- package/tests/synthesize.test.ts +413 -0
- package/src/session-approval-cache.ts +0 -81
- package/tests/session-approval-cache.test.ts +0 -131
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { evaluate } from "../src/rule";
|
|
4
|
+
import { deriveApprovalPattern, SessionRules } from "../src/session-rules";
|
|
5
|
+
|
|
6
|
+
// ── SessionRules ───────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
describe("SessionRules", () => {
|
|
9
|
+
describe("getRuleset", () => {
|
|
10
|
+
it("returns an empty ruleset initially", () => {
|
|
11
|
+
const rules = new SessionRules();
|
|
12
|
+
expect(rules.getRuleset()).toEqual([]);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("returns a ruleset containing approved rules", () => {
|
|
16
|
+
const rules = new SessionRules();
|
|
17
|
+
rules.approve("external_directory", "/other/project/*");
|
|
18
|
+
expect(rules.getRuleset()).toEqual([
|
|
19
|
+
{
|
|
20
|
+
surface: "external_directory",
|
|
21
|
+
pattern: "/other/project/*",
|
|
22
|
+
action: "allow",
|
|
23
|
+
layer: "session",
|
|
24
|
+
},
|
|
25
|
+
]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("returns a defensive copy — mutations do not affect internal state", () => {
|
|
29
|
+
const rules = new SessionRules();
|
|
30
|
+
rules.approve("external_directory", "/other/project/*");
|
|
31
|
+
const copy = rules.getRuleset();
|
|
32
|
+
copy.push({ surface: "bash", pattern: "*", action: "deny" });
|
|
33
|
+
expect(rules.getRuleset()).toHaveLength(1);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("accumulates multiple approved patterns", () => {
|
|
37
|
+
const rules = new SessionRules();
|
|
38
|
+
rules.approve("external_directory", "/project-a/*");
|
|
39
|
+
rules.approve("external_directory", "/project-b/*");
|
|
40
|
+
expect(rules.getRuleset()).toHaveLength(2);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("clear", () => {
|
|
45
|
+
it("removes all session rules", () => {
|
|
46
|
+
const rules = new SessionRules();
|
|
47
|
+
rules.approve("external_directory", "/other/project/*");
|
|
48
|
+
rules.approve("external_directory", "/another/path/*");
|
|
49
|
+
rules.clear();
|
|
50
|
+
expect(rules.getRuleset()).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("allows new approvals after clearing", () => {
|
|
54
|
+
const rules = new SessionRules();
|
|
55
|
+
rules.approve("external_directory", "/old/path/*");
|
|
56
|
+
rules.clear();
|
|
57
|
+
rules.approve("external_directory", "/new/path/*");
|
|
58
|
+
expect(rules.getRuleset()).toHaveLength(1);
|
|
59
|
+
expect(rules.getRuleset()[0].pattern).toBe("/new/path/*");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("evaluate() integration", () => {
|
|
64
|
+
it("returns allow for a path under an approved directory", () => {
|
|
65
|
+
const session = new SessionRules();
|
|
66
|
+
session.approve("external_directory", "/other/project/*");
|
|
67
|
+
const result = evaluate(
|
|
68
|
+
"external_directory",
|
|
69
|
+
"/other/project/src/foo.ts",
|
|
70
|
+
session.getRuleset(),
|
|
71
|
+
);
|
|
72
|
+
expect(result.action).toBe("allow");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("returns ask (default) for a path outside approved directories", () => {
|
|
76
|
+
const session = new SessionRules();
|
|
77
|
+
session.approve("external_directory", "/other/project/*");
|
|
78
|
+
const result = evaluate(
|
|
79
|
+
"external_directory",
|
|
80
|
+
"/other/unrelated/file.ts",
|
|
81
|
+
session.getRuleset(),
|
|
82
|
+
);
|
|
83
|
+
// No rule matches — evaluate returns synthetic rule with default action "ask"
|
|
84
|
+
expect(result.action).toBe("ask");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("does not match a sibling directory that shares a string prefix", () => {
|
|
88
|
+
const session = new SessionRules();
|
|
89
|
+
session.approve("external_directory", "/other/project/*");
|
|
90
|
+
const result = evaluate(
|
|
91
|
+
"external_directory",
|
|
92
|
+
"/other/project-b/foo.ts",
|
|
93
|
+
session.getRuleset(),
|
|
94
|
+
);
|
|
95
|
+
expect(result.action).toBe("ask");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("matches the directory itself (trailing slash)", () => {
|
|
99
|
+
const session = new SessionRules();
|
|
100
|
+
session.approve("external_directory", "/other/project/src/*");
|
|
101
|
+
// The * in wildcardMatch maps to .* which matches zero chars — so /src/ is covered.
|
|
102
|
+
const result = evaluate(
|
|
103
|
+
"external_directory",
|
|
104
|
+
"/other/project/src/",
|
|
105
|
+
session.getRuleset(),
|
|
106
|
+
);
|
|
107
|
+
expect(result.action).toBe("allow");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("handles multiple approved directories", () => {
|
|
111
|
+
const session = new SessionRules();
|
|
112
|
+
session.approve("external_directory", "/project-a/*");
|
|
113
|
+
session.approve("external_directory", "/project-b/*");
|
|
114
|
+
expect(
|
|
115
|
+
evaluate(
|
|
116
|
+
"external_directory",
|
|
117
|
+
"/project-a/foo.ts",
|
|
118
|
+
session.getRuleset(),
|
|
119
|
+
).action,
|
|
120
|
+
).toBe("allow");
|
|
121
|
+
expect(
|
|
122
|
+
evaluate(
|
|
123
|
+
"external_directory",
|
|
124
|
+
"/project-b/bar.ts",
|
|
125
|
+
session.getRuleset(),
|
|
126
|
+
).action,
|
|
127
|
+
).toBe("allow");
|
|
128
|
+
expect(
|
|
129
|
+
evaluate(
|
|
130
|
+
"external_directory",
|
|
131
|
+
"/project-c/baz.ts",
|
|
132
|
+
session.getRuleset(),
|
|
133
|
+
).action,
|
|
134
|
+
).toBe("ask");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("does not match a different surface", () => {
|
|
138
|
+
const session = new SessionRules();
|
|
139
|
+
session.approve("external_directory", "/other/project/*");
|
|
140
|
+
const result = evaluate(
|
|
141
|
+
"bash",
|
|
142
|
+
"/other/project/foo.ts",
|
|
143
|
+
session.getRuleset(),
|
|
144
|
+
);
|
|
145
|
+
expect(result.action).toBe("ask");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("returns allow after clearing and re-approving", () => {
|
|
149
|
+
const session = new SessionRules();
|
|
150
|
+
session.approve("external_directory", "/old/project/*");
|
|
151
|
+
session.clear();
|
|
152
|
+
session.approve("external_directory", "/new/project/*");
|
|
153
|
+
expect(
|
|
154
|
+
evaluate(
|
|
155
|
+
"external_directory",
|
|
156
|
+
"/old/project/file.ts",
|
|
157
|
+
session.getRuleset(),
|
|
158
|
+
).action,
|
|
159
|
+
).toBe("ask");
|
|
160
|
+
expect(
|
|
161
|
+
evaluate(
|
|
162
|
+
"external_directory",
|
|
163
|
+
"/new/project/file.ts",
|
|
164
|
+
session.getRuleset(),
|
|
165
|
+
).action,
|
|
166
|
+
).toBe("allow");
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// ── deriveApprovalPattern ──────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
describe("deriveApprovalPattern", () => {
|
|
174
|
+
it("returns parent directory glob for a file path", () => {
|
|
175
|
+
expect(deriveApprovalPattern("/other/project/src/foo.ts")).toBe(
|
|
176
|
+
"/other/project/src/*",
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("returns directory glob when path already ends with separator", () => {
|
|
181
|
+
expect(deriveApprovalPattern("/other/project/src/")).toBe(
|
|
182
|
+
"/other/project/src/*",
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("returns parent directory glob for a directory-like path without trailing separator", () => {
|
|
187
|
+
// Cannot distinguish dir from file — dirname is the safe choice
|
|
188
|
+
expect(deriveApprovalPattern("/other/project/src")).toBe(
|
|
189
|
+
"/other/project/*",
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("handles root path", () => {
|
|
194
|
+
expect(deriveApprovalPattern("/")).toBe("/*");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("handles single-level path", () => {
|
|
198
|
+
expect(deriveApprovalPattern("/foo")).toBe("/*");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("produces a pattern that matches paths under the approved directory", () => {
|
|
202
|
+
const pattern = deriveApprovalPattern("/other/project/src/foo.ts");
|
|
203
|
+
const session = new SessionRules();
|
|
204
|
+
session.approve("external_directory", pattern);
|
|
205
|
+
expect(
|
|
206
|
+
evaluate(
|
|
207
|
+
"external_directory",
|
|
208
|
+
"/other/project/src/bar.ts",
|
|
209
|
+
session.getRuleset(),
|
|
210
|
+
).action,
|
|
211
|
+
).toBe("allow");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("produces a pattern that does not match sibling directories", () => {
|
|
215
|
+
const pattern = deriveApprovalPattern("/other/project/src/foo.ts");
|
|
216
|
+
const session = new SessionRules();
|
|
217
|
+
session.approve("external_directory", pattern);
|
|
218
|
+
expect(
|
|
219
|
+
evaluate(
|
|
220
|
+
"external_directory",
|
|
221
|
+
"/other/project/lib/bar.ts",
|
|
222
|
+
session.getRuleset(),
|
|
223
|
+
).action,
|
|
224
|
+
).toBe("ask");
|
|
225
|
+
});
|
|
226
|
+
});
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { evaluate } from "../src/rule";
|
|
3
|
+
import {
|
|
4
|
+
composeRuleset,
|
|
5
|
+
synthesizeBaseline,
|
|
6
|
+
synthesizeDefaults,
|
|
7
|
+
synthesizeOverrides,
|
|
8
|
+
} from "../src/synthesize";
|
|
9
|
+
import type { PermissionDefaultPolicy } from "../src/types";
|
|
10
|
+
|
|
11
|
+
const ALL_ASK: PermissionDefaultPolicy = {
|
|
12
|
+
tools: "ask",
|
|
13
|
+
bash: "ask",
|
|
14
|
+
mcp: "ask",
|
|
15
|
+
skills: "ask",
|
|
16
|
+
special: "ask",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const ALL_ALLOW: PermissionDefaultPolicy = {
|
|
20
|
+
tools: "allow",
|
|
21
|
+
bash: "allow",
|
|
22
|
+
mcp: "allow",
|
|
23
|
+
skills: "allow",
|
|
24
|
+
special: "allow",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// ── synthesizeDefaults ─────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
describe("synthesizeDefaults", () => {
|
|
30
|
+
test("emits 5 catch-all rules with layer 'default'", () => {
|
|
31
|
+
const rules = synthesizeDefaults(ALL_ASK);
|
|
32
|
+
expect(rules).toHaveLength(5);
|
|
33
|
+
for (const rule of rules) {
|
|
34
|
+
expect(rule.layer).toBe("default");
|
|
35
|
+
expect(rule.pattern).toBe("*");
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("emits universal catch-all for tools default", () => {
|
|
40
|
+
const rules = synthesizeDefaults(ALL_ASK);
|
|
41
|
+
const universal = rules.find((r) => r.surface === "*");
|
|
42
|
+
expect(universal).toEqual({
|
|
43
|
+
surface: "*",
|
|
44
|
+
pattern: "*",
|
|
45
|
+
action: "ask",
|
|
46
|
+
layer: "default",
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("emits per-surface catch-alls for bash, mcp, skill, special", () => {
|
|
51
|
+
const rules = synthesizeDefaults(ALL_ASK);
|
|
52
|
+
const surfaces = rules.map((r) => r.surface);
|
|
53
|
+
expect(surfaces).toContain("bash");
|
|
54
|
+
expect(surfaces).toContain("mcp");
|
|
55
|
+
expect(surfaces).toContain("skill");
|
|
56
|
+
expect(surfaces).toContain("special");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("reflects non-ask actions correctly", () => {
|
|
60
|
+
const rules = synthesizeDefaults(ALL_ALLOW);
|
|
61
|
+
for (const rule of rules) {
|
|
62
|
+
expect(rule.action).toBe("allow");
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("mixed defaults produce correct per-surface actions", () => {
|
|
67
|
+
const mixed: PermissionDefaultPolicy = {
|
|
68
|
+
tools: "allow",
|
|
69
|
+
bash: "deny",
|
|
70
|
+
mcp: "ask",
|
|
71
|
+
skills: "allow",
|
|
72
|
+
special: "deny",
|
|
73
|
+
};
|
|
74
|
+
const rules = synthesizeDefaults(mixed);
|
|
75
|
+
const get = (surface: string) => rules.find((r) => r.surface === surface);
|
|
76
|
+
expect(get("*")?.action).toBe("allow"); // tools default
|
|
77
|
+
expect(get("bash")?.action).toBe("deny");
|
|
78
|
+
expect(get("mcp")?.action).toBe("ask");
|
|
79
|
+
expect(get("skill")?.action).toBe("allow");
|
|
80
|
+
expect(get("special")?.action).toBe("deny");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("default rules catch any surface via the universal '*' entry", () => {
|
|
84
|
+
const rules = synthesizeDefaults(ALL_ASK);
|
|
85
|
+
// A brand-new surface "future_tool" should be caught by the universal rule.
|
|
86
|
+
const result = evaluate("future_tool", "*", rules);
|
|
87
|
+
expect(result.action).toBe("ask");
|
|
88
|
+
expect(result.layer).toBe("default");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("specific surface default beats universal default (last-match-wins)", () => {
|
|
92
|
+
const mixed: PermissionDefaultPolicy = {
|
|
93
|
+
tools: "allow",
|
|
94
|
+
bash: "deny",
|
|
95
|
+
mcp: "ask",
|
|
96
|
+
skills: "ask",
|
|
97
|
+
special: "ask",
|
|
98
|
+
};
|
|
99
|
+
const rules = synthesizeDefaults(mixed);
|
|
100
|
+
// For bash, the specific bash rule (later in array) beats the universal rule.
|
|
101
|
+
const result = evaluate("bash", "git status", rules);
|
|
102
|
+
expect(result.action).toBe("deny");
|
|
103
|
+
expect(result.layer).toBe("default");
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// ── synthesizeOverrides ────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
describe("synthesizeOverrides", () => {
|
|
110
|
+
test("returns empty ruleset for empty input", () => {
|
|
111
|
+
expect(synthesizeOverrides([])).toEqual([]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("returns empty ruleset when no scope has overrides", () => {
|
|
115
|
+
expect(synthesizeOverrides([{}, {}, {}])).toEqual([]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("emits a bash override rule for each scope that defines tools.bash", () => {
|
|
119
|
+
const rules = synthesizeOverrides([{ bash: "allow" }]);
|
|
120
|
+
expect(rules).toEqual([
|
|
121
|
+
{ surface: "bash", pattern: "*", action: "allow", layer: "override" },
|
|
122
|
+
]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("emits an mcp override rule for each scope that defines tools.mcp", () => {
|
|
126
|
+
const rules = synthesizeOverrides([{ mcp: "deny" }]);
|
|
127
|
+
expect(rules).toEqual([
|
|
128
|
+
{ surface: "mcp", pattern: "*", action: "deny", layer: "override" },
|
|
129
|
+
]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("emits both bash and mcp override rules when both are defined in a scope", () => {
|
|
133
|
+
const rules = synthesizeOverrides([{ bash: "allow", mcp: "deny" }]);
|
|
134
|
+
expect(rules).toHaveLength(2);
|
|
135
|
+
const bash = rules.find((r) => r.surface === "bash");
|
|
136
|
+
const mcp = rules.find((r) => r.surface === "mcp");
|
|
137
|
+
expect(bash?.action).toBe("allow");
|
|
138
|
+
expect(mcp?.action).toBe("deny");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("later scopes produce later rules (higher priority via last-match-wins)", () => {
|
|
142
|
+
const rules = synthesizeOverrides([{ bash: "deny" }, { bash: "allow" }]);
|
|
143
|
+
const result = evaluate("bash", "git status", rules);
|
|
144
|
+
expect(result.action).toBe("allow"); // later scope wins
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("skips undefined fields and emits nothing for them", () => {
|
|
148
|
+
const rules = synthesizeOverrides([
|
|
149
|
+
{ bash: undefined, mcp: "allow" },
|
|
150
|
+
{ bash: "deny", mcp: undefined },
|
|
151
|
+
]);
|
|
152
|
+
const bashRules = rules.filter((r) => r.surface === "bash");
|
|
153
|
+
const mcpRules = rules.filter((r) => r.surface === "mcp");
|
|
154
|
+
expect(bashRules).toHaveLength(1);
|
|
155
|
+
expect(mcpRules).toHaveLength(1);
|
|
156
|
+
expect(bashRules[0].action).toBe("deny");
|
|
157
|
+
expect(mcpRules[0].action).toBe("allow");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("override rules all have layer 'override'", () => {
|
|
161
|
+
const rules = synthesizeOverrides([
|
|
162
|
+
{ bash: "allow", mcp: "deny" },
|
|
163
|
+
{ bash: "deny" },
|
|
164
|
+
]);
|
|
165
|
+
for (const rule of rules) {
|
|
166
|
+
expect(rule.layer).toBe("override");
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// ── synthesizeBaseline ─────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
describe("synthesizeBaseline", () => {
|
|
174
|
+
test("returns empty ruleset when config has no mcp allow rules", () => {
|
|
175
|
+
const configRules = [
|
|
176
|
+
{
|
|
177
|
+
surface: "mcp",
|
|
178
|
+
pattern: "*",
|
|
179
|
+
action: "deny" as const,
|
|
180
|
+
layer: "config" as const,
|
|
181
|
+
},
|
|
182
|
+
];
|
|
183
|
+
expect(synthesizeBaseline(configRules)).toEqual([]);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("returns empty ruleset for empty config rules", () => {
|
|
187
|
+
expect(synthesizeBaseline([])).toEqual([]);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("synthesizes 5 baseline rules when at least one mcp allow config rule exists", () => {
|
|
191
|
+
const configRules = [
|
|
192
|
+
{
|
|
193
|
+
surface: "mcp",
|
|
194
|
+
pattern: "exa:*",
|
|
195
|
+
action: "allow" as const,
|
|
196
|
+
layer: "config" as const,
|
|
197
|
+
},
|
|
198
|
+
];
|
|
199
|
+
const rules = synthesizeBaseline(configRules);
|
|
200
|
+
expect(rules).toHaveLength(5);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("baseline rules all have layer 'baseline' and action 'allow'", () => {
|
|
204
|
+
const configRules = [
|
|
205
|
+
{
|
|
206
|
+
surface: "mcp",
|
|
207
|
+
pattern: "exa:*",
|
|
208
|
+
action: "allow" as const,
|
|
209
|
+
layer: "config" as const,
|
|
210
|
+
},
|
|
211
|
+
];
|
|
212
|
+
const rules = synthesizeBaseline(configRules);
|
|
213
|
+
for (const rule of rules) {
|
|
214
|
+
expect(rule.layer).toBe("baseline");
|
|
215
|
+
expect(rule.action).toBe("allow");
|
|
216
|
+
expect(rule.surface).toBe("mcp");
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("baseline rules cover the 5 MCP metadata targets", () => {
|
|
221
|
+
const configRules = [
|
|
222
|
+
{
|
|
223
|
+
surface: "mcp",
|
|
224
|
+
pattern: "exa:*",
|
|
225
|
+
action: "allow" as const,
|
|
226
|
+
layer: "config" as const,
|
|
227
|
+
},
|
|
228
|
+
];
|
|
229
|
+
const rules = synthesizeBaseline(configRules);
|
|
230
|
+
const patterns = rules.map((r) => r.pattern);
|
|
231
|
+
expect(patterns).toContain("mcp_status");
|
|
232
|
+
expect(patterns).toContain("mcp_list");
|
|
233
|
+
expect(patterns).toContain("mcp_search");
|
|
234
|
+
expect(patterns).toContain("mcp_describe");
|
|
235
|
+
expect(patterns).toContain("mcp_connect");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("baseline is NOT synthesized when allow rule is on a non-mcp surface", () => {
|
|
239
|
+
const configRules = [
|
|
240
|
+
{
|
|
241
|
+
surface: "bash",
|
|
242
|
+
pattern: "git *",
|
|
243
|
+
action: "allow" as const,
|
|
244
|
+
layer: "config" as const,
|
|
245
|
+
},
|
|
246
|
+
];
|
|
247
|
+
expect(synthesizeBaseline(configRules)).toEqual([]);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("baseline is NOT synthesized when defaults.mcp === 'allow' but no config allow rules", () => {
|
|
251
|
+
// defaults.mcp === 'allow' is handled by the synthesized default catch-all, not baseline.
|
|
252
|
+
const configRules = [
|
|
253
|
+
{
|
|
254
|
+
surface: "mcp",
|
|
255
|
+
pattern: "*",
|
|
256
|
+
action: "deny" as const,
|
|
257
|
+
layer: "config" as const,
|
|
258
|
+
},
|
|
259
|
+
];
|
|
260
|
+
expect(synthesizeBaseline(configRules)).toEqual([]);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("baseline auto-allows mcp_status when an mcp allow rule exists", () => {
|
|
264
|
+
const configRules = [
|
|
265
|
+
{
|
|
266
|
+
surface: "mcp",
|
|
267
|
+
pattern: "exa:*",
|
|
268
|
+
action: "allow" as const,
|
|
269
|
+
layer: "config" as const,
|
|
270
|
+
},
|
|
271
|
+
];
|
|
272
|
+
const rules = synthesizeBaseline(configRules);
|
|
273
|
+
const result = evaluate("mcp", "mcp_status", rules);
|
|
274
|
+
expect(result.action).toBe("allow");
|
|
275
|
+
expect(result.layer).toBe("baseline");
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// ── composeRuleset ─────────────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
describe("composeRuleset", () => {
|
|
282
|
+
test("returns concatenation of all layers in order", () => {
|
|
283
|
+
const defaults = synthesizeDefaults(ALL_ASK);
|
|
284
|
+
const baseline = synthesizeBaseline([
|
|
285
|
+
{ surface: "mcp", pattern: "exa:*", action: "allow", layer: "config" },
|
|
286
|
+
]);
|
|
287
|
+
const overrides = synthesizeOverrides([{ bash: "allow" }]);
|
|
288
|
+
const config = [
|
|
289
|
+
{ surface: "bash", pattern: "rm -rf *", action: "deny" as const },
|
|
290
|
+
];
|
|
291
|
+
const composed = composeRuleset(defaults, baseline, overrides, config);
|
|
292
|
+
expect(composed.length).toBe(
|
|
293
|
+
defaults.length + baseline.length + overrides.length + config.length,
|
|
294
|
+
);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("defaults come first (lowest priority), config comes last (highest priority)", () => {
|
|
298
|
+
const defaults = [
|
|
299
|
+
{
|
|
300
|
+
surface: "bash",
|
|
301
|
+
pattern: "*",
|
|
302
|
+
action: "ask" as const,
|
|
303
|
+
layer: "default" as const,
|
|
304
|
+
},
|
|
305
|
+
];
|
|
306
|
+
const baseline: never[] = [];
|
|
307
|
+
const overrides = [
|
|
308
|
+
{
|
|
309
|
+
surface: "bash",
|
|
310
|
+
pattern: "*",
|
|
311
|
+
action: "allow" as const,
|
|
312
|
+
layer: "override" as const,
|
|
313
|
+
},
|
|
314
|
+
];
|
|
315
|
+
const config = [
|
|
316
|
+
{
|
|
317
|
+
surface: "bash",
|
|
318
|
+
pattern: "*",
|
|
319
|
+
action: "deny" as const,
|
|
320
|
+
layer: "config" as const,
|
|
321
|
+
},
|
|
322
|
+
];
|
|
323
|
+
const composed = composeRuleset(defaults, baseline, overrides, config);
|
|
324
|
+
// Last-match-wins: config is last → deny wins for any bash command.
|
|
325
|
+
const result = evaluate("bash", "echo hello", composed);
|
|
326
|
+
expect(result.action).toBe("deny");
|
|
327
|
+
expect(result.layer).toBe("config");
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("override beats default when no config rule exists", () => {
|
|
331
|
+
const defaults = [
|
|
332
|
+
{
|
|
333
|
+
surface: "bash",
|
|
334
|
+
pattern: "*",
|
|
335
|
+
action: "ask" as const,
|
|
336
|
+
layer: "default" as const,
|
|
337
|
+
},
|
|
338
|
+
];
|
|
339
|
+
const overrides = [
|
|
340
|
+
{
|
|
341
|
+
surface: "bash",
|
|
342
|
+
pattern: "*",
|
|
343
|
+
action: "allow" as const,
|
|
344
|
+
layer: "override" as const,
|
|
345
|
+
},
|
|
346
|
+
];
|
|
347
|
+
const composed = composeRuleset(defaults, [], overrides, []);
|
|
348
|
+
const result = evaluate("bash", "echo hello", composed);
|
|
349
|
+
expect(result.action).toBe("allow");
|
|
350
|
+
expect(result.layer).toBe("override");
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("baseline beats default but override beats baseline", () => {
|
|
354
|
+
const defaults = [
|
|
355
|
+
{
|
|
356
|
+
surface: "mcp",
|
|
357
|
+
pattern: "*",
|
|
358
|
+
action: "ask" as const,
|
|
359
|
+
layer: "default" as const,
|
|
360
|
+
},
|
|
361
|
+
];
|
|
362
|
+
const baseline = [
|
|
363
|
+
{
|
|
364
|
+
surface: "mcp",
|
|
365
|
+
pattern: "mcp_status",
|
|
366
|
+
action: "allow" as const,
|
|
367
|
+
layer: "baseline" as const,
|
|
368
|
+
},
|
|
369
|
+
];
|
|
370
|
+
const overrides = [
|
|
371
|
+
{
|
|
372
|
+
surface: "mcp",
|
|
373
|
+
pattern: "*",
|
|
374
|
+
action: "deny" as const,
|
|
375
|
+
layer: "override" as const,
|
|
376
|
+
},
|
|
377
|
+
];
|
|
378
|
+
const composed = composeRuleset(defaults, baseline, overrides, []);
|
|
379
|
+
// override beats baseline for mcp_status
|
|
380
|
+
const result = evaluate("mcp", "mcp_status", composed);
|
|
381
|
+
expect(result.action).toBe("deny");
|
|
382
|
+
expect(result.layer).toBe("override");
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("config beats override for specific patterns", () => {
|
|
386
|
+
const overrides = [
|
|
387
|
+
{
|
|
388
|
+
surface: "mcp",
|
|
389
|
+
pattern: "*",
|
|
390
|
+
action: "deny" as const,
|
|
391
|
+
layer: "override" as const,
|
|
392
|
+
},
|
|
393
|
+
];
|
|
394
|
+
const config = [
|
|
395
|
+
{
|
|
396
|
+
surface: "mcp",
|
|
397
|
+
pattern: "exa_web_search",
|
|
398
|
+
action: "allow" as const,
|
|
399
|
+
layer: "config" as const,
|
|
400
|
+
},
|
|
401
|
+
];
|
|
402
|
+
const composed = composeRuleset([], [], overrides, config);
|
|
403
|
+
const result = evaluate("mcp", "exa_web_search", composed);
|
|
404
|
+
expect(result.action).toBe("allow");
|
|
405
|
+
expect(result.layer).toBe("config");
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("handles empty layers gracefully", () => {
|
|
409
|
+
const defaults = synthesizeDefaults(ALL_ASK);
|
|
410
|
+
const composed = composeRuleset(defaults, [], [], []);
|
|
411
|
+
expect(composed).toEqual(defaults);
|
|
412
|
+
});
|
|
413
|
+
});
|