@gotgenes/pi-permission-system 4.4.1 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [4.5.0](https://github.com/gotgenes/pi-permission-system/compare/v4.4.1...v4.5.0) (2026-05-05)
9
+
10
+
11
+ ### Features
12
+
13
+ * add evaluateFirst multi-candidate evaluate helper ([6b1fa60](https://github.com/gotgenes/pi-permission-system/commit/6b1fa603e6eaf750624d1f553ddb84755ab9b78e))
14
+ * add input normalizer for non-MCP surfaces ([6d25624](https://github.com/gotgenes/pi-permission-system/commit/6d256241e3f599aa9db5952a16b10d10b72e5321))
15
+ * add MCP input normalization to input-normalizer ([6fa58b2](https://github.com/gotgenes/pi-permission-system/commit/6fa58b211f06eef5885f1e6f238285cdb77aab09))
16
+ * concatenate session rules into composed ruleset ([e85e844](https://github.com/gotgenes/pi-permission-system/commit/e85e844f414c6dfd14ffbbc74826c976d8f0f234))
17
+
18
+
19
+ ### Documentation
20
+
21
+ * mark unified checkPermission as implemented in target architecture ([bb7214a](https://github.com/gotgenes/pi-permission-system/commit/bb7214a8363b6b1ad70ae1233f297d28c36cd423))
22
+ * plan unified checkPermission evaluate path ([#81](https://github.com/gotgenes/pi-permission-system/issues/81)) ([6562328](https://github.com/gotgenes/pi-permission-system/commit/65623287aa899424829b0e31e7c9aa46f17e3f81))
23
+ * **retro:** add retro notes for issue [#82](https://github.com/gotgenes/pi-permission-system/issues/82) ([f748fe0](https://github.com/gotgenes/pi-permission-system/commit/f748fe00105dbce087ec0a41653171c53364d531))
24
+
8
25
  ## [4.4.1](https://github.com/gotgenes/pi-permission-system/compare/v4.4.0...v4.4.1) (2026-05-05)
9
26
 
10
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "4.4.1",
3
+ "version": "4.5.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "files": [
@@ -0,0 +1,94 @@
1
+ import { toRecord } from "./common";
2
+ import { createMcpPermissionTargets } from "./mcp-targets";
3
+
4
+ /**
5
+ * Surface-normalized representation of a tool invocation used by
6
+ * `checkPermission()` to feed a single `evaluateFirst()` call.
7
+ */
8
+ export interface NormalizedInput {
9
+ /** The permission surface for `evaluate()` (e.g. "bash", "mcp", "skill"). */
10
+ surface: string;
11
+ /**
12
+ * Candidate lookup values in priority order (most-specific first).
13
+ * Most surfaces produce a single-element array; MCP produces a
14
+ * multi-candidate list derived from the invocation input.
15
+ */
16
+ values: string[];
17
+ /**
18
+ * Surface-specific fields forwarded verbatim into `PermissionCheckResult`
19
+ * (e.g. `{ command }` for bash, `{ target }` for mcp).
20
+ */
21
+ resultExtras: Record<string, unknown>;
22
+ }
23
+
24
+ const SPECIAL_PERMISSION_KEYS = new Set(["external_directory"]);
25
+
26
+ /**
27
+ * Map a raw tool invocation to the surface/values/extras triple needed by
28
+ * `checkPermission()`.
29
+ *
30
+ * @param toolName - Normalized (trimmed) tool name from the tool-call event.
31
+ * @param input - Raw input payload from the tool-call event.
32
+ * @param configuredMcpServerNames - Ordered list of MCP server names from the
33
+ * global MCP config, used to derive server-qualified MCP targets.
34
+ */
35
+ export function normalizeInput(
36
+ toolName: string,
37
+ input: unknown,
38
+ configuredMcpServerNames: readonly string[],
39
+ ): NormalizedInput {
40
+ // --- Special surfaces (external_directory) ---
41
+ if (SPECIAL_PERMISSION_KEYS.has(toolName)) {
42
+ const record = toRecord(input);
43
+ const pathValue = typeof record.path === "string" ? record.path : null;
44
+ return {
45
+ surface: toolName,
46
+ values: [pathValue ?? "*"],
47
+ resultExtras: {},
48
+ };
49
+ }
50
+
51
+ // --- Skill ---
52
+ if (toolName === "skill") {
53
+ const record = toRecord(input);
54
+ const skillName = record.name;
55
+ const lookupValue = typeof skillName === "string" ? skillName : "*";
56
+ return {
57
+ surface: "skill",
58
+ values: [lookupValue],
59
+ resultExtras: {},
60
+ };
61
+ }
62
+
63
+ // --- Bash ---
64
+ if (toolName === "bash") {
65
+ const record = toRecord(input);
66
+ const command = typeof record.command === "string" ? record.command : "";
67
+ return {
68
+ surface: "bash",
69
+ values: [command],
70
+ resultExtras: { command },
71
+ };
72
+ }
73
+
74
+ // --- MCP ---
75
+ if (toolName === "mcp") {
76
+ const mcpTargets = [
77
+ ...createMcpPermissionTargets(input, configuredMcpServerNames),
78
+ "mcp",
79
+ ];
80
+ const fallbackTarget = mcpTargets[0] ?? "mcp";
81
+ return {
82
+ surface: "mcp",
83
+ values: mcpTargets,
84
+ resultExtras: { target: fallbackTarget },
85
+ };
86
+ }
87
+
88
+ // --- Tool surfaces (read, write, edit, grep, find, ls, extension tools) ---
89
+ return {
90
+ surface: toolName,
91
+ values: ["*"],
92
+ resultExtras: {},
93
+ };
94
+ }
@@ -0,0 +1,160 @@
1
+ import { getNonEmptyString, toRecord } from "./common";
2
+
3
+ /**
4
+ * Parse a qualified MCP tool name of the form `server:tool`.
5
+ *
6
+ * Returns `{ server, tool }` when the string contains exactly one colon with
7
+ * non-empty text on both sides; otherwise returns `null`.
8
+ */
9
+ export function parseQualifiedMcpToolName(
10
+ value: string,
11
+ ): { server: string; tool: string } | null {
12
+ const trimmed = value.trim();
13
+ if (!trimmed) {
14
+ return null;
15
+ }
16
+
17
+ const colonIndex = trimmed.indexOf(":");
18
+ if (colonIndex <= 0 || colonIndex >= trimmed.length - 1) {
19
+ return null;
20
+ }
21
+
22
+ const server = trimmed.slice(0, colonIndex).trim();
23
+ const tool = trimmed.slice(colonIndex + 1).trim();
24
+ if (!server || !tool) {
25
+ return null;
26
+ }
27
+
28
+ return { server, tool };
29
+ }
30
+
31
+ function addDerivedMcpServerTargets(
32
+ toolName: string,
33
+ configuredServerNames: readonly string[],
34
+ pushTarget: (value: string | null) => void,
35
+ ): void {
36
+ const trimmedToolName = toolName.trim();
37
+ if (!trimmedToolName) {
38
+ return;
39
+ }
40
+
41
+ for (const serverName of configuredServerNames) {
42
+ const trimmedServerName = serverName.trim();
43
+ if (!trimmedServerName) {
44
+ continue;
45
+ }
46
+
47
+ if (!trimmedToolName.endsWith(`_${trimmedServerName}`)) {
48
+ continue;
49
+ }
50
+
51
+ if (trimmedToolName.startsWith(`${trimmedServerName}_`)) {
52
+ continue;
53
+ }
54
+
55
+ pushTarget(`${trimmedServerName}_${trimmedToolName}`);
56
+ pushTarget(`${trimmedServerName}:${trimmedToolName}`);
57
+ pushTarget(trimmedServerName);
58
+ }
59
+ }
60
+
61
+ function pushMcpToolPermissionTargets(
62
+ rawReference: string,
63
+ serverHint: string | null,
64
+ configuredServerNames: readonly string[],
65
+ pushTarget: (value: string | null) => void,
66
+ ): void {
67
+ const qualified = parseQualifiedMcpToolName(rawReference);
68
+ const resolvedServer = serverHint ?? qualified?.server ?? null;
69
+ const resolvedTool = qualified?.tool ?? rawReference;
70
+
71
+ if (resolvedServer) {
72
+ pushTarget(`${resolvedServer}_${resolvedTool}`);
73
+ pushTarget(`${resolvedServer}:${resolvedTool}`);
74
+ pushTarget(resolvedServer);
75
+ } else {
76
+ addDerivedMcpServerTargets(resolvedTool, configuredServerNames, pushTarget);
77
+ }
78
+
79
+ pushTarget(resolvedTool);
80
+ pushTarget(rawReference);
81
+ }
82
+
83
+ /**
84
+ * Derive the ordered list of MCP permission-lookup candidates from a raw MCP
85
+ * tool invocation input.
86
+ *
87
+ * Candidates are ordered from most-specific to least-specific so that
88
+ * `evaluateFirst()` stops at the first non-default match.
89
+ */
90
+ export function createMcpPermissionTargets(
91
+ input: unknown,
92
+ configuredServerNames: readonly string[] = [],
93
+ ): string[] {
94
+ const record = toRecord(input);
95
+ const tool = getNonEmptyString(record.tool);
96
+ const server = getNonEmptyString(record.server);
97
+ const connect = getNonEmptyString(record.connect);
98
+ const describe = getNonEmptyString(record.describe);
99
+ const search = getNonEmptyString(record.search);
100
+
101
+ const targets: string[] = [];
102
+ const pushTarget = (value: string | null) => {
103
+ if (!value) {
104
+ return;
105
+ }
106
+ if (!targets.includes(value)) {
107
+ targets.push(value);
108
+ }
109
+ };
110
+
111
+ if (tool) {
112
+ pushMcpToolPermissionTargets(
113
+ tool,
114
+ server,
115
+ configuredServerNames,
116
+ pushTarget,
117
+ );
118
+ pushTarget("mcp_call");
119
+ return targets;
120
+ }
121
+
122
+ if (connect) {
123
+ pushTarget(`mcp_connect_${connect}`);
124
+ pushTarget(connect);
125
+ pushTarget("mcp_connect");
126
+ return targets;
127
+ }
128
+
129
+ if (describe) {
130
+ pushMcpToolPermissionTargets(
131
+ describe,
132
+ server,
133
+ configuredServerNames,
134
+ pushTarget,
135
+ );
136
+ pushTarget("mcp_describe");
137
+ return targets;
138
+ }
139
+
140
+ if (search) {
141
+ if (server) {
142
+ pushTarget(`mcp_server_${server}`);
143
+ pushTarget(server);
144
+ }
145
+
146
+ pushTarget(search);
147
+ pushTarget("mcp_search");
148
+ return targets;
149
+ }
150
+
151
+ if (server) {
152
+ pushTarget(`mcp_server_${server}`);
153
+ pushTarget(server);
154
+ pushTarget("mcp_list");
155
+ return targets;
156
+ }
157
+
158
+ pushTarget("mcp_status");
159
+ return targets;
160
+ }
@@ -4,7 +4,6 @@ import { getAgentDir } from "@mariozechner/pi-coding-agent";
4
4
 
5
5
  import {
6
6
  extractFrontmatter,
7
- getNonEmptyString,
8
7
  isPermissionState,
9
8
  parseSimpleYamlMap,
10
9
  toRecord,
@@ -15,9 +14,10 @@ import {
15
14
  stripJsonComments,
16
15
  } from "./config-loader";
17
16
  import { getGlobalConfigPath } from "./config-paths";
17
+ import { normalizeInput } from "./input-normalizer";
18
18
  import { normalizeFlatConfig } from "./normalize";
19
19
  import type { Rule, Ruleset } from "./rule";
20
- import { evaluate } from "./rule";
20
+ import { evaluate, evaluateFirst } from "./rule";
21
21
  import {
22
22
  composeRuleset,
23
23
  synthesizeBaseline,
@@ -453,330 +453,73 @@ export class PermissionManager {
453
453
  const { composedRules } = this.resolvePermissions(agentName);
454
454
  const normalizedToolName = toolName.trim();
455
455
 
456
- // --- Special surfaces (external_directory) ---
457
- if (SPECIAL_PERMISSION_KEYS.has(normalizedToolName)) {
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
- );
484
- return {
485
- toolName,
486
- state: rule.action,
487
- matchedPattern: rule.layer === "config" ? rule.pattern : undefined,
488
- source: "special",
489
- };
490
- }
491
-
492
- // --- Skills ---
493
- if (normalizedToolName === "skill") {
494
- const skillName = toRecord(input).name;
495
- const lookupValue = typeof skillName === "string" ? skillName : "*";
496
-
497
- // Session check.
498
- if (sessionRules && sessionRules.length > 0) {
499
- const sessionRule = evaluate("skill", lookupValue, sessionRules);
500
- if (sessionRules.includes(sessionRule)) {
501
- return {
502
- toolName,
503
- state: "allow",
504
- matchedPattern: sessionRule.pattern,
505
- source: "session",
506
- };
507
- }
508
- }
509
-
510
- const rule = evaluate("skill", lookupValue, composedRules);
511
- return {
512
- toolName,
513
- state: rule.action,
514
- matchedPattern: rule.layer === "config" ? rule.pattern : undefined,
515
- source: "skill",
516
- };
517
- }
518
-
519
- // --- Bash ---
520
- if (normalizedToolName === "bash") {
521
- const record = toRecord(input);
522
- const command = typeof record.command === "string" ? record.command : "";
523
-
524
- // Session check.
525
- if (sessionRules && sessionRules.length > 0) {
526
- const sessionRule = evaluate("bash", command, sessionRules);
527
- if (sessionRules.includes(sessionRule)) {
528
- return {
529
- toolName,
530
- state: "allow",
531
- command,
532
- matchedPattern: sessionRule.pattern,
533
- source: "session",
534
- };
535
- }
536
- }
537
-
538
- const rule = evaluate("bash", command, composedRules);
539
- return {
540
- toolName,
541
- state: rule.action,
542
- command,
543
- matchedPattern: rule.layer === "config" ? rule.pattern : undefined,
544
- source: "bash",
545
- };
546
- }
456
+ // Append session rules at the end (highest priority) so evaluate() handles
457
+ // them via last-match-wins — no separate per-branch pre-check needed.
458
+ const fullRules: Ruleset = sessionRules?.length
459
+ ? [...composedRules, ...sessionRules]
460
+ : composedRules;
547
461
 
548
- // --- MCP ---
549
- if (normalizedToolName === "mcp") {
550
- const mcpTargets = [
551
- ...createMcpPermissionTargets(
552
- input,
553
- this.getConfiguredMcpServerNames(),
554
- ),
555
- "mcp",
556
- ];
557
- const fallbackTarget = mcpTargets[0] || "mcp";
558
-
559
- // Session check: try each candidate target against session rules.
560
- if (sessionRules && sessionRules.length > 0) {
561
- for (const target of mcpTargets) {
562
- const sessionRule = evaluate("mcp", target, sessionRules);
563
- if (sessionRules.includes(sessionRule)) {
564
- return {
565
- toolName,
566
- state: "allow",
567
- matchedPattern: sessionRule.pattern,
568
- target,
569
- source: "session",
570
- };
571
- }
572
- }
573
- }
574
-
575
- // Try each candidate target. Stop on the first non-default match.
576
- for (const target of mcpTargets) {
577
- const rule = evaluate("mcp", target, composedRules);
578
- if (rule.layer !== "default") {
579
- return {
580
- toolName,
581
- state: rule.action,
582
- matchedPattern: rule.layer === "config" ? rule.pattern : undefined,
583
- target,
584
- source: rule.layer === "override" ? "tool" : "mcp",
585
- };
586
- }
587
- }
588
-
589
- // All targets matched only the synthesized default.
590
- const defaultRule = evaluate("mcp", fallbackTarget, composedRules);
591
- return {
592
- toolName,
593
- state: defaultRule.action,
594
- target: fallbackTarget,
595
- source: "default",
596
- };
597
- }
598
-
599
- // --- Tools (read, write, edit, grep, find, ls, extension tools) ---
600
-
601
- // Session check.
602
- if (sessionRules && sessionRules.length > 0) {
603
- const sessionRule = evaluate(normalizedToolName, "*", sessionRules);
604
- if (sessionRules.includes(sessionRule)) {
605
- return {
606
- toolName,
607
- state: "allow",
608
- matchedPattern: sessionRule.pattern,
609
- source: "session",
610
- };
611
- }
612
- }
462
+ const { surface, values, resultExtras } = normalizeInput(
463
+ normalizedToolName,
464
+ input,
465
+ this.getConfiguredMcpServerNames(),
466
+ );
613
467
 
614
- const rule = evaluate(normalizedToolName, "*", composedRules);
468
+ const { rule, value } = evaluateFirst(surface, values, fullRules);
615
469
 
616
- if (BUILT_IN_TOOL_PERMISSION_NAMES.has(normalizedToolName)) {
617
- return {
618
- toolName,
619
- state: rule.action,
620
- source: "tool",
621
- };
622
- }
470
+ // For MCP, replace the normalizer's fallback target with the actual
471
+ // matched candidate value so PermissionCheckResult.target is accurate.
472
+ const extras =
473
+ surface === "mcp" ? { ...resultExtras, target: value } : resultExtras;
623
474
 
624
475
  return {
625
476
  toolName,
626
477
  state: rule.action,
627
- source: rule.layer === "default" ? "default" : "tool",
478
+ matchedPattern:
479
+ rule.layer === "config" || rule.layer === "session"
480
+ ? rule.pattern
481
+ : undefined,
482
+ source: deriveSource(rule, normalizedToolName),
483
+ ...extras,
628
484
  };
629
485
  }
630
486
  }
631
487
 
632
- // ---------------------------------------------------------------------------
633
- // MCP target derivation helpers (unchanged)
634
- // ---------------------------------------------------------------------------
635
-
636
- function parseQualifiedMcpToolName(
637
- value: string,
638
- ): { server: string; tool: string } | null {
639
- const trimmed = value.trim();
640
- if (!trimmed) {
641
- return null;
642
- }
643
-
644
- const colonIndex = trimmed.indexOf(":");
645
- if (colonIndex <= 0 || colonIndex >= trimmed.length - 1) {
646
- return null;
647
- }
648
-
649
- const server = trimmed.slice(0, colonIndex).trim();
650
- const tool = trimmed.slice(colonIndex + 1).trim();
651
- if (!server || !tool) {
652
- return null;
653
- }
654
-
655
- return { server, tool };
656
- }
657
-
658
- function addDerivedMcpServerTargets(
488
+ /**
489
+ * Map a matched rule + tool name to the correct PermissionCheckResult.source.
490
+ *
491
+ * Mirrors the source-derivation logic from the former per-branch
492
+ * checkPermission() implementation:
493
+ *
494
+ * - session → "session" (always, all surfaces)
495
+ * - mcp + default → "default"
496
+ * - mcp + override → "tool"
497
+ * - mcp + other → "mcp"
498
+ * - special → "special" (always)
499
+ * - skill → "skill" (always)
500
+ * - bash → "bash" (always)
501
+ * - built-in tool → "tool" (always)
502
+ * - extension tool → "default" when default layer, "tool" otherwise
503
+ */
504
+ function deriveSource(
505
+ rule: Rule,
659
506
  toolName: string,
660
- configuredServerNames: readonly string[],
661
- pushTarget: (value: string | null) => void,
662
- ): void {
663
- const trimmedToolName = toolName.trim();
664
- if (!trimmedToolName) {
665
- return;
666
- }
667
-
668
- for (const serverName of configuredServerNames) {
669
- const trimmedServerName = serverName.trim();
670
- if (!trimmedServerName) {
671
- continue;
672
- }
673
-
674
- if (!trimmedToolName.endsWith(`_${trimmedServerName}`)) {
675
- continue;
676
- }
677
-
678
- if (trimmedToolName.startsWith(`${trimmedServerName}_`)) {
679
- continue;
680
- }
507
+ ): PermissionCheckResult["source"] {
508
+ if (rule.layer === "session") return "session";
681
509
 
682
- pushTarget(`${trimmedServerName}_${trimmedToolName}`);
683
- pushTarget(`${trimmedServerName}:${trimmedToolName}`);
684
- pushTarget(trimmedServerName);
685
- }
686
- }
687
-
688
- function pushMcpToolPermissionTargets(
689
- rawReference: string,
690
- serverHint: string | null,
691
- configuredServerNames: readonly string[],
692
- pushTarget: (value: string | null) => void,
693
- ): void {
694
- const qualified = parseQualifiedMcpToolName(rawReference);
695
- const resolvedServer = serverHint ?? qualified?.server ?? null;
696
- const resolvedTool = qualified?.tool ?? rawReference;
697
-
698
- if (resolvedServer) {
699
- pushTarget(`${resolvedServer}_${resolvedTool}`);
700
- pushTarget(`${resolvedServer}:${resolvedTool}`);
701
- pushTarget(resolvedServer);
702
- } else {
703
- addDerivedMcpServerTargets(resolvedTool, configuredServerNames, pushTarget);
510
+ if (toolName === "mcp") {
511
+ if (rule.layer === "default") return "default";
512
+ if (rule.layer === "override") return "tool";
513
+ return "mcp";
704
514
  }
705
515
 
706
- pushTarget(resolvedTool);
707
- pushTarget(rawReference);
708
- }
709
-
710
- function createMcpPermissionTargets(
711
- input: unknown,
712
- configuredServerNames: readonly string[] = [],
713
- ): string[] {
714
- const record = toRecord(input);
715
- const tool = getNonEmptyString(record.tool);
716
- const server = getNonEmptyString(record.server);
717
- const connect = getNonEmptyString(record.connect);
718
- const describe = getNonEmptyString(record.describe);
719
- const search = getNonEmptyString(record.search);
720
-
721
- const targets: string[] = [];
722
- const pushTarget = (value: string | null) => {
723
- if (!value) {
724
- return;
725
- }
726
- if (!targets.includes(value)) {
727
- targets.push(value);
728
- }
729
- };
730
-
731
- if (tool) {
732
- pushMcpToolPermissionTargets(
733
- tool,
734
- server,
735
- configuredServerNames,
736
- pushTarget,
737
- );
738
- pushTarget("mcp_call");
739
- return targets;
740
- }
741
-
742
- if (connect) {
743
- pushTarget(`mcp_connect_${connect}`);
744
- pushTarget(connect);
745
- pushTarget("mcp_connect");
746
- return targets;
747
- }
748
-
749
- if (describe) {
750
- pushMcpToolPermissionTargets(
751
- describe,
752
- server,
753
- configuredServerNames,
754
- pushTarget,
755
- );
756
- pushTarget("mcp_describe");
757
- return targets;
758
- }
759
-
760
- if (search) {
761
- if (server) {
762
- pushTarget(`mcp_server_${server}`);
763
- pushTarget(server);
764
- }
765
-
766
- pushTarget(search);
767
- pushTarget("mcp_search");
768
- return targets;
769
- }
770
-
771
- if (server) {
772
- pushTarget(`mcp_server_${server}`);
773
- pushTarget(server);
774
- pushTarget("mcp_list");
775
- return targets;
776
- }
516
+ if (SPECIAL_PERMISSION_KEYS.has(toolName)) return "special";
517
+ if (toolName === "skill") return "skill";
518
+ if (toolName === "bash") return "bash";
777
519
 
778
- pushTarget("mcp_status");
779
- return targets;
520
+ // Built-in tools always report "tool"; extension tools distinguish default.
521
+ if (BUILT_IN_TOOL_PERMISSION_NAMES.has(toolName)) return "tool";
522
+ return rule.layer === "default" ? "default" : "tool";
780
523
  }
781
524
 
782
525
  // Keep isPermissionState and toRecord available for convenience — they are