@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/src/synthesize.ts CHANGED
@@ -1,90 +1,27 @@
1
1
  import type { Rule, Ruleset } from "./rule";
2
- import type { PermissionDefaultPolicy, PermissionState } from "./types";
2
+ import type { PermissionState } from "./types";
3
3
 
4
4
  /**
5
- * Convert the merged `defaultPolicy` into catch-all rules at the lowest
6
- * priority position in the composed ruleset.
5
+ * Synthesize a single universal catch-all rule from the universal default.
7
6
  *
8
- * Produces 5 rules:
9
- * 1. `{ surface: "*", pattern: "*" }` universal fallback (tools default)
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
- * All rules carry `layer: "default"`. `evaluate()` ignores this field.
16
- * The specific per-surface rules come after the universal rule so they win
17
- * via last-match-wins when a surface-specific default differs from the
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(defaults: PermissionDefaultPolicy): Ruleset {
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: "skill",
17
+ surface: "*",
27
18
  pattern: "*",
28
- action: defaults.skills,
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 `defaults.mcp === "allow"`, the synthesized default catch-all already
108
- * covers all MCP targets — no separate baseline rules are needed (and this
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 override rules in the composed array so
112
- * that `tools.mcp` overrides beat baseline (preserving current behaviour where
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 → overrides → config
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, ...overrides, ...config];
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
- * All fields optional each scope may define a subset of the policy.
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
- defaultPolicy?: Partial<PermissionDefaultPolicy>;
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 policy", () => {
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
- defaultPolicy: { tools: "allow", bash: "deny" },
32
- tools: { read: "allow", write: "deny" },
33
- bash: { "git status": "allow" },
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.defaultPolicy).toEqual({
43
- tools: "allow",
44
- bash: "deny",
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
- "defaultPolicy": { "tools": "ask" }
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.defaultPolicy).toEqual({ tools: "ask" });
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 defaults and no issues when the file does not exist", () => {
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.defaultPolicy).toBeUndefined();
91
- expect(result.config.tools).toBeUndefined();
91
+ expect(result.config.permission).toBeUndefined();
92
92
  });
93
93
 
94
- it("returns defaults and an issue when the file contains invalid JSON", () => {
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 maps, keeping only valid PermissionState values", () => {
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
- tools: { read: "allow", write: "invalid", edit: "deny" },
127
- bash: { "git *": "ask", "rm -rf": 42 },
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.tools).toEqual({ read: "allow", edit: "deny" });
133
- expect(result.config.bash).toEqual({ "git *": "ask" });
134
+ expect(result.config.permission).toEqual({
135
+ read: "allow",
136
+ bash: { "git *": "ask" },
137
+ });
134
138
  });
135
139
 
136
- it("collects deprecated special key issues (doom_loop and tool_call_limit)", () => {
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
- special: { doom_loop: "deny", tool_call_limit: "ask" },
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).toHaveLength(2);
147
- expect(result.issues.some((i) => i.includes("doom_loop"))).toBe(true);
148
- expect(result.issues.some((i) => i.includes("tool_call_limit"))).toBe(true);
149
- expect(result.config.special).toBeUndefined();
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 object fields so project overrides global per-key", () => {
182
+ it("deep-merges permission objects so project overrides global per-key", () => {
155
183
  const merged = mergeUnifiedConfigs(
156
184
  {
157
- defaultPolicy: { tools: "ask", bash: "deny" },
158
- tools: { read: "allow", write: "deny" },
159
- bash: { "git status": "allow" },
185
+ permission: {
186
+ "*": "ask",
187
+ read: "allow",
188
+ bash: { "git status": "allow" },
189
+ },
160
190
  },
161
191
  {
162
- defaultPolicy: { tools: "allow" },
163
- tools: { write: "allow", edit: "ask" },
192
+ permission: {
193
+ "*": "allow",
194
+ bash: { "rm -rf *": "deny" },
195
+ },
164
196
  },
165
197
  );
166
198
 
167
- expect(merged.defaultPolicy).toEqual({ tools: "allow", bash: "deny" });
168
- expect(merged.tools).toEqual({
199
+ expect(merged.permission).toEqual({
200
+ "*": "allow",
169
201
  read: "allow",
170
- write: "allow",
171
- edit: "ask",
202
+ bash: { "git status": "allow", "rm -rf *": "deny" },
172
203
  });
173
- expect(merged.bash).toEqual({ "git status": "allow" });
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
- defaultPolicy: { tools: "ask" as const },
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.defaultPolicy).toEqual({ tools: "ask" });
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.bash).toEqual({ "rm -rf": "deny" });
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("defaultPolicy");
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
- defaultPolicy: { tools: "ask", bash: "deny" },
274
- tools: { read: "allow" },
326
+ permission: { "*": "ask", read: "allow" },
275
327
  });
276
328
  writeProject({
277
- defaultPolicy: { tools: "allow" },
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.defaultPolicy).toEqual({
285
- tools: "allow",
286
- bash: "deny",
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 values are merged
302
- expect(result.merged.defaultPolicy).toEqual({ tools: "allow" });
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
- expect(result.merged.bash).toEqual({ "git status": "allow" });
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
- defaultPolicy: { tools: "deny" },
401
+ permission: { "*": "deny" },
352
402
  });
353
403
  writeLegacyGlobalPolicy({
354
- defaultPolicy: { tools: "allow" },
404
+ permission: { "*": "allow" },
355
405
  });
356
406
 
357
407
  const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
358
- // New layout wins
359
- expect(result.merged.defaultPolicy).toEqual({ tools: "deny" });
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,
@@ -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
- const SPECIAL_KEYS = new Set(["external_directory"]);
10
-
11
- describe("getSurfaceDefault", () => {
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
  });