@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
package/src/synthesize.ts
CHANGED
|
@@ -1,90 +1,27 @@
|
|
|
1
1
|
import type { Rule, Ruleset } from "./rule";
|
|
2
|
-
import type {
|
|
2
|
+
import type { PermissionState } from "./types";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
* priority position in the composed ruleset.
|
|
5
|
+
* Synthesize a single universal catch-all rule from the universal default.
|
|
7
6
|
*
|
|
8
|
-
* Produces
|
|
9
|
-
*
|
|
10
|
-
* 2. `{ surface: "bash", pattern: "*" }` — bash default
|
|
11
|
-
* 3. `{ surface: "mcp", pattern: "*" }` — mcp default
|
|
12
|
-
* 4. `{ surface: "skill", pattern: "*" }` — skill default
|
|
13
|
-
* 5. `{ surface: "special", pattern: "*" }` — special / external_directory default
|
|
7
|
+
* Produces one rule:
|
|
8
|
+
* `{ surface: "*", pattern: "*", action: universalDefault, layer: "default" }`
|
|
14
9
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* tools default.
|
|
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.
|
|
19
13
|
*/
|
|
20
|
-
export function synthesizeDefaults(
|
|
14
|
+
export function synthesizeDefaults(universalDefault: PermissionState): Ruleset {
|
|
21
15
|
return [
|
|
22
|
-
{ surface: "*", pattern: "*", action: defaults.tools, layer: "default" },
|
|
23
|
-
{ surface: "bash", pattern: "*", action: defaults.bash, layer: "default" },
|
|
24
|
-
{ surface: "mcp", pattern: "*", action: defaults.mcp, layer: "default" },
|
|
25
16
|
{
|
|
26
|
-
surface: "
|
|
17
|
+
surface: "*",
|
|
27
18
|
pattern: "*",
|
|
28
|
-
action:
|
|
29
|
-
layer: "default",
|
|
30
|
-
},
|
|
31
|
-
{
|
|
32
|
-
surface: "special",
|
|
33
|
-
pattern: "*",
|
|
34
|
-
action: defaults.special,
|
|
19
|
+
action: universalDefault,
|
|
35
20
|
layer: "default",
|
|
36
21
|
},
|
|
37
22
|
];
|
|
38
23
|
}
|
|
39
24
|
|
|
40
|
-
/**
|
|
41
|
-
* Per-scope override shape — the relevant keys extracted from `tools`.
|
|
42
|
-
* `undefined` means the scope did not configure that override.
|
|
43
|
-
*/
|
|
44
|
-
export interface OverrideScope {
|
|
45
|
-
bash?: PermissionState;
|
|
46
|
-
mcp?: PermissionState;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Convert per-scope `tools.bash` / `tools.mcp` entries into catch-all rules
|
|
51
|
-
* placed between defaults and config rules.
|
|
52
|
-
*
|
|
53
|
-
* Scopes must be passed in precedence order (lowest first, e.g. global →
|
|
54
|
-
* project → agent → project-agent). Later scopes produce later rules and
|
|
55
|
-
* therefore win via last-match-wins — identical to the current last-scope-wins
|
|
56
|
-
* logic for `bashDefault` / `mcpToolLevel`.
|
|
57
|
-
*
|
|
58
|
-
* Only scopes that explicitly define a value contribute a rule; `undefined`
|
|
59
|
-
* entries are skipped.
|
|
60
|
-
*
|
|
61
|
-
* All rules carry `layer: "override"`.
|
|
62
|
-
*/
|
|
63
|
-
export function synthesizeOverrides(
|
|
64
|
-
scopes: ReadonlyArray<OverrideScope>,
|
|
65
|
-
): Ruleset {
|
|
66
|
-
const rules: Rule[] = [];
|
|
67
|
-
for (const scope of scopes) {
|
|
68
|
-
if (scope.bash !== undefined) {
|
|
69
|
-
rules.push({
|
|
70
|
-
surface: "bash",
|
|
71
|
-
pattern: "*",
|
|
72
|
-
action: scope.bash,
|
|
73
|
-
layer: "override",
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
if (scope.mcp !== undefined) {
|
|
77
|
-
rules.push({
|
|
78
|
-
surface: "mcp",
|
|
79
|
-
pattern: "*",
|
|
80
|
-
action: scope.mcp,
|
|
81
|
-
layer: "override",
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
return rules;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
25
|
/**
|
|
89
26
|
* MCP metadata operation targets that are auto-allowed when any explicit MCP
|
|
90
27
|
* allow rule exists in the config layer.
|
|
@@ -104,13 +41,12 @@ const MCP_BASELINE_TARGETS: readonly string[] = [
|
|
|
104
41
|
* contains at least one `surface: "mcp", action: "allow"` rule. This replicates
|
|
105
42
|
* the `hasAnyMcpAllowRule` heuristic as actual rules.
|
|
106
43
|
*
|
|
107
|
-
* When `
|
|
108
|
-
* covers all MCP targets — no separate
|
|
109
|
-
* function is not called in that case).
|
|
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).
|
|
110
47
|
*
|
|
111
|
-
* Baseline rules are placed BEFORE
|
|
112
|
-
* that
|
|
113
|
-
* an explicit `tools.mcp` value always terminates the MCP decision).
|
|
48
|
+
* Baseline rules are placed BEFORE config rules in the composed array so
|
|
49
|
+
* that explicit config deny rules can still override them.
|
|
114
50
|
*
|
|
115
51
|
* All rules carry `layer: "baseline"`.
|
|
116
52
|
*/
|
|
@@ -135,7 +71,7 @@ export function synthesizeBaseline(configRules: Ruleset): Ruleset {
|
|
|
135
71
|
* Concatenate all rule layers into a single flat ruleset.
|
|
136
72
|
*
|
|
137
73
|
* Priority order (lowest → highest, i.e. earlier index → later index):
|
|
138
|
-
* defaults → baseline →
|
|
74
|
+
* defaults → baseline → config
|
|
139
75
|
*
|
|
140
76
|
* Session rules are NOT included here — they are appended at call-time inside
|
|
141
77
|
* `checkPermission()` so that the cached composed ruleset remains session-agnostic.
|
|
@@ -145,8 +81,7 @@ export function synthesizeBaseline(configRules: Ruleset): Ruleset {
|
|
|
145
81
|
export function composeRuleset(
|
|
146
82
|
defaults: Ruleset,
|
|
147
83
|
baseline: Ruleset,
|
|
148
|
-
overrides: Ruleset,
|
|
149
84
|
config: Ruleset,
|
|
150
85
|
): Ruleset {
|
|
151
|
-
return [...defaults, ...baseline, ...
|
|
86
|
+
return [...defaults, ...baseline, ...config];
|
|
152
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 {
|
|
@@ -279,6 +279,37 @@ describe("extractExternalPathsFromBashCommand", () => {
|
|
|
279
279
|
});
|
|
280
280
|
});
|
|
281
281
|
|
|
282
|
+
describe("bare-slash tokens are skipped", () => {
|
|
283
|
+
test("does not flag // token", () => {
|
|
284
|
+
const result = extractExternalPathsFromBashCommand("echo //", cwd);
|
|
285
|
+
expect(result).toHaveLength(0);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("does not flag / token", () => {
|
|
289
|
+
const result = extractExternalPathsFromBashCommand("echo /", cwd);
|
|
290
|
+
expect(result).toHaveLength(0);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("does not flag /// token", () => {
|
|
294
|
+
const result = extractExternalPathsFromBashCommand("echo ///", cwd);
|
|
295
|
+
expect(result).toHaveLength(0);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("does not flag // in echo with other args", () => {
|
|
299
|
+
const result = extractExternalPathsFromBashCommand("echo // hello", cwd);
|
|
300
|
+
expect(result).toHaveLength(0);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("still flags real external path alongside //", () => {
|
|
304
|
+
const result = extractExternalPathsFromBashCommand(
|
|
305
|
+
"cat /etc/hosts; echo //",
|
|
306
|
+
cwd,
|
|
307
|
+
);
|
|
308
|
+
expect(result).toContain("/etc/hosts");
|
|
309
|
+
expect(result).toHaveLength(1);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
282
313
|
describe("deduplication", () => {
|
|
283
314
|
test("returns deduplicated paths", () => {
|
|
284
315
|
const result = extractExternalPathsFromBashCommand(
|
|
@@ -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
|
});
|