@gotgenes/pi-permission-system 3.11.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,21 +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";
18
21
  import {
19
22
  composeRuleset,
20
23
  synthesizeBaseline,
21
24
  synthesizeDefaults,
22
- synthesizeOverrides,
23
25
  } from "./synthesize";
24
26
  import type {
27
+ FlatPermissionConfig,
25
28
  PermissionCheckResult,
26
- PermissionDefaultPolicy,
27
29
  PermissionState,
28
30
  ScopeConfig,
29
31
  } from "./types";
@@ -48,71 +50,37 @@ const BUILT_IN_TOOL_PERMISSION_NAMES = new Set([
48
50
  "ls",
49
51
  ]);
50
52
  const SPECIAL_PERMISSION_KEYS = new Set(["external_directory"]);
51
- const DEFAULT_POLICY: PermissionDefaultPolicy = {
52
- tools: "ask",
53
- bash: "ask",
54
- mcp: "ask",
55
- skills: "ask",
56
- special: "ask",
57
- };
58
-
59
- function normalizePolicy(value: unknown): PermissionDefaultPolicy {
60
- const record = toRecord(value);
61
- return {
62
- tools: isPermissionState(record.tools)
63
- ? record.tools
64
- : DEFAULT_POLICY.tools,
65
- bash: isPermissionState(record.bash) ? record.bash : DEFAULT_POLICY.bash,
66
- mcp: isPermissionState(record.mcp) ? record.mcp : DEFAULT_POLICY.mcp,
67
- skills: isPermissionState(record.skills)
68
- ? record.skills
69
- : DEFAULT_POLICY.skills,
70
- special: isPermissionState(record.special)
71
- ? record.special
72
- : DEFAULT_POLICY.special,
73
- };
74
- }
75
-
76
- function normalizePartialPolicy(
77
- value: unknown,
78
- ): Partial<PermissionDefaultPolicy> {
79
- const record = toRecord(value);
80
- const normalized: Partial<PermissionDefaultPolicy> = {};
81
-
82
- if (isPermissionState(record.tools)) {
83
- normalized.tools = record.tools;
84
- }
85
-
86
- if (isPermissionState(record.bash)) {
87
- normalized.bash = record.bash;
88
- }
89
-
90
- if (isPermissionState(record.mcp)) {
91
- normalized.mcp = record.mcp;
92
- }
93
-
94
- if (isPermissionState(record.skills)) {
95
- normalized.skills = record.skills;
96
- }
97
53
 
98
- if (isPermissionState(record.special)) {
99
- normalized.special = record.special;
100
- }
101
-
102
- return normalized;
103
- }
104
-
105
- function normalizePermissionRecord(
106
- value: unknown,
107
- ): Record<string, PermissionState> {
108
- const record = toRecord(value);
109
- const normalized: Record<string, PermissionState> = {};
110
- for (const [key, state] of Object.entries(record)) {
111
- if (isPermissionState(state)) {
112
- 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;
113
81
  }
114
82
  }
115
- return normalized;
83
+ return merged;
116
84
  }
117
85
 
118
86
  function readConfiguredMcpServerNamesFromConfigPath(
@@ -148,208 +116,6 @@ function getConfiguredMcpServerNamesFromPaths(
148
116
  );
149
117
  }
150
118
 
151
- const DEPRECATED_SPECIAL_KEYS: ReadonlySet<string> = new Set([
152
- "doom_loop",
153
- "tool_call_limit",
154
- ]);
155
-
156
- export interface NormalizeResult {
157
- permissions: ScopeConfig;
158
- configIssues: string[];
159
- }
160
-
161
- export function normalizeRawPermission(raw: unknown): NormalizeResult {
162
- const record = toRecord(raw);
163
- const configIssues: string[] = [];
164
- const normalizedTools = normalizePermissionRecord(record.tools);
165
-
166
- const normalized: ScopeConfig = {
167
- defaultPolicy: normalizePartialPolicy(record.defaultPolicy),
168
- tools: normalizedTools,
169
- bash: normalizePermissionRecord(record.bash),
170
- mcp: normalizePermissionRecord(record.mcp),
171
- skills: normalizePermissionRecord(record.skills),
172
- special: normalizePermissionRecord(record.special),
173
- };
174
-
175
- // Detect deprecated keys in the raw special sub-object before discarding.
176
- const rawSpecial = toRecord(record.special);
177
- for (const key of DEPRECATED_SPECIAL_KEYS) {
178
- if (key in rawSpecial) {
179
- configIssues.push(
180
- `special.${key} is deprecated and ignored — remove it from your policy file.`,
181
- );
182
- // Ensure the key is stripped even if its value was a valid PermissionState.
183
- if (normalized.special) {
184
- delete normalized.special[key];
185
- }
186
- }
187
- }
188
-
189
- for (const [key, value] of Object.entries(record)) {
190
- if (!isPermissionState(value)) {
191
- continue;
192
- }
193
-
194
- if (BUILT_IN_TOOL_PERMISSION_NAMES.has(key)) {
195
- normalized.tools = { ...(normalized.tools || {}), [key]: value };
196
- continue;
197
- }
198
-
199
- if (SPECIAL_PERMISSION_KEYS.has(key)) {
200
- normalized.special = { ...(normalized.special || {}), [key]: value };
201
- }
202
- }
203
-
204
- return { permissions: normalized, configIssues };
205
- }
206
-
207
- function parseQualifiedMcpToolName(
208
- value: string,
209
- ): { server: string; tool: string } | null {
210
- const trimmed = value.trim();
211
- if (!trimmed) {
212
- return null;
213
- }
214
-
215
- const colonIndex = trimmed.indexOf(":");
216
- if (colonIndex <= 0 || colonIndex >= trimmed.length - 1) {
217
- return null;
218
- }
219
-
220
- const server = trimmed.slice(0, colonIndex).trim();
221
- const tool = trimmed.slice(colonIndex + 1).trim();
222
- if (!server || !tool) {
223
- return null;
224
- }
225
-
226
- return { server, tool };
227
- }
228
-
229
- function addDerivedMcpServerTargets(
230
- toolName: string,
231
- configuredServerNames: readonly string[],
232
- pushTarget: (value: string | null) => void,
233
- ): void {
234
- const trimmedToolName = toolName.trim();
235
- if (!trimmedToolName) {
236
- return;
237
- }
238
-
239
- for (const serverName of configuredServerNames) {
240
- const trimmedServerName = serverName.trim();
241
- if (!trimmedServerName) {
242
- continue;
243
- }
244
-
245
- if (!trimmedToolName.endsWith(`_${trimmedServerName}`)) {
246
- continue;
247
- }
248
-
249
- if (trimmedToolName.startsWith(`${trimmedServerName}_`)) {
250
- continue;
251
- }
252
-
253
- pushTarget(`${trimmedServerName}_${trimmedToolName}`);
254
- pushTarget(`${trimmedServerName}:${trimmedToolName}`);
255
- pushTarget(trimmedServerName);
256
- }
257
- }
258
-
259
- function pushMcpToolPermissionTargets(
260
- rawReference: string,
261
- serverHint: string | null,
262
- configuredServerNames: readonly string[],
263
- pushTarget: (value: string | null) => void,
264
- ): void {
265
- const qualified = parseQualifiedMcpToolName(rawReference);
266
- const resolvedServer = serverHint ?? qualified?.server ?? null;
267
- const resolvedTool = qualified?.tool ?? rawReference;
268
-
269
- if (resolvedServer) {
270
- pushTarget(`${resolvedServer}_${resolvedTool}`);
271
- pushTarget(`${resolvedServer}:${resolvedTool}`);
272
- pushTarget(resolvedServer);
273
- } else {
274
- addDerivedMcpServerTargets(resolvedTool, configuredServerNames, pushTarget);
275
- }
276
-
277
- pushTarget(resolvedTool);
278
- pushTarget(rawReference);
279
- }
280
-
281
- function createMcpPermissionTargets(
282
- input: unknown,
283
- configuredServerNames: readonly string[] = [],
284
- ): string[] {
285
- const record = toRecord(input);
286
- const tool = getNonEmptyString(record.tool);
287
- const server = getNonEmptyString(record.server);
288
- const connect = getNonEmptyString(record.connect);
289
- const describe = getNonEmptyString(record.describe);
290
- const search = getNonEmptyString(record.search);
291
-
292
- const targets: string[] = [];
293
- const pushTarget = (value: string | null) => {
294
- if (!value) {
295
- return;
296
- }
297
- if (!targets.includes(value)) {
298
- targets.push(value);
299
- }
300
- };
301
-
302
- if (tool) {
303
- pushMcpToolPermissionTargets(
304
- tool,
305
- server,
306
- configuredServerNames,
307
- pushTarget,
308
- );
309
- pushTarget("mcp_call");
310
- return targets;
311
- }
312
-
313
- if (connect) {
314
- pushTarget(`mcp_connect_${connect}`);
315
- pushTarget(connect);
316
- pushTarget("mcp_connect");
317
- return targets;
318
- }
319
-
320
- if (describe) {
321
- pushMcpToolPermissionTargets(
322
- describe,
323
- server,
324
- configuredServerNames,
325
- pushTarget,
326
- );
327
- pushTarget("mcp_describe");
328
- return targets;
329
- }
330
-
331
- if (search) {
332
- if (server) {
333
- pushTarget(`mcp_server_${server}`);
334
- pushTarget(server);
335
- }
336
-
337
- pushTarget(search);
338
- pushTarget("mcp_search");
339
- return targets;
340
- }
341
-
342
- if (server) {
343
- pushTarget(`mcp_server_${server}`);
344
- pushTarget(server);
345
- pushTarget("mcp_list");
346
- return targets;
347
- }
348
-
349
- pushTarget("mcp_status");
350
- return targets;
351
- }
352
-
353
119
  export interface ResolvedPolicyPaths {
354
120
  globalConfigPath: string;
355
121
  globalConfigExists: boolean;
@@ -363,7 +129,7 @@ export interface ResolvedPolicyPaths {
363
129
 
364
130
  type ResolvedPermissions = {
365
131
  /**
366
- * Fully composed ruleset: synthesized defaults → baseline → overrides → config.
132
+ * Fully composed ruleset: synthesized defaults → baseline → config.
367
133
  * Session rules are appended at call-time inside checkPermission().
368
134
  */
369
135
  composedRules: Ruleset;
@@ -460,12 +226,7 @@ export class PermissionManager {
460
226
  this.accumulateConfigIssues(issues);
461
227
 
462
228
  const value: ScopeConfig = {
463
- defaultPolicy: normalizePolicy(config.defaultPolicy),
464
- tools: config.tools || {},
465
- bash: config.bash || {},
466
- mcp: config.mcp || {},
467
- skills: config.skills || {},
468
- special: config.special || {},
229
+ permission: config.permission,
469
230
  };
470
231
 
471
232
  this.globalConfigCache = { stamp, value };
@@ -486,12 +247,7 @@ export class PermissionManager {
486
247
  this.accumulateConfigIssues(issues);
487
248
 
488
249
  const value: ScopeConfig = {
489
- defaultPolicy: config.defaultPolicy,
490
- tools: config.tools,
491
- bash: config.bash,
492
- mcp: config.mcp,
493
- skills: config.skills,
494
- special: config.special,
250
+ permission: config.permission,
495
251
  };
496
252
 
497
253
  this.projectGlobalConfigCache = { stamp, value };
@@ -522,9 +278,11 @@ export class PermissionManager {
522
278
  value = {};
523
279
  } else {
524
280
  const parsed = parseSimpleYamlMap(frontmatter);
525
- const result = normalizeRawPermission(parsed.permission);
526
- value = result.permissions;
527
- 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 };
528
286
  }
529
287
  } catch {
530
288
  value = {};
@@ -595,48 +353,46 @@ export class PermissionManager {
595
353
  const agentConfig = this.loadScopeConfig(agentName);
596
354
  const projectAgentConfig = this.loadProjectScopeConfig(agentName);
597
355
 
598
- // Tag config rules with layer "config" so checkPermission() can derive
599
- // the result source and matchedPattern without positional arithmetic.
600
- const tagConfig = (r: import("./rule").Rule) => ({
601
- ...r,
602
- layer: "config" as const,
603
- });
604
- const configRules: Ruleset = [
605
- ...normalizeConfig(globalConfig).map(tagConfig),
606
- ...normalizeConfig(projectConfig).map(tagConfig),
607
- ...normalizeConfig(agentConfig).map(tagConfig),
608
- ...normalizeConfig(projectAgentConfig).map(tagConfig),
609
- ];
610
-
611
- // Merge defaultPolicy across scopes (shallow spread, same precedence).
612
- const defaults = mergeDefaults(
613
- globalConfig.defaultPolicy,
614
- projectConfig.defaultPolicy,
615
- agentConfig.defaultPolicy,
616
- projectAgentConfig.defaultPolicy,
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
+ }
371
+
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 !== "*"),
617
382
  );
618
383
 
619
- // tools.bash / tools.mcp overrides: per-scope, lowest-priority first so
620
- // that later scopes win via last-match-wins in synthesizeOverrides.
621
- const overrideScopes = [
622
- { bash: globalConfig.tools?.bash, mcp: globalConfig.tools?.mcp },
623
- { bash: projectConfig.tools?.bash, mcp: projectConfig.tools?.mcp },
624
- { bash: agentConfig.tools?.bash, mcp: agentConfig.tools?.mcp },
625
- {
626
- bash: projectAgentConfig.tools?.bash,
627
- mcp: projectAgentConfig.tools?.mcp,
628
- },
629
- ];
384
+ // Normalize to config rules, tagged with "config" layer.
385
+ const configRules: Ruleset = normalizeFlatConfig(
386
+ permissionWithoutUniversal,
387
+ ).map((r): Rule => ({ ...r, layer: "config" }));
630
388
 
631
389
  const composedRules = composeRuleset(
632
- synthesizeDefaults(defaults),
390
+ synthesizeDefaults(universalFallback),
633
391
  synthesizeBaseline(configRules),
634
- synthesizeOverrides(overrideScopes),
635
392
  configRules,
636
393
  );
637
394
 
638
395
  const value: ResolvedPermissions = { composedRules };
639
-
640
396
  this.resolvedPermissionsCache.set(cacheKey, { stamp, value });
641
397
  return value;
642
398
  }
@@ -660,31 +416,20 @@ export class PermissionManager {
660
416
  }
661
417
 
662
418
  /**
663
- * Get the tool-level permission state for a tool, without considering command-level rules.
664
- * This is used for tool injection decisions where we need to know if a tool is allowed/denied
665
- * at the tool level before checking specific command permissions.
666
- *
667
- * With tool-name-as-surface normalization, tools.bash becomes a bash catch-all
668
- * { surface: "bash", pattern: "*", action } so getToolPermission("bash")
669
- * naturally picks it up via evaluate("bash", "*", rules).
670
- *
671
- * @param toolName - The name of the tool (for example "bash", "read", or a third-party tool name)
672
- * @param agentName - Optional agent name to check agent-specific permissions
673
- * @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.
674
421
  */
675
422
  getToolPermission(toolName: string, agentName?: string): PermissionState {
676
423
  const { composedRules } = this.resolvePermissions(agentName);
677
424
  const normalizedToolName = toolName.trim();
678
425
 
679
- // Special surfaces: evaluate via the "special" surface used by config rules
680
- // and the synthesized special default.
426
+ // Special surfaces (external_directory): evaluate directly by surface name.
681
427
  if (SPECIAL_PERMISSION_KEYS.has(normalizedToolName)) {
682
- return evaluate("special", normalizedToolName, composedRules).action;
428
+ return evaluate(normalizedToolName, "*", composedRules).action;
683
429
  }
684
430
 
685
- // For bash, mcp, skill: evaluate with "*" value against composedRules.
686
- // The synthesized override/default rules are catch-alls (pattern "*") that
687
- // respond correctly to a "*" lookup without matching specific patterns.
431
+ // Bash, MCP, skill: evaluate with "*" value the per-surface catch-all
432
+ // (or universal default) handles this correctly.
688
433
  if (normalizedToolName === "bash") {
689
434
  return evaluate("bash", "*", composedRules).action;
690
435
  }
@@ -709,12 +454,11 @@ export class PermissionManager {
709
454
  const normalizedToolName = toolName.trim();
710
455
 
711
456
  // --- Special surfaces (external_directory) ---
712
- // Config/default rules use surface "special"; session rules use surface
713
- // "external_directory" with path patterns. Check each independently.
714
457
  if (SPECIAL_PERMISSION_KEYS.has(normalizedToolName)) {
715
- // Session check: match by specific normalized path.
716
458
  const record = toRecord(input);
717
459
  const pathValue = typeof record.path === "string" ? record.path : null;
460
+
461
+ // Session check: match by specific normalized path.
718
462
  if (pathValue && sessionRules && sessionRules.length > 0) {
719
463
  const sessionRule = evaluate(
720
464
  "external_directory",
@@ -730,8 +474,13 @@ export class PermissionManager {
730
474
  };
731
475
  }
732
476
  }
477
+
733
478
  // Config/default check.
734
- const rule = evaluate("special", normalizedToolName, composedRules);
479
+ const rule = evaluate(
480
+ normalizedToolName,
481
+ pathValue ?? "*",
482
+ composedRules,
483
+ );
735
484
  return {
736
485
  toolName,
737
486
  state: rule.action,
@@ -778,10 +527,7 @@ export class PermissionManager {
778
527
  ];
779
528
  const fallbackTarget = mcpTargets[0] || "mcp";
780
529
 
781
- // Try each candidate target. Stop on the first non-default match
782
- // (config, override, or baseline rule). Default rules are catch-alls
783
- // that would fire on every target — skip them so more-specific targets
784
- // can be checked first.
530
+ // Try each candidate target. Stop on the first non-default match.
785
531
  for (const target of mcpTargets) {
786
532
  const rule = evaluate("mcp", target, composedRules);
787
533
  if (rule.layer !== "default") {
@@ -808,8 +554,6 @@ export class PermissionManager {
808
554
  // --- Tools (read, write, edit, grep, find, ls, extension tools) ---
809
555
  const rule = evaluate(normalizedToolName, "*", composedRules);
810
556
 
811
- // Built-in tools always report source "tool" regardless of which layer
812
- // supplied the decision (matches current behaviour).
813
557
  if (BUILT_IN_TOOL_PERMISSION_NAMES.has(normalizedToolName)) {
814
558
  return {
815
559
  toolName,
@@ -818,8 +562,6 @@ export class PermissionManager {
818
562
  };
819
563
  }
820
564
 
821
- // Extension tools: "default" layer → source "default"; any explicit rule
822
- // (config or override) → source "tool".
823
565
  return {
824
566
  toolName,
825
567
  state: rule.action,
@@ -827,3 +569,157 @@ export class PermissionManager {
827
569
  };
828
570
  }
829
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 };