@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 +17 -0
- package/package.json +1 -1
- package/src/input-normalizer.ts +94 -0
- package/src/mcp-targets.ts +160 -0
- package/src/permission-manager.ts +53 -310
- package/src/rule.ts +32 -0
- package/tests/input-normalizer.test.ts +150 -0
- package/tests/mcp-targets.test.ts +178 -0
- package/tests/permission-manager-unified.test.ts +375 -0
- package/tests/rule.test.ts +81 -1
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
|
@@ -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
|
-
//
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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 =
|
|
468
|
+
const { rule, value } = evaluateFirst(surface, values, fullRules);
|
|
615
469
|
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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
|
-
|
|
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
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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
|
-
|
|
661
|
-
|
|
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
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
-
|
|
707
|
-
|
|
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
|
-
|
|
779
|
-
return
|
|
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
|