@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.
- package/CHANGELOG.md +55 -0
- package/README.md +135 -168
- package/config/config.example.json +11 -21
- package/package.json +1 -1
- package/schemas/permissions.schema.json +34 -102
- package/src/config-loader.ts +87 -118
- package/src/defaults.ts +6 -56
- package/src/extension-config.ts +3 -4
- package/src/handlers/tool-call.ts +15 -18
- package/src/normalize.ts +22 -60
- package/src/permission-manager.ts +309 -431
- package/src/rule.ts +5 -0
- package/src/session-rules.ts +1 -1
- package/src/synthesize.ts +87 -0
- package/src/types.ts +13 -19
- package/tests/config-loader.test.ts +113 -63
- package/tests/defaults.test.ts +8 -101
- package/tests/extension-config.test.ts +12 -4
- package/tests/normalize.test.ts +67 -64
- package/tests/permission-system.test.ts +310 -677
- package/tests/rule.test.ts +31 -0
- package/tests/session-rules.test.ts +1 -0
- package/tests/session-start.test.ts +1 -7
- package/tests/synthesize.test.ts +240 -0
|
@@ -9,15 +9,23 @@ import {
|
|
|
9
9
|
parseSimpleYamlMap,
|
|
10
10
|
toRecord,
|
|
11
11
|
} from "./common";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
loadUnifiedConfig,
|
|
14
|
+
normalizeUnifiedConfig,
|
|
15
|
+
stripJsonComments,
|
|
16
|
+
} from "./config-loader";
|
|
13
17
|
import { getGlobalConfigPath } from "./config-paths";
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
-
//
|
|
603
|
-
|
|
604
|
-
const
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
]
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
-
//
|
|
620
|
-
//
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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
|
|
670
|
-
*
|
|
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 {
|
|
683
|
-
this.resolvePermissions(agentName);
|
|
423
|
+
const { composedRules } = this.resolvePermissions(agentName);
|
|
684
424
|
const normalizedToolName = toolName.trim();
|
|
685
425
|
|
|
686
|
-
// Special
|
|
426
|
+
// Special surfaces (external_directory): evaluate directly by surface name.
|
|
687
427
|
if (SPECIAL_PERMISSION_KEYS.has(normalizedToolName)) {
|
|
688
|
-
|
|
689
|
-
if (rules.includes(rule)) return rule.action;
|
|
690
|
-
return defaults.special;
|
|
428
|
+
return evaluate(normalizedToolName, "*", composedRules).action;
|
|
691
429
|
}
|
|
692
430
|
|
|
693
|
-
// Bash
|
|
694
|
-
|
|
695
|
-
if (normalizedToolName === "
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
if (normalizedToolName === "
|
|
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
|
|
701
|
-
|
|
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 {
|
|
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
|
|
718
|
-
const
|
|
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:
|
|
722
|
-
matchedPattern:
|
|
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
|
-
|
|
731
|
-
|
|
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:
|
|
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,
|
|
752
|
-
const explicit = rules.includes(rule);
|
|
509
|
+
const rule = evaluate("bash", command, composedRules);
|
|
753
510
|
return {
|
|
754
511
|
toolName,
|
|
755
|
-
state:
|
|
512
|
+
state: rule.action,
|
|
756
513
|
command,
|
|
757
|
-
matchedPattern:
|
|
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
|
|
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,
|
|
776
|
-
if (
|
|
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:
|
|
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, "*",
|
|
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:
|
|
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 };
|