@gotgenes/pi-permission-system 3.11.0 → 4.0.1
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 +49 -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 -62
- package/src/extension-config.ts +3 -4
- package/src/external-directory.ts +4 -0
- package/src/normalize.ts +22 -60
- package/src/permission-manager.ts +244 -348
- package/src/synthesize.ts +17 -82
- package/src/types.ts +12 -18
- package/tests/bash-external-directory.test.ts +31 -0
- 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 +153 -714
- package/tests/session-start.test.ts +1 -7
- package/tests/synthesize.test.ts +46 -219
|
@@ -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,
|
package/tests/synthesize.test.ts
CHANGED
|
@@ -4,42 +4,15 @@ import {
|
|
|
4
4
|
composeRuleset,
|
|
5
5
|
synthesizeBaseline,
|
|
6
6
|
synthesizeDefaults,
|
|
7
|
-
synthesizeOverrides,
|
|
8
7
|
} 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
8
|
|
|
27
9
|
// ── synthesizeDefaults ─────────────────────────────────────────────────────
|
|
28
10
|
|
|
29
11
|
describe("synthesizeDefaults", () => {
|
|
30
|
-
test("emits
|
|
31
|
-
const rules = synthesizeDefaults(
|
|
32
|
-
expect(rules).toHaveLength(
|
|
33
|
-
|
|
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({
|
|
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({
|
|
43
16
|
surface: "*",
|
|
44
17
|
pattern: "*",
|
|
45
18
|
action: "ask",
|
|
@@ -47,124 +20,23 @@ describe("synthesizeDefaults", () => {
|
|
|
47
20
|
});
|
|
48
21
|
});
|
|
49
22
|
|
|
50
|
-
test("
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
expect(
|
|
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");
|
|
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");
|
|
139
27
|
});
|
|
140
28
|
|
|
141
|
-
test("
|
|
142
|
-
const rules =
|
|
143
|
-
|
|
144
|
-
expect(
|
|
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");
|
|
145
35
|
});
|
|
146
36
|
|
|
147
|
-
test("
|
|
148
|
-
const rules =
|
|
149
|
-
|
|
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
|
-
}
|
|
37
|
+
test("universal rule has layer 'default'", () => {
|
|
38
|
+
const rules = synthesizeDefaults("allow");
|
|
39
|
+
expect(evaluate("read", "*", rules).layer).toBe("default");
|
|
168
40
|
});
|
|
169
41
|
});
|
|
170
42
|
|
|
@@ -247,19 +119,6 @@ describe("synthesizeBaseline", () => {
|
|
|
247
119
|
expect(synthesizeBaseline(configRules)).toEqual([]);
|
|
248
120
|
});
|
|
249
121
|
|
|
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
122
|
test("baseline auto-allows mcp_status when an mcp allow rule exists", () => {
|
|
264
123
|
const configRules = [
|
|
265
124
|
{
|
|
@@ -280,38 +139,21 @@ describe("synthesizeBaseline", () => {
|
|
|
280
139
|
|
|
281
140
|
describe("composeRuleset", () => {
|
|
282
141
|
test("returns concatenation of all layers in order", () => {
|
|
283
|
-
const defaults = synthesizeDefaults(
|
|
142
|
+
const defaults = synthesizeDefaults("ask");
|
|
284
143
|
const baseline = synthesizeBaseline([
|
|
285
144
|
{ surface: "mcp", pattern: "exa:*", action: "allow", layer: "config" },
|
|
286
145
|
]);
|
|
287
|
-
const overrides = synthesizeOverrides([{ bash: "allow" }]);
|
|
288
146
|
const config = [
|
|
289
147
|
{ surface: "bash", pattern: "rm -rf *", action: "deny" as const },
|
|
290
148
|
];
|
|
291
|
-
const composed = composeRuleset(defaults, baseline,
|
|
149
|
+
const composed = composeRuleset(defaults, baseline, config);
|
|
292
150
|
expect(composed.length).toBe(
|
|
293
|
-
defaults.length + baseline.length +
|
|
151
|
+
defaults.length + baseline.length + config.length,
|
|
294
152
|
);
|
|
295
153
|
});
|
|
296
154
|
|
|
297
155
|
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
|
-
];
|
|
156
|
+
const defaults = synthesizeDefaults("ask");
|
|
315
157
|
const config = [
|
|
316
158
|
{
|
|
317
159
|
surface: "bash",
|
|
@@ -320,45 +162,30 @@ describe("composeRuleset", () => {
|
|
|
320
162
|
layer: "config" as const,
|
|
321
163
|
},
|
|
322
164
|
];
|
|
323
|
-
const composed = composeRuleset(defaults,
|
|
324
|
-
// Last-match-wins: config is last → deny wins for any bash command.
|
|
165
|
+
const composed = composeRuleset(defaults, [], config);
|
|
325
166
|
const result = evaluate("bash", "echo hello", composed);
|
|
326
167
|
expect(result.action).toBe("deny");
|
|
327
168
|
expect(result.layer).toBe("config");
|
|
328
169
|
});
|
|
329
170
|
|
|
330
|
-
test("
|
|
331
|
-
const defaults =
|
|
332
|
-
|
|
333
|
-
surface: "bash",
|
|
334
|
-
pattern: "*",
|
|
335
|
-
action: "ask" as const,
|
|
336
|
-
layer: "default" as const,
|
|
337
|
-
},
|
|
338
|
-
];
|
|
339
|
-
const overrides = [
|
|
171
|
+
test("config beats default for matching patterns", () => {
|
|
172
|
+
const defaults = synthesizeDefaults("ask");
|
|
173
|
+
const config = [
|
|
340
174
|
{
|
|
341
|
-
surface: "
|
|
175
|
+
surface: "read",
|
|
342
176
|
pattern: "*",
|
|
343
177
|
action: "allow" as const,
|
|
344
|
-
layer: "
|
|
178
|
+
layer: "config" as const,
|
|
345
179
|
},
|
|
346
180
|
];
|
|
347
|
-
const composed = composeRuleset(defaults, [],
|
|
348
|
-
const result = evaluate("
|
|
181
|
+
const composed = composeRuleset(defaults, [], config);
|
|
182
|
+
const result = evaluate("read", "*", composed);
|
|
349
183
|
expect(result.action).toBe("allow");
|
|
350
|
-
expect(result.layer).toBe("
|
|
184
|
+
expect(result.layer).toBe("config");
|
|
351
185
|
});
|
|
352
186
|
|
|
353
|
-
test("baseline beats default but
|
|
354
|
-
const defaults =
|
|
355
|
-
{
|
|
356
|
-
surface: "mcp",
|
|
357
|
-
pattern: "*",
|
|
358
|
-
action: "ask" as const,
|
|
359
|
-
layer: "default" as const,
|
|
360
|
-
},
|
|
361
|
-
];
|
|
187
|
+
test("baseline beats default but config beats baseline", () => {
|
|
188
|
+
const defaults = synthesizeDefaults("ask");
|
|
362
189
|
const baseline = [
|
|
363
190
|
{
|
|
364
191
|
surface: "mcp",
|
|
@@ -367,28 +194,28 @@ describe("composeRuleset", () => {
|
|
|
367
194
|
layer: "baseline" as const,
|
|
368
195
|
},
|
|
369
196
|
];
|
|
370
|
-
const
|
|
197
|
+
const config = [
|
|
371
198
|
{
|
|
372
199
|
surface: "mcp",
|
|
373
|
-
pattern: "
|
|
200
|
+
pattern: "mcp_status",
|
|
374
201
|
action: "deny" as const,
|
|
375
|
-
layer: "
|
|
202
|
+
layer: "config" as const,
|
|
376
203
|
},
|
|
377
204
|
];
|
|
378
|
-
const composed = composeRuleset(defaults, baseline,
|
|
379
|
-
// override beats baseline for mcp_status
|
|
205
|
+
const composed = composeRuleset(defaults, baseline, config);
|
|
380
206
|
const result = evaluate("mcp", "mcp_status", composed);
|
|
381
207
|
expect(result.action).toBe("deny");
|
|
382
|
-
expect(result.layer).toBe("
|
|
208
|
+
expect(result.layer).toBe("config");
|
|
383
209
|
});
|
|
384
210
|
|
|
385
|
-
test("config beats
|
|
386
|
-
const
|
|
211
|
+
test("config beats baseline for specific patterns", () => {
|
|
212
|
+
const defaults = synthesizeDefaults("ask");
|
|
213
|
+
const baseline = [
|
|
387
214
|
{
|
|
388
215
|
surface: "mcp",
|
|
389
|
-
pattern: "
|
|
390
|
-
action: "
|
|
391
|
-
layer: "
|
|
216
|
+
pattern: "mcp_status",
|
|
217
|
+
action: "allow" as const,
|
|
218
|
+
layer: "baseline" as const,
|
|
392
219
|
},
|
|
393
220
|
];
|
|
394
221
|
const config = [
|
|
@@ -399,15 +226,15 @@ describe("composeRuleset", () => {
|
|
|
399
226
|
layer: "config" as const,
|
|
400
227
|
},
|
|
401
228
|
];
|
|
402
|
-
const composed = composeRuleset(
|
|
229
|
+
const composed = composeRuleset(defaults, baseline, config);
|
|
403
230
|
const result = evaluate("mcp", "exa_web_search", composed);
|
|
404
231
|
expect(result.action).toBe("allow");
|
|
405
232
|
expect(result.layer).toBe("config");
|
|
406
233
|
});
|
|
407
234
|
|
|
408
235
|
test("handles empty layers gracefully", () => {
|
|
409
|
-
const defaults = synthesizeDefaults(
|
|
410
|
-
const composed = composeRuleset(defaults, [], []
|
|
236
|
+
const defaults = synthesizeDefaults("ask");
|
|
237
|
+
const composed = composeRuleset(defaults, [], []);
|
|
411
238
|
expect(composed).toEqual(defaults);
|
|
412
239
|
});
|
|
413
240
|
});
|