@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.
@@ -9,15 +9,23 @@ import {
9
9
  parseSimpleYamlMap,
10
10
  toRecord,
11
11
  } from "./common";
12
- import { loadUnifiedConfig, stripJsonComments } from "./config-loader";
12
+ import {
13
+ loadUnifiedConfig,
14
+ normalizeUnifiedConfig,
15
+ stripJsonComments,
16
+ } from "./config-loader";
13
17
  import { getGlobalConfigPath } from "./config-paths";
14
- import { mergeDefaults } from "./defaults";
15
- import { normalizeConfig } from "./normalize";
16
- import type { Ruleset } from "./rule";
18
+ import { normalizeFlatConfig } from "./normalize";
19
+ import type { Rule, Ruleset } from "./rule";
17
20
  import { evaluate } from "./rule";
21
+ import {
22
+ composeRuleset,
23
+ synthesizeBaseline,
24
+ synthesizeDefaults,
25
+ } from "./synthesize";
18
26
  import type {
27
+ FlatPermissionConfig,
19
28
  PermissionCheckResult,
20
- PermissionDefaultPolicy,
21
29
  PermissionState,
22
30
  ScopeConfig,
23
31
  } from "./types";
@@ -42,79 +50,37 @@ const BUILT_IN_TOOL_PERMISSION_NAMES = new Set([
42
50
  "ls",
43
51
  ]);
44
52
  const SPECIAL_PERMISSION_KEYS = new Set(["external_directory"]);
45
- const MCP_BASELINE_TARGETS = new Set([
46
- "mcp_status",
47
- "mcp_list",
48
- "mcp_search",
49
- "mcp_describe",
50
- "mcp_connect",
51
- ]);
52
-
53
- const DEFAULT_POLICY: PermissionDefaultPolicy = {
54
- tools: "ask",
55
- bash: "ask",
56
- mcp: "ask",
57
- skills: "ask",
58
- special: "ask",
59
- };
60
-
61
- function normalizePolicy(value: unknown): PermissionDefaultPolicy {
62
- const record = toRecord(value);
63
- return {
64
- tools: isPermissionState(record.tools)
65
- ? record.tools
66
- : DEFAULT_POLICY.tools,
67
- bash: isPermissionState(record.bash) ? record.bash : DEFAULT_POLICY.bash,
68
- mcp: isPermissionState(record.mcp) ? record.mcp : DEFAULT_POLICY.mcp,
69
- skills: isPermissionState(record.skills)
70
- ? record.skills
71
- : DEFAULT_POLICY.skills,
72
- special: isPermissionState(record.special)
73
- ? record.special
74
- : DEFAULT_POLICY.special,
75
- };
76
- }
77
-
78
- function normalizePartialPolicy(
79
- value: unknown,
80
- ): Partial<PermissionDefaultPolicy> {
81
- const record = toRecord(value);
82
- const normalized: Partial<PermissionDefaultPolicy> = {};
83
-
84
- if (isPermissionState(record.tools)) {
85
- normalized.tools = record.tools;
86
- }
87
-
88
- if (isPermissionState(record.bash)) {
89
- normalized.bash = record.bash;
90
- }
91
-
92
- if (isPermissionState(record.mcp)) {
93
- normalized.mcp = record.mcp;
94
- }
95
-
96
- if (isPermissionState(record.skills)) {
97
- normalized.skills = record.skills;
98
- }
99
-
100
- if (isPermissionState(record.special)) {
101
- normalized.special = record.special;
102
- }
103
-
104
- return normalized;
105
- }
106
53
 
107
- function normalizePermissionRecord(
108
- value: unknown,
109
- ): Record<string, PermissionState> {
110
- const record = toRecord(value);
111
- const normalized: Record<string, PermissionState> = {};
112
- for (const [key, state] of Object.entries(record)) {
113
- if (isPermissionState(state)) {
114
- normalized[key] = state;
54
+ /** Universal fallback when permission["*"] is absent from all scopes. */
55
+ const DEFAULT_UNIVERSAL_FALLBACK: PermissionState = "ask";
56
+
57
+ /**
58
+ * Deep-shallow merge two flat permission configs.
59
+ * Both objects shallow-merge the pattern maps.
60
+ * Otherwise → override replaces base.
61
+ */
62
+ function mergeFlatPermissions(
63
+ base: FlatPermissionConfig,
64
+ override: FlatPermissionConfig,
65
+ ): FlatPermissionConfig {
66
+ const merged: FlatPermissionConfig = { ...base };
67
+ for (const [key, value] of Object.entries(override)) {
68
+ const baseVal = merged[key];
69
+ if (
70
+ typeof baseVal === "object" &&
71
+ baseVal !== null &&
72
+ typeof value === "object" &&
73
+ value !== null
74
+ ) {
75
+ merged[key] = {
76
+ ...(baseVal as Record<string, PermissionState>),
77
+ ...(value as Record<string, PermissionState>),
78
+ };
79
+ } else {
80
+ merged[key] = value;
115
81
  }
116
82
  }
117
- return normalized;
83
+ return merged;
118
84
  }
119
85
 
120
86
  function readConfiguredMcpServerNamesFromConfigPath(
@@ -150,208 +116,6 @@ function getConfiguredMcpServerNamesFromPaths(
150
116
  );
151
117
  }
152
118
 
153
- const DEPRECATED_SPECIAL_KEYS: ReadonlySet<string> = new Set([
154
- "doom_loop",
155
- "tool_call_limit",
156
- ]);
157
-
158
- export interface NormalizeResult {
159
- permissions: ScopeConfig;
160
- configIssues: string[];
161
- }
162
-
163
- export function normalizeRawPermission(raw: unknown): NormalizeResult {
164
- const record = toRecord(raw);
165
- const configIssues: string[] = [];
166
- const normalizedTools = normalizePermissionRecord(record.tools);
167
-
168
- const normalized: ScopeConfig = {
169
- defaultPolicy: normalizePartialPolicy(record.defaultPolicy),
170
- tools: normalizedTools,
171
- bash: normalizePermissionRecord(record.bash),
172
- mcp: normalizePermissionRecord(record.mcp),
173
- skills: normalizePermissionRecord(record.skills),
174
- special: normalizePermissionRecord(record.special),
175
- };
176
-
177
- // Detect deprecated keys in the raw special sub-object before discarding.
178
- const rawSpecial = toRecord(record.special);
179
- for (const key of DEPRECATED_SPECIAL_KEYS) {
180
- if (key in rawSpecial) {
181
- configIssues.push(
182
- `special.${key} is deprecated and ignored — remove it from your policy file.`,
183
- );
184
- // Ensure the key is stripped even if its value was a valid PermissionState.
185
- if (normalized.special) {
186
- delete normalized.special[key];
187
- }
188
- }
189
- }
190
-
191
- for (const [key, value] of Object.entries(record)) {
192
- if (!isPermissionState(value)) {
193
- continue;
194
- }
195
-
196
- if (BUILT_IN_TOOL_PERMISSION_NAMES.has(key)) {
197
- normalized.tools = { ...(normalized.tools || {}), [key]: value };
198
- continue;
199
- }
200
-
201
- if (SPECIAL_PERMISSION_KEYS.has(key)) {
202
- normalized.special = { ...(normalized.special || {}), [key]: value };
203
- }
204
- }
205
-
206
- return { permissions: normalized, configIssues };
207
- }
208
-
209
- function parseQualifiedMcpToolName(
210
- value: string,
211
- ): { server: string; tool: string } | null {
212
- const trimmed = value.trim();
213
- if (!trimmed) {
214
- return null;
215
- }
216
-
217
- const colonIndex = trimmed.indexOf(":");
218
- if (colonIndex <= 0 || colonIndex >= trimmed.length - 1) {
219
- return null;
220
- }
221
-
222
- const server = trimmed.slice(0, colonIndex).trim();
223
- const tool = trimmed.slice(colonIndex + 1).trim();
224
- if (!server || !tool) {
225
- return null;
226
- }
227
-
228
- return { server, tool };
229
- }
230
-
231
- function addDerivedMcpServerTargets(
232
- toolName: string,
233
- configuredServerNames: readonly string[],
234
- pushTarget: (value: string | null) => void,
235
- ): void {
236
- const trimmedToolName = toolName.trim();
237
- if (!trimmedToolName) {
238
- return;
239
- }
240
-
241
- for (const serverName of configuredServerNames) {
242
- const trimmedServerName = serverName.trim();
243
- if (!trimmedServerName) {
244
- continue;
245
- }
246
-
247
- if (!trimmedToolName.endsWith(`_${trimmedServerName}`)) {
248
- continue;
249
- }
250
-
251
- if (trimmedToolName.startsWith(`${trimmedServerName}_`)) {
252
- continue;
253
- }
254
-
255
- pushTarget(`${trimmedServerName}_${trimmedToolName}`);
256
- pushTarget(`${trimmedServerName}:${trimmedToolName}`);
257
- pushTarget(trimmedServerName);
258
- }
259
- }
260
-
261
- function pushMcpToolPermissionTargets(
262
- rawReference: string,
263
- serverHint: string | null,
264
- configuredServerNames: readonly string[],
265
- pushTarget: (value: string | null) => void,
266
- ): void {
267
- const qualified = parseQualifiedMcpToolName(rawReference);
268
- const resolvedServer = serverHint ?? qualified?.server ?? null;
269
- const resolvedTool = qualified?.tool ?? rawReference;
270
-
271
- if (resolvedServer) {
272
- pushTarget(`${resolvedServer}_${resolvedTool}`);
273
- pushTarget(`${resolvedServer}:${resolvedTool}`);
274
- pushTarget(resolvedServer);
275
- } else {
276
- addDerivedMcpServerTargets(resolvedTool, configuredServerNames, pushTarget);
277
- }
278
-
279
- pushTarget(resolvedTool);
280
- pushTarget(rawReference);
281
- }
282
-
283
- function createMcpPermissionTargets(
284
- input: unknown,
285
- configuredServerNames: readonly string[] = [],
286
- ): string[] {
287
- const record = toRecord(input);
288
- const tool = getNonEmptyString(record.tool);
289
- const server = getNonEmptyString(record.server);
290
- const connect = getNonEmptyString(record.connect);
291
- const describe = getNonEmptyString(record.describe);
292
- const search = getNonEmptyString(record.search);
293
-
294
- const targets: string[] = [];
295
- const pushTarget = (value: string | null) => {
296
- if (!value) {
297
- return;
298
- }
299
- if (!targets.includes(value)) {
300
- targets.push(value);
301
- }
302
- };
303
-
304
- if (tool) {
305
- pushMcpToolPermissionTargets(
306
- tool,
307
- server,
308
- configuredServerNames,
309
- pushTarget,
310
- );
311
- pushTarget("mcp_call");
312
- return targets;
313
- }
314
-
315
- if (connect) {
316
- pushTarget(`mcp_connect_${connect}`);
317
- pushTarget(connect);
318
- pushTarget("mcp_connect");
319
- return targets;
320
- }
321
-
322
- if (describe) {
323
- pushMcpToolPermissionTargets(
324
- describe,
325
- server,
326
- configuredServerNames,
327
- pushTarget,
328
- );
329
- pushTarget("mcp_describe");
330
- return targets;
331
- }
332
-
333
- if (search) {
334
- if (server) {
335
- pushTarget(`mcp_server_${server}`);
336
- pushTarget(server);
337
- }
338
-
339
- pushTarget(search);
340
- pushTarget("mcp_search");
341
- return targets;
342
- }
343
-
344
- if (server) {
345
- pushTarget(`mcp_server_${server}`);
346
- pushTarget(server);
347
- pushTarget("mcp_list");
348
- return targets;
349
- }
350
-
351
- pushTarget("mcp_status");
352
- return targets;
353
- }
354
-
355
119
  export interface ResolvedPolicyPaths {
356
120
  globalConfigPath: string;
357
121
  globalConfigExists: boolean;
@@ -364,13 +128,11 @@ export interface ResolvedPolicyPaths {
364
128
  }
365
129
 
366
130
  type ResolvedPermissions = {
367
- rules: Ruleset;
368
- defaults: PermissionDefaultPolicy;
369
- /** tools.bash fallback: tools.bash || defaults.bash */
370
- bashDefault: PermissionState;
371
- /** tools.mcp fallback (undefined = no explicit tools.mcp) */
372
- mcpToolLevel: PermissionState | undefined;
373
- hasAnyMcpAllowRule: boolean;
131
+ /**
132
+ * Fully composed ruleset: synthesized defaults → baseline → config.
133
+ * Session rules are appended at call-time inside checkPermission().
134
+ */
135
+ composedRules: Ruleset;
374
136
  };
375
137
 
376
138
  type FileCacheEntry<TValue> = {
@@ -464,12 +226,7 @@ export class PermissionManager {
464
226
  this.accumulateConfigIssues(issues);
465
227
 
466
228
  const value: ScopeConfig = {
467
- defaultPolicy: normalizePolicy(config.defaultPolicy),
468
- tools: config.tools || {},
469
- bash: config.bash || {},
470
- mcp: config.mcp || {},
471
- skills: config.skills || {},
472
- special: config.special || {},
229
+ permission: config.permission,
473
230
  };
474
231
 
475
232
  this.globalConfigCache = { stamp, value };
@@ -490,12 +247,7 @@ export class PermissionManager {
490
247
  this.accumulateConfigIssues(issues);
491
248
 
492
249
  const value: ScopeConfig = {
493
- defaultPolicy: config.defaultPolicy,
494
- tools: config.tools,
495
- bash: config.bash,
496
- mcp: config.mcp,
497
- skills: config.skills,
498
- special: config.special,
250
+ permission: config.permission,
499
251
  };
500
252
 
501
253
  this.projectGlobalConfigCache = { stamp, value };
@@ -526,9 +278,11 @@ export class PermissionManager {
526
278
  value = {};
527
279
  } else {
528
280
  const parsed = parseSimpleYamlMap(frontmatter);
529
- const result = normalizeRawPermission(parsed.permission);
530
- value = result.permissions;
531
- this.accumulateConfigIssues(result.configIssues);
281
+ // Re-use the config-loader normalizer so the flat permission shape
282
+ // is validated the same way as on-disk config files.
283
+ const { config, issues } = normalizeUnifiedConfig(parsed);
284
+ this.accumulateConfigIssues(issues);
285
+ value = { permission: config.permission };
532
286
  }
533
287
  } catch {
534
288
  value = {};
@@ -599,50 +353,46 @@ export class PermissionManager {
599
353
  const agentConfig = this.loadScopeConfig(agentName);
600
354
  const projectAgentConfig = this.loadProjectScopeConfig(agentName);
601
355
 
602
- // Normalize each scope into a flat Ruleset and concatenate.
603
- // Later scopes appear last → higher priority via last-match-wins.
604
- const rules: Ruleset = [
605
- ...normalizeConfig(globalConfig),
606
- ...normalizeConfig(projectConfig),
607
- ...normalizeConfig(agentConfig),
608
- ...normalizeConfig(projectAgentConfig),
609
- ];
610
-
611
- // Merge defaults separately (shallow spread, same precedence order).
612
- const defaults = mergeDefaults(
613
- globalConfig.defaultPolicy,
614
- projectConfig.defaultPolicy,
615
- agentConfig.defaultPolicy,
616
- projectAgentConfig.defaultPolicy,
617
- );
356
+ // Merge permission objects across scopes (lowest highest precedence).
357
+ let mergedPermission: FlatPermissionConfig = {};
358
+ for (const scope of [
359
+ globalConfig,
360
+ projectConfig,
361
+ agentConfig,
362
+ projectAgentConfig,
363
+ ]) {
364
+ if (scope.permission) {
365
+ mergedPermission = mergeFlatPermissions(
366
+ mergedPermission,
367
+ scope.permission,
368
+ );
369
+ }
370
+ }
618
371
 
619
- // tools.bash / tools.mcp are fallback overrides, not catch-all rules.
620
- // Extract with last-scope-wins precedence.
621
- const toolBash =
622
- projectAgentConfig.tools?.bash ??
623
- agentConfig.tools?.bash ??
624
- projectConfig.tools?.bash ??
625
- globalConfig.tools?.bash;
626
- const bashDefault = toolBash ?? defaults.bash;
627
-
628
- const mcpToolLevel =
629
- projectAgentConfig.tools?.mcp ??
630
- agentConfig.tools?.mcp ??
631
- projectConfig.tools?.mcp ??
632
- globalConfig.tools?.mcp;
633
-
634
- const hasAnyMcpAllowRule = rules.some(
635
- (r) => r.surface === "mcp" && r.action === "allow",
372
+ // Extract the universal fallback from permission["*"].
373
+ // The "*" key feeds synthesizeDefaults() only — it is NOT included as a
374
+ // config rule so that extension tools fall through to source:"default".
375
+ const universalFallback = isPermissionState(mergedPermission["*"])
376
+ ? (mergedPermission["*"] as PermissionState)
377
+ : DEFAULT_UNIVERSAL_FALLBACK;
378
+
379
+ // Build config rules from everything except the universal "*" key.
380
+ const permissionWithoutUniversal: FlatPermissionConfig = Object.fromEntries(
381
+ Object.entries(mergedPermission).filter(([k]) => k !== "*"),
636
382
  );
637
383
 
638
- const value: ResolvedPermissions = {
639
- rules,
640
- defaults,
641
- bashDefault,
642
- mcpToolLevel,
643
- hasAnyMcpAllowRule,
644
- };
384
+ // Normalize to config rules, tagged with "config" layer.
385
+ const configRules: Ruleset = normalizeFlatConfig(
386
+ permissionWithoutUniversal,
387
+ ).map((r): Rule => ({ ...r, layer: "config" }));
388
+
389
+ const composedRules = composeRuleset(
390
+ synthesizeDefaults(universalFallback),
391
+ synthesizeBaseline(configRules),
392
+ configRules,
393
+ );
645
394
 
395
+ const value: ResolvedPermissions = { composedRules };
646
396
  this.resolvedPermissionsCache.set(cacheKey, { stamp, value });
647
397
  return value;
648
398
  }
@@ -666,60 +416,75 @@ export class PermissionManager {
666
416
  }
667
417
 
668
418
  /**
669
- * Get the tool-level permission state for a tool, without considering command-level rules.
670
- * This is used for tool injection decisions where we need to know if a tool is allowed/denied
671
- * at the tool level before checking specific command permissions.
672
- *
673
- * With tool-name-as-surface normalization, tools.bash becomes a bash catch-all
674
- * { surface: "bash", pattern: "*", action } so getToolPermission("bash")
675
- * naturally picks it up via evaluate("bash", "*", rules).
676
- *
677
- * @param toolName - The name of the tool (for example "bash", "read", or a third-party tool name)
678
- * @param agentName - Optional agent name to check agent-specific permissions
679
- * @returns The permission state for the tool at the tool level
419
+ * Get the tool-level permission state for a tool, without considering
420
+ * command-level rules. Used for tool injection decisions.
680
421
  */
681
422
  getToolPermission(toolName: string, agentName?: string): PermissionState {
682
- const { rules, defaults, bashDefault, mcpToolLevel } =
683
- this.resolvePermissions(agentName);
423
+ const { composedRules } = this.resolvePermissions(agentName);
684
424
  const normalizedToolName = toolName.trim();
685
425
 
686
- // Special keys use the special default.
426
+ // Special surfaces (external_directory): evaluate directly by surface name.
687
427
  if (SPECIAL_PERMISSION_KEYS.has(normalizedToolName)) {
688
- const rule = evaluate("special", normalizedToolName, rules);
689
- if (rules.includes(rule)) return rule.action;
690
- return defaults.special;
428
+ return evaluate(normalizedToolName, "*", composedRules).action;
691
429
  }
692
430
 
693
- // Bash and MCP have dedicated fallback overrides from tools.bash / tools.mcp.
694
- if (normalizedToolName === "bash") return bashDefault;
695
- if (normalizedToolName === "mcp") return mcpToolLevel ?? defaults.mcp;
696
-
697
- // Skills use the skills default.
698
- if (normalizedToolName === "skill") return defaults.skills;
431
+ // Bash, MCP, skill: evaluate with "*" value the per-surface catch-all
432
+ // (or universal default) handles this correctly.
433
+ if (normalizedToolName === "bash") {
434
+ return evaluate("bash", "*", composedRules).action;
435
+ }
436
+ if (normalizedToolName === "mcp") {
437
+ return evaluate("mcp", "*", composedRules).action;
438
+ }
439
+ if (normalizedToolName === "skill") {
440
+ return evaluate("skill", "*", composedRules).action;
441
+ }
699
442
 
700
- // Tool-name surfaces: check rules, fall back to tools default.
701
- const rule = evaluate(normalizedToolName, "*", rules);
702
- if (rules.includes(rule)) return rule.action;
703
- return defaults.tools;
443
+ // Tool-name surfaces (read, write, etc. and extension tools).
444
+ return evaluate(normalizedToolName, "*", composedRules).action;
704
445
  }
705
446
 
706
447
  checkPermission(
707
448
  toolName: string,
708
449
  input: unknown,
709
450
  agentName?: string,
451
+ sessionRules?: Ruleset,
710
452
  ): PermissionCheckResult {
711
- const { rules, defaults, bashDefault, mcpToolLevel, hasAnyMcpAllowRule } =
712
- this.resolvePermissions(agentName);
453
+ const { composedRules } = this.resolvePermissions(agentName);
713
454
  const normalizedToolName = toolName.trim();
714
455
 
715
456
  // --- Special surfaces (external_directory) ---
716
457
  if (SPECIAL_PERMISSION_KEYS.has(normalizedToolName)) {
717
- const rule = evaluate("special", normalizedToolName, rules);
718
- const explicit = rules.includes(rule);
458
+ const record = toRecord(input);
459
+ const pathValue = typeof record.path === "string" ? record.path : null;
460
+
461
+ // Session check: match by specific normalized path.
462
+ if (pathValue && sessionRules && sessionRules.length > 0) {
463
+ const sessionRule = evaluate(
464
+ "external_directory",
465
+ pathValue,
466
+ sessionRules,
467
+ );
468
+ if (sessionRules.includes(sessionRule)) {
469
+ return {
470
+ toolName,
471
+ state: "allow",
472
+ matchedPattern: sessionRule.pattern,
473
+ source: "session",
474
+ };
475
+ }
476
+ }
477
+
478
+ // Config/default check.
479
+ const rule = evaluate(
480
+ normalizedToolName,
481
+ pathValue ?? "*",
482
+ composedRules,
483
+ );
719
484
  return {
720
485
  toolName,
721
- state: explicit ? rule.action : defaults.special,
722
- matchedPattern: explicit ? rule.pattern : undefined,
486
+ state: rule.action,
487
+ matchedPattern: rule.layer === "config" ? rule.pattern : undefined,
723
488
  source: "special",
724
489
  };
725
490
  }
@@ -727,19 +492,12 @@ export class PermissionManager {
727
492
  // --- Skills ---
728
493
  if (normalizedToolName === "skill") {
729
494
  const skillName = toRecord(input).name;
730
- if (typeof skillName === "string") {
731
- const rule = evaluate("skill", skillName, rules);
732
- const explicit = rules.includes(rule);
733
- return {
734
- toolName,
735
- state: explicit ? rule.action : defaults.skills,
736
- matchedPattern: explicit ? rule.pattern : undefined,
737
- source: explicit ? "skill" : "skill",
738
- };
739
- }
495
+ const lookupValue = typeof skillName === "string" ? skillName : "*";
496
+ const rule = evaluate("skill", lookupValue, composedRules);
740
497
  return {
741
498
  toolName,
742
- state: defaults.skills,
499
+ state: rule.action,
500
+ matchedPattern: rule.layer === "config" ? rule.pattern : undefined,
743
501
  source: "skill",
744
502
  };
745
503
  }
@@ -748,13 +506,12 @@ export class PermissionManager {
748
506
  if (normalizedToolName === "bash") {
749
507
  const record = toRecord(input);
750
508
  const command = typeof record.command === "string" ? record.command : "";
751
- const rule = evaluate("bash", command, rules);
752
- const explicit = rules.includes(rule);
509
+ const rule = evaluate("bash", command, composedRules);
753
510
  return {
754
511
  toolName,
755
- state: explicit ? rule.action : bashDefault,
512
+ state: rule.action,
756
513
  command,
757
- matchedPattern: explicit ? rule.pattern : undefined,
514
+ matchedPattern: rule.layer === "config" ? rule.pattern : undefined,
758
515
  source: "bash",
759
516
  };
760
517
  }
@@ -770,67 +527,34 @@ export class PermissionManager {
770
527
  ];
771
528
  const fallbackTarget = mcpTargets[0] || "mcp";
772
529
 
773
- // Try each candidate target against the merged rules.
530
+ // Try each candidate target. Stop on the first non-default match.
774
531
  for (const target of mcpTargets) {
775
- const rule = evaluate("mcp", target, rules);
776
- if (rules.includes(rule)) {
532
+ const rule = evaluate("mcp", target, composedRules);
533
+ if (rule.layer !== "default") {
777
534
  return {
778
535
  toolName,
779
536
  state: rule.action,
780
- matchedPattern: rule.pattern,
537
+ matchedPattern: rule.layer === "config" ? rule.pattern : undefined,
781
538
  target,
782
- source: "mcp",
783
- };
784
- }
785
- }
786
-
787
- // tools.mcp fallback (e.g. tools: { mcp: "allow" }).
788
- if (mcpToolLevel) {
789
- return {
790
- toolName,
791
- state: mcpToolLevel,
792
- target: fallbackTarget,
793
- source: "tool",
794
- };
795
- }
796
-
797
- // Baseline auto-allow: if this is a metadata operation and at least one
798
- // MCP rule allows something (or the default is allow), auto-allow.
799
- const baselineTarget = mcpTargets.find((target) =>
800
- MCP_BASELINE_TARGETS.has(target),
801
- );
802
- if (baselineTarget) {
803
- if (hasAnyMcpAllowRule || defaults.mcp === "allow") {
804
- return {
805
- toolName,
806
- state: "allow",
807
- target: baselineTarget,
808
- source: "mcp",
539
+ source: rule.layer === "override" ? "tool" : "mcp",
809
540
  };
810
541
  }
811
542
  }
812
543
 
544
+ // All targets matched only the synthesized default.
545
+ const defaultRule = evaluate("mcp", fallbackTarget, composedRules);
813
546
  return {
814
547
  toolName,
815
- state: defaults.mcp,
548
+ state: defaultRule.action,
816
549
  target: fallbackTarget,
817
550
  source: "default",
818
551
  };
819
552
  }
820
553
 
821
554
  // --- Tools (read, write, edit, grep, find, ls, extension tools) ---
822
- const rule = evaluate(normalizedToolName, "*", rules);
823
- const explicit = rules.includes(rule);
555
+ const rule = evaluate(normalizedToolName, "*", composedRules);
824
556
 
825
557
  if (BUILT_IN_TOOL_PERMISSION_NAMES.has(normalizedToolName)) {
826
- return {
827
- toolName,
828
- state: explicit ? rule.action : defaults.tools,
829
- source: "tool",
830
- };
831
- }
832
-
833
- if (explicit) {
834
558
  return {
835
559
  toolName,
836
560
  state: rule.action,
@@ -840,8 +564,162 @@ export class PermissionManager {
840
564
 
841
565
  return {
842
566
  toolName,
843
- state: defaults.tools,
844
- source: "default",
567
+ state: rule.action,
568
+ source: rule.layer === "default" ? "default" : "tool",
845
569
  };
846
570
  }
847
571
  }
572
+
573
+ // ---------------------------------------------------------------------------
574
+ // MCP target derivation helpers (unchanged)
575
+ // ---------------------------------------------------------------------------
576
+
577
+ function parseQualifiedMcpToolName(
578
+ value: string,
579
+ ): { server: string; tool: string } | null {
580
+ const trimmed = value.trim();
581
+ if (!trimmed) {
582
+ return null;
583
+ }
584
+
585
+ const colonIndex = trimmed.indexOf(":");
586
+ if (colonIndex <= 0 || colonIndex >= trimmed.length - 1) {
587
+ return null;
588
+ }
589
+
590
+ const server = trimmed.slice(0, colonIndex).trim();
591
+ const tool = trimmed.slice(colonIndex + 1).trim();
592
+ if (!server || !tool) {
593
+ return null;
594
+ }
595
+
596
+ return { server, tool };
597
+ }
598
+
599
+ function addDerivedMcpServerTargets(
600
+ toolName: string,
601
+ configuredServerNames: readonly string[],
602
+ pushTarget: (value: string | null) => void,
603
+ ): void {
604
+ const trimmedToolName = toolName.trim();
605
+ if (!trimmedToolName) {
606
+ return;
607
+ }
608
+
609
+ for (const serverName of configuredServerNames) {
610
+ const trimmedServerName = serverName.trim();
611
+ if (!trimmedServerName) {
612
+ continue;
613
+ }
614
+
615
+ if (!trimmedToolName.endsWith(`_${trimmedServerName}`)) {
616
+ continue;
617
+ }
618
+
619
+ if (trimmedToolName.startsWith(`${trimmedServerName}_`)) {
620
+ continue;
621
+ }
622
+
623
+ pushTarget(`${trimmedServerName}_${trimmedToolName}`);
624
+ pushTarget(`${trimmedServerName}:${trimmedToolName}`);
625
+ pushTarget(trimmedServerName);
626
+ }
627
+ }
628
+
629
+ function pushMcpToolPermissionTargets(
630
+ rawReference: string,
631
+ serverHint: string | null,
632
+ configuredServerNames: readonly string[],
633
+ pushTarget: (value: string | null) => void,
634
+ ): void {
635
+ const qualified = parseQualifiedMcpToolName(rawReference);
636
+ const resolvedServer = serverHint ?? qualified?.server ?? null;
637
+ const resolvedTool = qualified?.tool ?? rawReference;
638
+
639
+ if (resolvedServer) {
640
+ pushTarget(`${resolvedServer}_${resolvedTool}`);
641
+ pushTarget(`${resolvedServer}:${resolvedTool}`);
642
+ pushTarget(resolvedServer);
643
+ } else {
644
+ addDerivedMcpServerTargets(resolvedTool, configuredServerNames, pushTarget);
645
+ }
646
+
647
+ pushTarget(resolvedTool);
648
+ pushTarget(rawReference);
649
+ }
650
+
651
+ function createMcpPermissionTargets(
652
+ input: unknown,
653
+ configuredServerNames: readonly string[] = [],
654
+ ): string[] {
655
+ const record = toRecord(input);
656
+ const tool = getNonEmptyString(record.tool);
657
+ const server = getNonEmptyString(record.server);
658
+ const connect = getNonEmptyString(record.connect);
659
+ const describe = getNonEmptyString(record.describe);
660
+ const search = getNonEmptyString(record.search);
661
+
662
+ const targets: string[] = [];
663
+ const pushTarget = (value: string | null) => {
664
+ if (!value) {
665
+ return;
666
+ }
667
+ if (!targets.includes(value)) {
668
+ targets.push(value);
669
+ }
670
+ };
671
+
672
+ if (tool) {
673
+ pushMcpToolPermissionTargets(
674
+ tool,
675
+ server,
676
+ configuredServerNames,
677
+ pushTarget,
678
+ );
679
+ pushTarget("mcp_call");
680
+ return targets;
681
+ }
682
+
683
+ if (connect) {
684
+ pushTarget(`mcp_connect_${connect}`);
685
+ pushTarget(connect);
686
+ pushTarget("mcp_connect");
687
+ return targets;
688
+ }
689
+
690
+ if (describe) {
691
+ pushMcpToolPermissionTargets(
692
+ describe,
693
+ server,
694
+ configuredServerNames,
695
+ pushTarget,
696
+ );
697
+ pushTarget("mcp_describe");
698
+ return targets;
699
+ }
700
+
701
+ if (search) {
702
+ if (server) {
703
+ pushTarget(`mcp_server_${server}`);
704
+ pushTarget(server);
705
+ }
706
+
707
+ pushTarget(search);
708
+ pushTarget("mcp_search");
709
+ return targets;
710
+ }
711
+
712
+ if (server) {
713
+ pushTarget(`mcp_server_${server}`);
714
+ pushTarget(server);
715
+ pushTarget("mcp_list");
716
+ return targets;
717
+ }
718
+
719
+ pushTarget("mcp_status");
720
+ return targets;
721
+ }
722
+
723
+ // Keep isPermissionState and toRecord available for convenience — they are
724
+ // used directly in some handler files that import from permission-manager.
725
+ export { isPermissionState, toRecord };