@gotgenes/pi-permission-system 3.10.0 → 4.0.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 +55 -0
- package/README.md +135 -168
- package/config/config.example.json +11 -21
- package/package.json +1 -1
- package/schemas/permissions.schema.json +34 -102
- package/src/config-loader.ts +87 -118
- package/src/defaults.ts +6 -56
- package/src/extension-config.ts +3 -4
- package/src/handlers/tool-call.ts +15 -18
- package/src/normalize.ts +22 -60
- package/src/permission-manager.ts +309 -431
- package/src/rule.ts +5 -0
- package/src/session-rules.ts +1 -1
- package/src/synthesize.ts +87 -0
- package/src/types.ts +13 -19
- package/tests/config-loader.test.ts +113 -63
- package/tests/defaults.test.ts +8 -101
- package/tests/extension-config.test.ts +12 -4
- package/tests/normalize.test.ts +67 -64
- package/tests/permission-system.test.ts +310 -677
- package/tests/rule.test.ts +31 -0
- package/tests/session-rules.test.ts +1 -0
- package/tests/session-start.test.ts +1 -7
- package/tests/synthesize.test.ts +240 -0
package/tests/rule.test.ts
CHANGED
|
@@ -137,4 +137,35 @@ describe("evaluate", () => {
|
|
|
137
137
|
expect(result.pattern).toBe("git status");
|
|
138
138
|
expect(result.action).toBe("ask");
|
|
139
139
|
});
|
|
140
|
+
|
|
141
|
+
test("rule.layer is ignored by evaluate() — matching is identical with or without it", () => {
|
|
142
|
+
const withLayer: Rule = {
|
|
143
|
+
surface: "bash",
|
|
144
|
+
pattern: "git *",
|
|
145
|
+
action: "allow",
|
|
146
|
+
layer: "config",
|
|
147
|
+
};
|
|
148
|
+
const withoutLayer: Rule = {
|
|
149
|
+
surface: "bash",
|
|
150
|
+
pattern: "git *",
|
|
151
|
+
action: "allow",
|
|
152
|
+
};
|
|
153
|
+
const withDefault: Rule = {
|
|
154
|
+
surface: "bash",
|
|
155
|
+
pattern: "*",
|
|
156
|
+
action: "ask",
|
|
157
|
+
layer: "default",
|
|
158
|
+
};
|
|
159
|
+
// Both rules with and without layer field produce the same match.
|
|
160
|
+
expect(evaluate("bash", "git status", [withLayer]).action).toBe("allow");
|
|
161
|
+
expect(evaluate("bash", "git status", [withoutLayer]).action).toBe("allow");
|
|
162
|
+
// Layer metadata does not affect last-match-wins ordering.
|
|
163
|
+
const ruleset: Rule[] = [withDefault, withLayer];
|
|
164
|
+
expect(evaluate("bash", "git status", ruleset)).toEqual(withLayer);
|
|
165
|
+
// A rule with layer: "default" still wins if it is last in the array.
|
|
166
|
+
const reversedRuleset: Rule[] = [withLayer, withDefault];
|
|
167
|
+
expect(evaluate("bash", "git status", reversedRuleset)).toEqual(
|
|
168
|
+
withDefault,
|
|
169
|
+
);
|
|
170
|
+
});
|
|
140
171
|
});
|
|
@@ -27,13 +27,7 @@ describe("session_start handler consolidation", () => {
|
|
|
27
27
|
mkdirSync(dirname(globalConfigPath), { recursive: true });
|
|
28
28
|
|
|
29
29
|
const config: ScopeConfig = {
|
|
30
|
-
|
|
31
|
-
tools: "ask",
|
|
32
|
-
bash: "ask",
|
|
33
|
-
mcp: "ask",
|
|
34
|
-
skills: "ask",
|
|
35
|
-
special: "ask",
|
|
36
|
-
},
|
|
30
|
+
permission: { "*": "ask" },
|
|
37
31
|
};
|
|
38
32
|
writeFileSync(
|
|
39
33
|
globalConfigPath,
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { evaluate } from "../src/rule";
|
|
3
|
+
import {
|
|
4
|
+
composeRuleset,
|
|
5
|
+
synthesizeBaseline,
|
|
6
|
+
synthesizeDefaults,
|
|
7
|
+
} from "../src/synthesize";
|
|
8
|
+
|
|
9
|
+
// ── synthesizeDefaults ─────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
describe("synthesizeDefaults", () => {
|
|
12
|
+
test("emits a single universal catch-all rule with layer 'default'", () => {
|
|
13
|
+
const rules = synthesizeDefaults("ask");
|
|
14
|
+
expect(rules).toHaveLength(1);
|
|
15
|
+
expect(rules[0]).toEqual({
|
|
16
|
+
surface: "*",
|
|
17
|
+
pattern: "*",
|
|
18
|
+
action: "ask",
|
|
19
|
+
layer: "default",
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("reflects the supplied PermissionState as the action", () => {
|
|
24
|
+
expect(synthesizeDefaults("allow")[0].action).toBe("allow");
|
|
25
|
+
expect(synthesizeDefaults("deny")[0].action).toBe("deny");
|
|
26
|
+
expect(synthesizeDefaults("ask")[0].action).toBe("ask");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("universal rule catches any surface via wildcardMatch", () => {
|
|
30
|
+
const rules = synthesizeDefaults("ask");
|
|
31
|
+
expect(evaluate("read", "*", rules).action).toBe("ask");
|
|
32
|
+
expect(evaluate("bash", "git status", rules).action).toBe("ask");
|
|
33
|
+
expect(evaluate("external_directory", "*", rules).action).toBe("ask");
|
|
34
|
+
expect(evaluate("future_surface", "*", rules).action).toBe("ask");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("universal rule has layer 'default'", () => {
|
|
38
|
+
const rules = synthesizeDefaults("allow");
|
|
39
|
+
expect(evaluate("read", "*", rules).layer).toBe("default");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// ── synthesizeBaseline ─────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
describe("synthesizeBaseline", () => {
|
|
46
|
+
test("returns empty ruleset when config has no mcp allow rules", () => {
|
|
47
|
+
const configRules = [
|
|
48
|
+
{
|
|
49
|
+
surface: "mcp",
|
|
50
|
+
pattern: "*",
|
|
51
|
+
action: "deny" as const,
|
|
52
|
+
layer: "config" as const,
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
expect(synthesizeBaseline(configRules)).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("returns empty ruleset for empty config rules", () => {
|
|
59
|
+
expect(synthesizeBaseline([])).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("synthesizes 5 baseline rules when at least one mcp allow config rule exists", () => {
|
|
63
|
+
const configRules = [
|
|
64
|
+
{
|
|
65
|
+
surface: "mcp",
|
|
66
|
+
pattern: "exa:*",
|
|
67
|
+
action: "allow" as const,
|
|
68
|
+
layer: "config" as const,
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
const rules = synthesizeBaseline(configRules);
|
|
72
|
+
expect(rules).toHaveLength(5);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("baseline rules all have layer 'baseline' and action 'allow'", () => {
|
|
76
|
+
const configRules = [
|
|
77
|
+
{
|
|
78
|
+
surface: "mcp",
|
|
79
|
+
pattern: "exa:*",
|
|
80
|
+
action: "allow" as const,
|
|
81
|
+
layer: "config" as const,
|
|
82
|
+
},
|
|
83
|
+
];
|
|
84
|
+
const rules = synthesizeBaseline(configRules);
|
|
85
|
+
for (const rule of rules) {
|
|
86
|
+
expect(rule.layer).toBe("baseline");
|
|
87
|
+
expect(rule.action).toBe("allow");
|
|
88
|
+
expect(rule.surface).toBe("mcp");
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("baseline rules cover the 5 MCP metadata targets", () => {
|
|
93
|
+
const configRules = [
|
|
94
|
+
{
|
|
95
|
+
surface: "mcp",
|
|
96
|
+
pattern: "exa:*",
|
|
97
|
+
action: "allow" as const,
|
|
98
|
+
layer: "config" as const,
|
|
99
|
+
},
|
|
100
|
+
];
|
|
101
|
+
const rules = synthesizeBaseline(configRules);
|
|
102
|
+
const patterns = rules.map((r) => r.pattern);
|
|
103
|
+
expect(patterns).toContain("mcp_status");
|
|
104
|
+
expect(patterns).toContain("mcp_list");
|
|
105
|
+
expect(patterns).toContain("mcp_search");
|
|
106
|
+
expect(patterns).toContain("mcp_describe");
|
|
107
|
+
expect(patterns).toContain("mcp_connect");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("baseline is NOT synthesized when allow rule is on a non-mcp surface", () => {
|
|
111
|
+
const configRules = [
|
|
112
|
+
{
|
|
113
|
+
surface: "bash",
|
|
114
|
+
pattern: "git *",
|
|
115
|
+
action: "allow" as const,
|
|
116
|
+
layer: "config" as const,
|
|
117
|
+
},
|
|
118
|
+
];
|
|
119
|
+
expect(synthesizeBaseline(configRules)).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("baseline auto-allows mcp_status when an mcp allow rule exists", () => {
|
|
123
|
+
const configRules = [
|
|
124
|
+
{
|
|
125
|
+
surface: "mcp",
|
|
126
|
+
pattern: "exa:*",
|
|
127
|
+
action: "allow" as const,
|
|
128
|
+
layer: "config" as const,
|
|
129
|
+
},
|
|
130
|
+
];
|
|
131
|
+
const rules = synthesizeBaseline(configRules);
|
|
132
|
+
const result = evaluate("mcp", "mcp_status", rules);
|
|
133
|
+
expect(result.action).toBe("allow");
|
|
134
|
+
expect(result.layer).toBe("baseline");
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// ── composeRuleset ─────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
describe("composeRuleset", () => {
|
|
141
|
+
test("returns concatenation of all layers in order", () => {
|
|
142
|
+
const defaults = synthesizeDefaults("ask");
|
|
143
|
+
const baseline = synthesizeBaseline([
|
|
144
|
+
{ surface: "mcp", pattern: "exa:*", action: "allow", layer: "config" },
|
|
145
|
+
]);
|
|
146
|
+
const config = [
|
|
147
|
+
{ surface: "bash", pattern: "rm -rf *", action: "deny" as const },
|
|
148
|
+
];
|
|
149
|
+
const composed = composeRuleset(defaults, baseline, config);
|
|
150
|
+
expect(composed.length).toBe(
|
|
151
|
+
defaults.length + baseline.length + config.length,
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("defaults come first (lowest priority), config comes last (highest priority)", () => {
|
|
156
|
+
const defaults = synthesizeDefaults("ask");
|
|
157
|
+
const config = [
|
|
158
|
+
{
|
|
159
|
+
surface: "bash",
|
|
160
|
+
pattern: "*",
|
|
161
|
+
action: "deny" as const,
|
|
162
|
+
layer: "config" as const,
|
|
163
|
+
},
|
|
164
|
+
];
|
|
165
|
+
const composed = composeRuleset(defaults, [], config);
|
|
166
|
+
const result = evaluate("bash", "echo hello", composed);
|
|
167
|
+
expect(result.action).toBe("deny");
|
|
168
|
+
expect(result.layer).toBe("config");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("config beats default for matching patterns", () => {
|
|
172
|
+
const defaults = synthesizeDefaults("ask");
|
|
173
|
+
const config = [
|
|
174
|
+
{
|
|
175
|
+
surface: "read",
|
|
176
|
+
pattern: "*",
|
|
177
|
+
action: "allow" as const,
|
|
178
|
+
layer: "config" as const,
|
|
179
|
+
},
|
|
180
|
+
];
|
|
181
|
+
const composed = composeRuleset(defaults, [], config);
|
|
182
|
+
const result = evaluate("read", "*", composed);
|
|
183
|
+
expect(result.action).toBe("allow");
|
|
184
|
+
expect(result.layer).toBe("config");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("baseline beats default but config beats baseline", () => {
|
|
188
|
+
const defaults = synthesizeDefaults("ask");
|
|
189
|
+
const baseline = [
|
|
190
|
+
{
|
|
191
|
+
surface: "mcp",
|
|
192
|
+
pattern: "mcp_status",
|
|
193
|
+
action: "allow" as const,
|
|
194
|
+
layer: "baseline" as const,
|
|
195
|
+
},
|
|
196
|
+
];
|
|
197
|
+
const config = [
|
|
198
|
+
{
|
|
199
|
+
surface: "mcp",
|
|
200
|
+
pattern: "mcp_status",
|
|
201
|
+
action: "deny" as const,
|
|
202
|
+
layer: "config" as const,
|
|
203
|
+
},
|
|
204
|
+
];
|
|
205
|
+
const composed = composeRuleset(defaults, baseline, config);
|
|
206
|
+
const result = evaluate("mcp", "mcp_status", composed);
|
|
207
|
+
expect(result.action).toBe("deny");
|
|
208
|
+
expect(result.layer).toBe("config");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("config beats baseline for specific patterns", () => {
|
|
212
|
+
const defaults = synthesizeDefaults("ask");
|
|
213
|
+
const baseline = [
|
|
214
|
+
{
|
|
215
|
+
surface: "mcp",
|
|
216
|
+
pattern: "mcp_status",
|
|
217
|
+
action: "allow" as const,
|
|
218
|
+
layer: "baseline" as const,
|
|
219
|
+
},
|
|
220
|
+
];
|
|
221
|
+
const config = [
|
|
222
|
+
{
|
|
223
|
+
surface: "mcp",
|
|
224
|
+
pattern: "exa_web_search",
|
|
225
|
+
action: "allow" as const,
|
|
226
|
+
layer: "config" as const,
|
|
227
|
+
},
|
|
228
|
+
];
|
|
229
|
+
const composed = composeRuleset(defaults, baseline, config);
|
|
230
|
+
const result = evaluate("mcp", "exa_web_search", composed);
|
|
231
|
+
expect(result.action).toBe("allow");
|
|
232
|
+
expect(result.layer).toBe("config");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("handles empty layers gracefully", () => {
|
|
236
|
+
const defaults = synthesizeDefaults("ask");
|
|
237
|
+
const composed = composeRuleset(defaults, [], []);
|
|
238
|
+
expect(composed).toEqual(defaults);
|
|
239
|
+
});
|
|
240
|
+
});
|