@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/src/rule.ts
CHANGED
|
@@ -9,6 +9,11 @@ export interface Rule {
|
|
|
9
9
|
pattern: string;
|
|
10
10
|
/** The permission decision. */
|
|
11
11
|
action: PermissionState;
|
|
12
|
+
/**
|
|
13
|
+
* Origin layer — used to derive PermissionCheckResult.source after evaluation.
|
|
14
|
+
* Not used by evaluate(); purely informational metadata.
|
|
15
|
+
*/
|
|
16
|
+
layer?: "default" | "override" | "baseline" | "config" | "session";
|
|
12
17
|
}
|
|
13
18
|
|
|
14
19
|
/** An ordered list of rules. Later rules take priority (last-match-wins). */
|
package/src/session-rules.ts
CHANGED
|
@@ -15,7 +15,7 @@ export class SessionRules {
|
|
|
15
15
|
|
|
16
16
|
/** Record a wildcard pattern as approved for the given surface. */
|
|
17
17
|
approve(surface: string, pattern: string): void {
|
|
18
|
-
this.rules.push({ surface, pattern, action: "allow" });
|
|
18
|
+
this.rules.push({ surface, pattern, action: "allow", layer: "session" });
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
/** Return a defensive copy of the current session ruleset. */
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { Rule, Ruleset } from "./rule";
|
|
2
|
+
import type { PermissionState } from "./types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Synthesize a single universal catch-all rule from the universal default.
|
|
6
|
+
*
|
|
7
|
+
* Produces one rule:
|
|
8
|
+
* `{ surface: "*", pattern: "*", action: universalDefault, layer: "default" }`
|
|
9
|
+
*
|
|
10
|
+
* Per-surface catch-alls (`bash["*"]`, `mcp["*"]`, etc.) are expressed as
|
|
11
|
+
* regular config rules from `normalizeFlatConfig()` and sit at higher indices
|
|
12
|
+
* in the composed array, so they override this default via last-match-wins.
|
|
13
|
+
*/
|
|
14
|
+
export function synthesizeDefaults(universalDefault: PermissionState): Ruleset {
|
|
15
|
+
return [
|
|
16
|
+
{
|
|
17
|
+
surface: "*",
|
|
18
|
+
pattern: "*",
|
|
19
|
+
action: universalDefault,
|
|
20
|
+
layer: "default",
|
|
21
|
+
},
|
|
22
|
+
];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* MCP metadata operation targets that are auto-allowed when any explicit MCP
|
|
27
|
+
* allow rule exists in the config layer.
|
|
28
|
+
*/
|
|
29
|
+
const MCP_BASELINE_TARGETS: readonly string[] = [
|
|
30
|
+
"mcp_status",
|
|
31
|
+
"mcp_list",
|
|
32
|
+
"mcp_search",
|
|
33
|
+
"mcp_describe",
|
|
34
|
+
"mcp_connect",
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Conditionally synthesize MCP baseline auto-allow rules.
|
|
39
|
+
*
|
|
40
|
+
* Emits allow rules for the 5 MCP metadata targets only when `configRules`
|
|
41
|
+
* contains at least one `surface: "mcp", action: "allow"` rule. This replicates
|
|
42
|
+
* the `hasAnyMcpAllowRule` heuristic as actual rules.
|
|
43
|
+
*
|
|
44
|
+
* When `permission["mcp"]` is `"allow"` (or `mcp["*"]` is `"allow"`), the
|
|
45
|
+
* synthesized config catch-all already covers all MCP targets — no separate
|
|
46
|
+
* baseline rules are needed (and this function is not called in that case).
|
|
47
|
+
*
|
|
48
|
+
* Baseline rules are placed BEFORE config rules in the composed array so
|
|
49
|
+
* that explicit config deny rules can still override them.
|
|
50
|
+
*
|
|
51
|
+
* All rules carry `layer: "baseline"`.
|
|
52
|
+
*/
|
|
53
|
+
export function synthesizeBaseline(configRules: Ruleset): Ruleset {
|
|
54
|
+
const hasAnyMcpAllow = configRules.some(
|
|
55
|
+
(r) => r.surface === "mcp" && r.action === "allow",
|
|
56
|
+
);
|
|
57
|
+
if (!hasAnyMcpAllow) {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
return MCP_BASELINE_TARGETS.map(
|
|
61
|
+
(target): Rule => ({
|
|
62
|
+
surface: "mcp",
|
|
63
|
+
pattern: target,
|
|
64
|
+
action: "allow",
|
|
65
|
+
layer: "baseline",
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Concatenate all rule layers into a single flat ruleset.
|
|
72
|
+
*
|
|
73
|
+
* Priority order (lowest → highest, i.e. earlier index → later index):
|
|
74
|
+
* defaults → baseline → config
|
|
75
|
+
*
|
|
76
|
+
* Session rules are NOT included here — they are appended at call-time inside
|
|
77
|
+
* `checkPermission()` so that the cached composed ruleset remains session-agnostic.
|
|
78
|
+
*
|
|
79
|
+
* `evaluate()` scans from the end, so later layers override earlier ones.
|
|
80
|
+
*/
|
|
81
|
+
export function composeRuleset(
|
|
82
|
+
defaults: Ruleset,
|
|
83
|
+
baseline: Ruleset,
|
|
84
|
+
config: Ruleset,
|
|
85
|
+
): Ruleset {
|
|
86
|
+
return [...defaults, ...baseline, ...config];
|
|
87
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
export type PermissionState = "allow" | "deny" | "ask";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* The on-disk permission shape inside the `"permission"` key.
|
|
5
|
+
* Each key is a surface name; values are either a PermissionState string
|
|
6
|
+
* (shorthand for `{ "*": action }`) or a pattern→action map.
|
|
7
|
+
*/
|
|
8
|
+
export type FlatPermissionConfig = Record<
|
|
9
|
+
string,
|
|
10
|
+
PermissionState | Record<string, PermissionState>
|
|
11
|
+
>;
|
|
12
|
+
|
|
3
13
|
export type BuiltInToolName =
|
|
4
14
|
| "bash"
|
|
5
15
|
| "read"
|
|
@@ -11,28 +21,12 @@ export type BuiltInToolName =
|
|
|
11
21
|
|
|
12
22
|
export type SpecialPermissionName = "external_directory";
|
|
13
23
|
|
|
14
|
-
export interface PermissionDefaultPolicy {
|
|
15
|
-
tools: PermissionState;
|
|
16
|
-
bash: PermissionState;
|
|
17
|
-
mcp: PermissionState;
|
|
18
|
-
skills: PermissionState;
|
|
19
|
-
special: PermissionState;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
24
|
/**
|
|
23
25
|
* Per-scope permission config shape after loading and validation.
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* This replaces the former AgentPermissions / GlobalPermissionConfig
|
|
27
|
-
* interfaces (removed in #56).
|
|
26
|
+
* Holds only the flat permission map — all policy is expressed there.
|
|
28
27
|
*/
|
|
29
28
|
export interface ScopeConfig {
|
|
30
|
-
|
|
31
|
-
tools?: Record<string, PermissionState>;
|
|
32
|
-
bash?: Record<string, PermissionState>;
|
|
33
|
-
mcp?: Record<string, PermissionState>;
|
|
34
|
-
skills?: Record<string, PermissionState>;
|
|
35
|
-
special?: Record<string, PermissionState>;
|
|
29
|
+
permission?: FlatPermissionConfig;
|
|
36
30
|
}
|
|
37
31
|
|
|
38
32
|
export interface PermissionCheckResult {
|
|
@@ -41,5 +35,5 @@ export interface PermissionCheckResult {
|
|
|
41
35
|
matchedPattern?: string;
|
|
42
36
|
command?: string;
|
|
43
37
|
target?: string;
|
|
44
|
-
source: "tool" | "bash" | "mcp" | "skill" | "special" | "default";
|
|
38
|
+
source: "tool" | "bash" | "mcp" | "skill" | "special" | "default" | "session";
|
|
45
39
|
}
|
|
@@ -20,7 +20,7 @@ describe("loadUnifiedConfig", () => {
|
|
|
20
20
|
rmSync(tempDir, { recursive: true, force: true });
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
-
it("parses a valid JSON file with runtime knobs and
|
|
23
|
+
it("parses a valid JSON file with runtime knobs and flat permission", () => {
|
|
24
24
|
const configPath = join(tempDir, "config.json");
|
|
25
25
|
writeFileSync(
|
|
26
26
|
configPath,
|
|
@@ -28,9 +28,11 @@ describe("loadUnifiedConfig", () => {
|
|
|
28
28
|
debugLog: true,
|
|
29
29
|
permissionReviewLog: false,
|
|
30
30
|
yoloMode: true,
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
permission: {
|
|
32
|
+
"*": "ask",
|
|
33
|
+
read: "allow",
|
|
34
|
+
bash: { "git status": "allow" },
|
|
35
|
+
},
|
|
34
36
|
}),
|
|
35
37
|
);
|
|
36
38
|
|
|
@@ -39,12 +41,11 @@ describe("loadUnifiedConfig", () => {
|
|
|
39
41
|
expect(result.config.debugLog).toBe(true);
|
|
40
42
|
expect(result.config.permissionReviewLog).toBe(false);
|
|
41
43
|
expect(result.config.yoloMode).toBe(true);
|
|
42
|
-
expect(result.config.
|
|
43
|
-
|
|
44
|
-
|
|
44
|
+
expect(result.config.permission).toEqual({
|
|
45
|
+
"*": "ask",
|
|
46
|
+
read: "allow",
|
|
47
|
+
bash: { "git status": "allow" },
|
|
45
48
|
});
|
|
46
|
-
expect(result.config.tools).toEqual({ read: "allow", write: "deny" });
|
|
47
|
-
expect(result.config.bash).toEqual({ "git status": "allow" });
|
|
48
49
|
});
|
|
49
50
|
|
|
50
51
|
it("strips JSONC comments before parsing", () => {
|
|
@@ -55,14 +56,14 @@ describe("loadUnifiedConfig", () => {
|
|
|
55
56
|
// This is a comment
|
|
56
57
|
"debugLog": true,
|
|
57
58
|
/* block comment */
|
|
58
|
-
"
|
|
59
|
+
"permission": { "*": "ask" }
|
|
59
60
|
}`,
|
|
60
61
|
);
|
|
61
62
|
|
|
62
63
|
const result = loadUnifiedConfig(configPath);
|
|
63
64
|
expect(result.issues).toEqual([]);
|
|
64
65
|
expect(result.config.debugLog).toBe(true);
|
|
65
|
-
expect(result.config.
|
|
66
|
+
expect(result.config.permission).toEqual({ "*": "ask" });
|
|
66
67
|
});
|
|
67
68
|
|
|
68
69
|
it("ignores unknown keys without emitting issues", () => {
|
|
@@ -82,16 +83,15 @@ describe("loadUnifiedConfig", () => {
|
|
|
82
83
|
expect(result.config).not.toHaveProperty("unknownField");
|
|
83
84
|
});
|
|
84
85
|
|
|
85
|
-
it("returns
|
|
86
|
+
it("returns empty config and no issues when the file does not exist", () => {
|
|
86
87
|
const configPath = join(tempDir, "nonexistent.json");
|
|
87
88
|
const result = loadUnifiedConfig(configPath);
|
|
88
89
|
expect(result.issues).toEqual([]);
|
|
89
90
|
expect(result.config.debugLog).toBeUndefined();
|
|
90
|
-
expect(result.config.
|
|
91
|
-
expect(result.config.tools).toBeUndefined();
|
|
91
|
+
expect(result.config.permission).toBeUndefined();
|
|
92
92
|
});
|
|
93
93
|
|
|
94
|
-
it("returns
|
|
94
|
+
it("returns empty config and an issue when the file contains invalid JSON", () => {
|
|
95
95
|
const configPath = join(tempDir, "config.json");
|
|
96
96
|
writeFileSync(configPath, "not valid json {{{");
|
|
97
97
|
|
|
@@ -112,65 +112,121 @@ describe("loadUnifiedConfig", () => {
|
|
|
112
112
|
);
|
|
113
113
|
|
|
114
114
|
const result = loadUnifiedConfig(configPath);
|
|
115
|
-
// Non-boolean values are not included
|
|
116
115
|
expect(result.config.debugLog).toBeUndefined();
|
|
117
116
|
expect(result.config.permissionReviewLog).toBeUndefined();
|
|
118
117
|
expect(result.config.yoloMode).toBeUndefined();
|
|
119
118
|
});
|
|
120
119
|
|
|
121
|
-
it("normalizes permission
|
|
120
|
+
it("normalizes permission map, keeping only valid PermissionState values", () => {
|
|
122
121
|
const configPath = join(tempDir, "config.json");
|
|
123
122
|
writeFileSync(
|
|
124
123
|
configPath,
|
|
125
124
|
JSON.stringify({
|
|
126
|
-
|
|
127
|
-
|
|
125
|
+
permission: {
|
|
126
|
+
read: "allow",
|
|
127
|
+
write: "invalid",
|
|
128
|
+
bash: { "git *": "ask", "rm -rf": 42 },
|
|
129
|
+
},
|
|
128
130
|
}),
|
|
129
131
|
);
|
|
130
132
|
|
|
131
133
|
const result = loadUnifiedConfig(configPath);
|
|
132
|
-
expect(result.config.
|
|
133
|
-
|
|
134
|
+
expect(result.config.permission).toEqual({
|
|
135
|
+
read: "allow",
|
|
136
|
+
bash: { "git *": "ask" },
|
|
137
|
+
});
|
|
134
138
|
});
|
|
135
139
|
|
|
136
|
-
it("
|
|
140
|
+
it("accepts permission as object with mixed string and object values", () => {
|
|
137
141
|
const configPath = join(tempDir, "config.json");
|
|
138
142
|
writeFileSync(
|
|
139
143
|
configPath,
|
|
140
144
|
JSON.stringify({
|
|
141
|
-
|
|
145
|
+
permission: {
|
|
146
|
+
"*": "ask",
|
|
147
|
+
read: "allow",
|
|
148
|
+
bash: { "*": "ask", "git *": "allow" },
|
|
149
|
+
external_directory: "ask",
|
|
150
|
+
},
|
|
142
151
|
}),
|
|
143
152
|
);
|
|
144
153
|
|
|
145
154
|
const result = loadUnifiedConfig(configPath);
|
|
146
|
-
expect(result.issues).
|
|
147
|
-
expect(result.
|
|
148
|
-
|
|
149
|
-
|
|
155
|
+
expect(result.issues).toEqual([]);
|
|
156
|
+
expect(result.config.permission).toEqual({
|
|
157
|
+
"*": "ask",
|
|
158
|
+
read: "allow",
|
|
159
|
+
bash: { "*": "ask", "git *": "allow" },
|
|
160
|
+
external_directory: "ask",
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("returns no permission when the permission field is absent", () => {
|
|
165
|
+
const configPath = join(tempDir, "config.json");
|
|
166
|
+
writeFileSync(configPath, JSON.stringify({ debugLog: false }));
|
|
167
|
+
|
|
168
|
+
const result = loadUnifiedConfig(configPath);
|
|
169
|
+
expect(result.config.permission).toBeUndefined();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("ignores a non-object permission field", () => {
|
|
173
|
+
const configPath = join(tempDir, "config.json");
|
|
174
|
+
writeFileSync(configPath, JSON.stringify({ permission: "allow" }));
|
|
175
|
+
|
|
176
|
+
const result = loadUnifiedConfig(configPath);
|
|
177
|
+
expect(result.config.permission).toBeUndefined();
|
|
150
178
|
});
|
|
151
179
|
});
|
|
152
180
|
|
|
153
181
|
describe("mergeUnifiedConfigs", () => {
|
|
154
|
-
it("deep-merges
|
|
182
|
+
it("deep-merges permission objects so project overrides global per-key", () => {
|
|
155
183
|
const merged = mergeUnifiedConfigs(
|
|
156
184
|
{
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
185
|
+
permission: {
|
|
186
|
+
"*": "ask",
|
|
187
|
+
read: "allow",
|
|
188
|
+
bash: { "git status": "allow" },
|
|
189
|
+
},
|
|
160
190
|
},
|
|
161
191
|
{
|
|
162
|
-
|
|
163
|
-
|
|
192
|
+
permission: {
|
|
193
|
+
"*": "allow",
|
|
194
|
+
bash: { "rm -rf *": "deny" },
|
|
195
|
+
},
|
|
164
196
|
},
|
|
165
197
|
);
|
|
166
198
|
|
|
167
|
-
expect(merged.
|
|
168
|
-
|
|
199
|
+
expect(merged.permission).toEqual({
|
|
200
|
+
"*": "allow",
|
|
169
201
|
read: "allow",
|
|
170
|
-
|
|
171
|
-
edit: "ask",
|
|
202
|
+
bash: { "git status": "allow", "rm -rf *": "deny" },
|
|
172
203
|
});
|
|
173
|
-
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("string permission value in override replaces base string for same key", () => {
|
|
207
|
+
const merged = mergeUnifiedConfigs(
|
|
208
|
+
{ permission: { read: "ask" } },
|
|
209
|
+
{ permission: { read: "allow" } },
|
|
210
|
+
);
|
|
211
|
+
expect(merged.permission).toEqual({ read: "allow" });
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("object replaces string when override uses object for same surface", () => {
|
|
215
|
+
const merged = mergeUnifiedConfigs(
|
|
216
|
+
{ permission: { bash: "ask" } },
|
|
217
|
+
{ permission: { bash: { "*": "allow", "rm -rf *": "deny" } } },
|
|
218
|
+
);
|
|
219
|
+
expect(merged.permission).toEqual({
|
|
220
|
+
bash: { "*": "allow", "rm -rf *": "deny" },
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("string replaces object when override uses string for same surface", () => {
|
|
225
|
+
const merged = mergeUnifiedConfigs(
|
|
226
|
+
{ permission: { bash: { "git *": "allow" } } },
|
|
227
|
+
{ permission: { bash: "deny" } },
|
|
228
|
+
);
|
|
229
|
+
expect(merged.permission).toEqual({ bash: "deny" });
|
|
174
230
|
});
|
|
175
231
|
|
|
176
232
|
it("replaces scalar runtime knobs (project wins)", () => {
|
|
@@ -187,25 +243,23 @@ describe("mergeUnifiedConfigs", () => {
|
|
|
187
243
|
it("returns base unchanged when override is empty", () => {
|
|
188
244
|
const base = {
|
|
189
245
|
debugLog: true,
|
|
190
|
-
|
|
191
|
-
tools: { read: "allow" as const },
|
|
246
|
+
permission: { read: "allow" as const },
|
|
192
247
|
};
|
|
193
248
|
const merged = mergeUnifiedConfigs(base, {});
|
|
194
249
|
|
|
195
250
|
expect(merged.debugLog).toBe(true);
|
|
196
|
-
expect(merged.
|
|
197
|
-
expect(merged.tools).toEqual({ read: "allow" });
|
|
251
|
+
expect(merged.permission).toEqual({ read: "allow" });
|
|
198
252
|
});
|
|
199
253
|
|
|
200
254
|
it("returns override unchanged when base is empty", () => {
|
|
201
255
|
const override = {
|
|
202
256
|
yoloMode: true,
|
|
203
|
-
bash: { "rm -rf": "deny" as const },
|
|
257
|
+
permission: { bash: { "rm -rf *": "deny" as const } },
|
|
204
258
|
};
|
|
205
259
|
const merged = mergeUnifiedConfigs({}, override);
|
|
206
260
|
|
|
207
261
|
expect(merged.yoloMode).toBe(true);
|
|
208
|
-
expect(merged.
|
|
262
|
+
expect(merged.permission).toEqual({ bash: { "rm -rf *": "deny" } });
|
|
209
263
|
});
|
|
210
264
|
|
|
211
265
|
it("does not set undefined keys in the merged result", () => {
|
|
@@ -214,8 +268,7 @@ describe("mergeUnifiedConfigs", () => {
|
|
|
214
268
|
expect(merged.debugLog).toBe(true);
|
|
215
269
|
expect(merged.yoloMode).toBe(false);
|
|
216
270
|
expect(merged).not.toHaveProperty("permissionReviewLog");
|
|
217
|
-
expect(merged).not.toHaveProperty("
|
|
218
|
-
expect(merged).not.toHaveProperty("tools");
|
|
271
|
+
expect(merged).not.toHaveProperty("permission");
|
|
219
272
|
});
|
|
220
273
|
});
|
|
221
274
|
|
|
@@ -270,22 +323,20 @@ describe("loadAndMergeConfigs", () => {
|
|
|
270
323
|
it("merges global and project new-layout configs", () => {
|
|
271
324
|
writeGlobal({
|
|
272
325
|
debugLog: true,
|
|
273
|
-
|
|
274
|
-
tools: { read: "allow" },
|
|
326
|
+
permission: { "*": "ask", read: "allow" },
|
|
275
327
|
});
|
|
276
328
|
writeProject({
|
|
277
|
-
|
|
278
|
-
tools: { write: "deny" },
|
|
329
|
+
permission: { "*": "allow", write: "deny" },
|
|
279
330
|
});
|
|
280
331
|
|
|
281
332
|
const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
|
|
282
333
|
expect(result.issues).toEqual([]);
|
|
283
334
|
expect(result.merged.debugLog).toBe(true);
|
|
284
|
-
expect(result.merged.
|
|
285
|
-
|
|
286
|
-
|
|
335
|
+
expect(result.merged.permission).toEqual({
|
|
336
|
+
"*": "allow",
|
|
337
|
+
read: "allow",
|
|
338
|
+
write: "deny",
|
|
287
339
|
});
|
|
288
|
-
expect(result.merged.tools).toEqual({ read: "allow", write: "deny" });
|
|
289
340
|
});
|
|
290
341
|
|
|
291
342
|
it("detects legacy global policy and emits migration issue", () => {
|
|
@@ -298,9 +349,8 @@ describe("loadAndMergeConfigs", () => {
|
|
|
298
349
|
expect(result.issues).toHaveLength(1);
|
|
299
350
|
expect(result.issues[0]).toContain("pi-permissions.jsonc");
|
|
300
351
|
expect(result.issues[0]).toContain("extensions/pi-permission-system");
|
|
301
|
-
// Legacy
|
|
302
|
-
expect(result.merged.
|
|
303
|
-
expect(result.merged.tools).toEqual({ read: "allow" });
|
|
352
|
+
// Legacy file has no flat-format permission key — no rules extracted
|
|
353
|
+
expect(result.merged.permission).toBeUndefined();
|
|
304
354
|
});
|
|
305
355
|
|
|
306
356
|
it("detects legacy project policy and emits migration issue", () => {
|
|
@@ -312,7 +362,8 @@ describe("loadAndMergeConfigs", () => {
|
|
|
312
362
|
expect(result.issues).toHaveLength(1);
|
|
313
363
|
expect(result.issues[0]).toContain(".pi/agent/pi-permissions.jsonc");
|
|
314
364
|
expect(result.issues[0]).toContain(".pi/extensions/pi-permission-system");
|
|
315
|
-
|
|
365
|
+
// Legacy file has no flat-format permission key — no rules extracted
|
|
366
|
+
expect(result.merged.permission).toBeUndefined();
|
|
316
367
|
});
|
|
317
368
|
|
|
318
369
|
it("detects legacy extension runtime config and emits migration issue", () => {
|
|
@@ -329,7 +380,6 @@ describe("loadAndMergeConfigs", () => {
|
|
|
329
380
|
});
|
|
330
381
|
|
|
331
382
|
it("does not emit legacy extension config issue when path equals new global path", () => {
|
|
332
|
-
// If the extension happens to be installed at the new path, no warning
|
|
333
383
|
const newGlobalDir = join(agentDir, "extensions", "pi-permission-system");
|
|
334
384
|
mkdirSync(newGlobalDir, { recursive: true });
|
|
335
385
|
writeFileSync(
|
|
@@ -348,15 +398,15 @@ describe("loadAndMergeConfigs", () => {
|
|
|
348
398
|
|
|
349
399
|
it("new-layout config takes precedence over legacy config at same scope", () => {
|
|
350
400
|
writeGlobal({
|
|
351
|
-
|
|
401
|
+
permission: { "*": "deny" },
|
|
352
402
|
});
|
|
353
403
|
writeLegacyGlobalPolicy({
|
|
354
|
-
|
|
404
|
+
permission: { "*": "allow" },
|
|
355
405
|
});
|
|
356
406
|
|
|
357
407
|
const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
|
|
358
|
-
// New layout wins
|
|
359
|
-
expect(result.merged.
|
|
408
|
+
// New layout wins (legacy loaded first, new layout loaded second → new wins)
|
|
409
|
+
expect(result.merged.permission).toEqual({ "*": "deny" });
|
|
360
410
|
// But legacy still emits a migration warning
|
|
361
411
|
expect(result.issues.some((i) => i.includes("pi-permissions.jsonc"))).toBe(
|
|
362
412
|
true,
|
package/tests/defaults.test.ts
CHANGED
|
@@ -1,105 +1,12 @@
|
|
|
1
|
+
// defaults.ts has been removed as part of #66 (flat permission config format).
|
|
2
|
+
// The PermissionDefaultPolicy type and mergeDefaults() / getSurfaceDefault()
|
|
3
|
+
// helpers are no longer needed — the universal default is expressed as
|
|
4
|
+
// permission["*"] in the flat config, and per-surface catch-alls are regular
|
|
5
|
+
// config rules produced by normalizeFlatConfig().
|
|
1
6
|
import { describe, expect, test } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
DEFAULT_POLICY,
|
|
4
|
-
getSurfaceDefault,
|
|
5
|
-
mergeDefaults,
|
|
6
|
-
} from "../src/defaults";
|
|
7
|
-
import type { PermissionDefaultPolicy } from "../src/types";
|
|
8
7
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const defaults: PermissionDefaultPolicy = {
|
|
13
|
-
tools: "allow",
|
|
14
|
-
bash: "deny",
|
|
15
|
-
mcp: "ask",
|
|
16
|
-
skills: "allow",
|
|
17
|
-
special: "deny",
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
test("returns defaults.bash for surface 'bash'", () => {
|
|
21
|
-
expect(getSurfaceDefault("bash", defaults, SPECIAL_KEYS)).toBe("deny");
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
test("returns defaults.mcp for surface 'mcp'", () => {
|
|
25
|
-
expect(getSurfaceDefault("mcp", defaults, SPECIAL_KEYS)).toBe("ask");
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
test("returns defaults.skills for surface 'skill'", () => {
|
|
29
|
-
expect(getSurfaceDefault("skill", defaults, SPECIAL_KEYS)).toBe("allow");
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
test("returns defaults.special for special-key surfaces", () => {
|
|
33
|
-
expect(
|
|
34
|
-
getSurfaceDefault("external_directory", defaults, SPECIAL_KEYS),
|
|
35
|
-
).toBe("deny");
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
test("returns defaults.tools for tool-name surfaces", () => {
|
|
39
|
-
expect(getSurfaceDefault("read", defaults, SPECIAL_KEYS)).toBe("allow");
|
|
40
|
-
expect(getSurfaceDefault("write", defaults, SPECIAL_KEYS)).toBe("allow");
|
|
41
|
-
expect(getSurfaceDefault("edit", defaults, SPECIAL_KEYS)).toBe("allow");
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
test("returns defaults.tools for unknown surfaces (least privilege via tools default)", () => {
|
|
45
|
-
expect(
|
|
46
|
-
getSurfaceDefault("unknown_extension_tool", defaults, SPECIAL_KEYS),
|
|
47
|
-
).toBe("allow");
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
test("uses DEFAULT_POLICY when no overrides exist", () => {
|
|
51
|
-
expect(getSurfaceDefault("bash", DEFAULT_POLICY, SPECIAL_KEYS)).toBe("ask");
|
|
52
|
-
expect(getSurfaceDefault("read", DEFAULT_POLICY, SPECIAL_KEYS)).toBe("ask");
|
|
53
|
-
expect(
|
|
54
|
-
getSurfaceDefault("external_directory", DEFAULT_POLICY, SPECIAL_KEYS),
|
|
55
|
-
).toBe("ask");
|
|
56
|
-
});
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
describe("mergeDefaults", () => {
|
|
60
|
-
test("returns DEFAULT_POLICY when called with no partials", () => {
|
|
61
|
-
expect(mergeDefaults()).toEqual(DEFAULT_POLICY);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
test("overrides specific fields from a single partial", () => {
|
|
65
|
-
const result = mergeDefaults({ tools: "allow", bash: "deny" });
|
|
66
|
-
expect(result).toEqual({
|
|
67
|
-
tools: "allow",
|
|
68
|
-
bash: "deny",
|
|
69
|
-
mcp: "ask",
|
|
70
|
-
skills: "ask",
|
|
71
|
-
special: "ask",
|
|
72
|
-
});
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
test("later partials override earlier ones", () => {
|
|
76
|
-
const global: Partial<PermissionDefaultPolicy> = { tools: "allow" };
|
|
77
|
-
const project: Partial<PermissionDefaultPolicy> = { tools: "deny" };
|
|
78
|
-
const result = mergeDefaults(global, project);
|
|
79
|
-
expect(result.tools).toBe("deny");
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
test("merges across multiple partials", () => {
|
|
83
|
-
const global: Partial<PermissionDefaultPolicy> = {
|
|
84
|
-
tools: "allow",
|
|
85
|
-
bash: "allow",
|
|
86
|
-
};
|
|
87
|
-
const project: Partial<PermissionDefaultPolicy> = { bash: "deny" };
|
|
88
|
-
const agent: Partial<PermissionDefaultPolicy> = { mcp: "allow" };
|
|
89
|
-
const result = mergeDefaults(global, project, agent);
|
|
90
|
-
expect(result).toEqual({
|
|
91
|
-
tools: "allow",
|
|
92
|
-
bash: "deny",
|
|
93
|
-
mcp: "allow",
|
|
94
|
-
skills: "ask",
|
|
95
|
-
special: "ask",
|
|
96
|
-
});
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
test("undefined fields in later partials do not override earlier values", () => {
|
|
100
|
-
const global: Partial<PermissionDefaultPolicy> = { tools: "allow" };
|
|
101
|
-
const project: Partial<PermissionDefaultPolicy> = { bash: "deny" };
|
|
102
|
-
const result = mergeDefaults(global, project);
|
|
103
|
-
expect(result.tools).toBe("allow");
|
|
8
|
+
describe("defaults (removed)", () => {
|
|
9
|
+
test("placeholder — defaults.ts was removed in #66", () => {
|
|
10
|
+
expect(true).toBe(true);
|
|
104
11
|
});
|
|
105
12
|
});
|
|
@@ -24,7 +24,7 @@ describe("detectMisplacedPermissionKeys", () => {
|
|
|
24
24
|
expect(result).toEqual([]);
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
-
it("returns misplaced key names when permission-rule keys are present", () => {
|
|
27
|
+
it("returns misplaced key names when legacy permission-rule keys are present", () => {
|
|
28
28
|
const result = detectMisplacedPermissionKeys({
|
|
29
29
|
debugLog: true,
|
|
30
30
|
defaultPolicy: { tools: "ask" },
|
|
@@ -33,7 +33,7 @@ describe("detectMisplacedPermissionKeys", () => {
|
|
|
33
33
|
expect(result).toEqual(["defaultPolicy", "bash"]);
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
-
it("detects all known permission-rule keys", () => {
|
|
36
|
+
it("detects all known legacy permission-rule keys", () => {
|
|
37
37
|
const result = detectMisplacedPermissionKeys({
|
|
38
38
|
defaultPolicy: {},
|
|
39
39
|
tools: {},
|
|
@@ -54,13 +54,21 @@ describe("detectMisplacedPermissionKeys", () => {
|
|
|
54
54
|
]);
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
-
it("does not detect doom_loop as a misplaced permission key
|
|
57
|
+
it("does not detect doom_loop as a misplaced permission key", () => {
|
|
58
58
|
const result = detectMisplacedPermissionKeys({
|
|
59
59
|
doom_loop: {},
|
|
60
60
|
});
|
|
61
61
|
expect(result).toEqual([]);
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
+
it("does not flag the new flat-format permission key as misplaced", () => {
|
|
65
|
+
const result = detectMisplacedPermissionKeys({
|
|
66
|
+
debugLog: false,
|
|
67
|
+
permission: { "*": "ask" },
|
|
68
|
+
});
|
|
69
|
+
expect(result).toEqual([]);
|
|
70
|
+
});
|
|
71
|
+
|
|
64
72
|
it("ignores unknown keys that are not permission-rule keys", () => {
|
|
65
73
|
const result = detectMisplacedPermissionKeys({
|
|
66
74
|
debugLog: true,
|
|
@@ -109,7 +117,7 @@ describe("loadPermissionSystemConfig", () => {
|
|
|
109
117
|
expect(result.warning).toBeDefined();
|
|
110
118
|
expect(result.warning).toContain("defaultPolicy");
|
|
111
119
|
expect(result.warning).toContain("bash");
|
|
112
|
-
expect(result.warning).toContain("
|
|
120
|
+
expect(result.warning).toContain("permission");
|
|
113
121
|
});
|
|
114
122
|
|
|
115
123
|
it("still returns the valid extension config fields when misplaced keys are present", () => {
|