@gotgenes/pi-permission-system 0.7.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.
@@ -0,0 +1,989 @@
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { getAgentDir } from "@mariozechner/pi-coding-agent";
4
+
5
+ import { BashFilter } from "./bash-filter.js";
6
+ import {
7
+ extractFrontmatter,
8
+ getNonEmptyString,
9
+ isPermissionState,
10
+ parseSimpleYamlMap,
11
+ toRecord,
12
+ } from "./common.js";
13
+ import type {
14
+ AgentPermissions,
15
+ BashPermissions,
16
+ GlobalPermissionConfig,
17
+ PermissionCheckResult,
18
+ PermissionDefaultPolicy,
19
+ PermissionState,
20
+ } from "./types.js";
21
+ import {
22
+ type CompiledWildcardPattern,
23
+ compileWildcardPatternEntries,
24
+ findCompiledWildcardMatch,
25
+ findCompiledWildcardMatchForNames,
26
+ } from "./wildcard-matcher.js";
27
+
28
+ function defaultGlobalConfigPath(): string {
29
+ return join(getAgentDir(), "pi-permissions.jsonc");
30
+ }
31
+ function defaultAgentsDir(): string {
32
+ return join(getAgentDir(), "agents");
33
+ }
34
+ function defaultLegacyGlobalSettingsPath(): string {
35
+ return join(getAgentDir(), "settings.json");
36
+ }
37
+ function defaultGlobalMcpConfigPath(): string {
38
+ return join(getAgentDir(), "mcp.json");
39
+ }
40
+
41
+ const BUILT_IN_TOOL_PERMISSION_NAMES = new Set([
42
+ "bash",
43
+ "read",
44
+ "write",
45
+ "edit",
46
+ "grep",
47
+ "find",
48
+ "ls",
49
+ ]);
50
+ const SPECIAL_PERMISSION_KEYS = new Set(["doom_loop", "external_directory"]);
51
+ const MCP_BASELINE_TARGETS = new Set([
52
+ "mcp_status",
53
+ "mcp_list",
54
+ "mcp_search",
55
+ "mcp_describe",
56
+ "mcp_connect",
57
+ ]);
58
+
59
+ const DEFAULT_POLICY: PermissionDefaultPolicy = {
60
+ tools: "ask",
61
+ bash: "ask",
62
+ mcp: "ask",
63
+ skills: "ask",
64
+ special: "ask",
65
+ };
66
+
67
+ const EMPTY_GLOBAL_CONFIG: GlobalPermissionConfig = {
68
+ defaultPolicy: DEFAULT_POLICY,
69
+ tools: {},
70
+ bash: {},
71
+ mcp: {},
72
+ skills: {},
73
+ special: {},
74
+ };
75
+
76
+ function stripJsonComments(input: string): string {
77
+ let output = "";
78
+ let inString = false;
79
+ let stringQuote: '"' | "'" | "" = "";
80
+ let escaping = false;
81
+ let inLineComment = false;
82
+ let inBlockComment = false;
83
+
84
+ for (let i = 0; i < input.length; i++) {
85
+ const char = input[i];
86
+ const next = input[i + 1] || "";
87
+
88
+ if (inLineComment) {
89
+ if (char === "\n") {
90
+ inLineComment = false;
91
+ output += char;
92
+ }
93
+ continue;
94
+ }
95
+
96
+ if (inBlockComment) {
97
+ if (char === "*" && next === "/") {
98
+ inBlockComment = false;
99
+ i++;
100
+ }
101
+ continue;
102
+ }
103
+
104
+ if (!inString && char === "/" && next === "/") {
105
+ inLineComment = true;
106
+ i++;
107
+ continue;
108
+ }
109
+
110
+ if (!inString && char === "/" && next === "*") {
111
+ inBlockComment = true;
112
+ i++;
113
+ continue;
114
+ }
115
+
116
+ output += char;
117
+
118
+ if (!inString && (char === '"' || char === "'")) {
119
+ inString = true;
120
+ stringQuote = char;
121
+ escaping = false;
122
+ continue;
123
+ }
124
+
125
+ if (!inString) {
126
+ continue;
127
+ }
128
+
129
+ if (escaping) {
130
+ escaping = false;
131
+ continue;
132
+ }
133
+
134
+ if (char === "\\") {
135
+ escaping = true;
136
+ continue;
137
+ }
138
+
139
+ if (char === stringQuote) {
140
+ inString = false;
141
+ stringQuote = "";
142
+ }
143
+ }
144
+
145
+ return output;
146
+ }
147
+
148
+ function normalizePolicy(value: unknown): PermissionDefaultPolicy {
149
+ const record = toRecord(value);
150
+ return {
151
+ tools: isPermissionState(record.tools)
152
+ ? record.tools
153
+ : DEFAULT_POLICY.tools,
154
+ bash: isPermissionState(record.bash) ? record.bash : DEFAULT_POLICY.bash,
155
+ mcp: isPermissionState(record.mcp) ? record.mcp : DEFAULT_POLICY.mcp,
156
+ skills: isPermissionState(record.skills)
157
+ ? record.skills
158
+ : DEFAULT_POLICY.skills,
159
+ special: isPermissionState(record.special)
160
+ ? record.special
161
+ : DEFAULT_POLICY.special,
162
+ };
163
+ }
164
+
165
+ function normalizePartialPolicy(
166
+ value: unknown,
167
+ ): Partial<PermissionDefaultPolicy> {
168
+ const record = toRecord(value);
169
+ const normalized: Partial<PermissionDefaultPolicy> = {};
170
+
171
+ if (isPermissionState(record.tools)) {
172
+ normalized.tools = record.tools;
173
+ }
174
+
175
+ if (isPermissionState(record.bash)) {
176
+ normalized.bash = record.bash;
177
+ }
178
+
179
+ if (isPermissionState(record.mcp)) {
180
+ normalized.mcp = record.mcp;
181
+ }
182
+
183
+ if (isPermissionState(record.skills)) {
184
+ normalized.skills = record.skills;
185
+ }
186
+
187
+ if (isPermissionState(record.special)) {
188
+ normalized.special = record.special;
189
+ }
190
+
191
+ return normalized;
192
+ }
193
+
194
+ function normalizePermissionRecord(
195
+ value: unknown,
196
+ ): Record<string, PermissionState> {
197
+ const record = toRecord(value);
198
+ const normalized: Record<string, PermissionState> = {};
199
+ for (const [key, state] of Object.entries(record)) {
200
+ if (isPermissionState(state)) {
201
+ normalized[key] = state;
202
+ }
203
+ }
204
+ return normalized;
205
+ }
206
+
207
+ function readConfiguredMcpServerNamesFromConfigPath(
208
+ configPath: string,
209
+ ): string[] {
210
+ try {
211
+ const raw = readFileSync(configPath, "utf-8");
212
+ const parsed = JSON.parse(stripJsonComments(raw)) as unknown;
213
+ const root = toRecord(parsed);
214
+ const serverRecord = toRecord(root.mcpServers ?? root["mcp-servers"]);
215
+
216
+ return Object.keys(serverRecord)
217
+ .map((name) => name.trim())
218
+ .filter((name) => name.length > 0);
219
+ } catch {
220
+ return [];
221
+ }
222
+ }
223
+
224
+ function getConfiguredMcpServerNamesFromPaths(
225
+ paths: readonly string[],
226
+ ): string[] {
227
+ const seen = new Set<string>();
228
+
229
+ for (const path of paths) {
230
+ for (const name of readConfiguredMcpServerNamesFromConfigPath(path)) {
231
+ seen.add(name);
232
+ }
233
+ }
234
+
235
+ return [...seen].sort(
236
+ (left, right) => right.length - left.length || left.localeCompare(right),
237
+ );
238
+ }
239
+
240
+ function normalizeRawPermission(raw: unknown): AgentPermissions {
241
+ const record = toRecord(raw);
242
+ const normalizedTools = normalizePermissionRecord(record.tools);
243
+
244
+ const normalized: AgentPermissions = {
245
+ defaultPolicy: normalizePartialPolicy(record.defaultPolicy),
246
+ tools: normalizedTools,
247
+ bash: normalizePermissionRecord(record.bash),
248
+ mcp: normalizePermissionRecord(record.mcp),
249
+ skills: normalizePermissionRecord(record.skills),
250
+ special: normalizePermissionRecord(record.special),
251
+ };
252
+
253
+ for (const [key, value] of Object.entries(record)) {
254
+ if (!isPermissionState(value)) {
255
+ continue;
256
+ }
257
+
258
+ if (BUILT_IN_TOOL_PERMISSION_NAMES.has(key)) {
259
+ normalized.tools = { ...(normalized.tools || {}), [key]: value };
260
+ continue;
261
+ }
262
+
263
+ if (SPECIAL_PERMISSION_KEYS.has(key)) {
264
+ normalized.special = { ...(normalized.special || {}), [key]: value };
265
+ }
266
+ }
267
+
268
+ return normalized;
269
+ }
270
+
271
+ function parseQualifiedMcpToolName(
272
+ value: string,
273
+ ): { server: string; tool: string } | null {
274
+ const trimmed = value.trim();
275
+ if (!trimmed) {
276
+ return null;
277
+ }
278
+
279
+ const colonIndex = trimmed.indexOf(":");
280
+ if (colonIndex <= 0 || colonIndex >= trimmed.length - 1) {
281
+ return null;
282
+ }
283
+
284
+ const server = trimmed.slice(0, colonIndex).trim();
285
+ const tool = trimmed.slice(colonIndex + 1).trim();
286
+ if (!server || !tool) {
287
+ return null;
288
+ }
289
+
290
+ return { server, tool };
291
+ }
292
+
293
+ function addDerivedMcpServerTargets(
294
+ toolName: string,
295
+ configuredServerNames: readonly string[],
296
+ pushTarget: (value: string | null) => void,
297
+ ): void {
298
+ const trimmedToolName = toolName.trim();
299
+ if (!trimmedToolName) {
300
+ return;
301
+ }
302
+
303
+ for (const serverName of configuredServerNames) {
304
+ const trimmedServerName = serverName.trim();
305
+ if (!trimmedServerName) {
306
+ continue;
307
+ }
308
+
309
+ if (!trimmedToolName.endsWith(`_${trimmedServerName}`)) {
310
+ continue;
311
+ }
312
+
313
+ if (trimmedToolName.startsWith(`${trimmedServerName}_`)) {
314
+ continue;
315
+ }
316
+
317
+ pushTarget(`${trimmedServerName}_${trimmedToolName}`);
318
+ pushTarget(`${trimmedServerName}:${trimmedToolName}`);
319
+ pushTarget(trimmedServerName);
320
+ }
321
+ }
322
+
323
+ function pushMcpToolPermissionTargets(
324
+ rawReference: string,
325
+ serverHint: string | null,
326
+ configuredServerNames: readonly string[],
327
+ pushTarget: (value: string | null) => void,
328
+ ): void {
329
+ const qualified = parseQualifiedMcpToolName(rawReference);
330
+ const resolvedServer = serverHint ?? qualified?.server ?? null;
331
+ const resolvedTool = qualified?.tool ?? rawReference;
332
+
333
+ if (resolvedServer) {
334
+ pushTarget(`${resolvedServer}_${resolvedTool}`);
335
+ pushTarget(`${resolvedServer}:${resolvedTool}`);
336
+ pushTarget(resolvedServer);
337
+ } else {
338
+ addDerivedMcpServerTargets(resolvedTool, configuredServerNames, pushTarget);
339
+ }
340
+
341
+ pushTarget(resolvedTool);
342
+ pushTarget(rawReference);
343
+ }
344
+
345
+ function createMcpPermissionTargets(
346
+ input: unknown,
347
+ configuredServerNames: readonly string[] = [],
348
+ ): string[] {
349
+ const record = toRecord(input);
350
+ const tool = getNonEmptyString(record.tool);
351
+ const server = getNonEmptyString(record.server);
352
+ const connect = getNonEmptyString(record.connect);
353
+ const describe = getNonEmptyString(record.describe);
354
+ const search = getNonEmptyString(record.search);
355
+
356
+ const targets: string[] = [];
357
+ const pushTarget = (value: string | null) => {
358
+ if (!value) {
359
+ return;
360
+ }
361
+ if (!targets.includes(value)) {
362
+ targets.push(value);
363
+ }
364
+ };
365
+
366
+ if (tool) {
367
+ pushMcpToolPermissionTargets(
368
+ tool,
369
+ server,
370
+ configuredServerNames,
371
+ pushTarget,
372
+ );
373
+ pushTarget("mcp_call");
374
+ return targets;
375
+ }
376
+
377
+ if (connect) {
378
+ pushTarget(`mcp_connect_${connect}`);
379
+ pushTarget(connect);
380
+ pushTarget("mcp_connect");
381
+ return targets;
382
+ }
383
+
384
+ if (describe) {
385
+ pushMcpToolPermissionTargets(
386
+ describe,
387
+ server,
388
+ configuredServerNames,
389
+ pushTarget,
390
+ );
391
+ pushTarget("mcp_describe");
392
+ return targets;
393
+ }
394
+
395
+ if (search) {
396
+ if (server) {
397
+ pushTarget(`mcp_server_${server}`);
398
+ pushTarget(server);
399
+ }
400
+
401
+ pushTarget(search);
402
+ pushTarget("mcp_search");
403
+ return targets;
404
+ }
405
+
406
+ if (server) {
407
+ pushTarget(`mcp_server_${server}`);
408
+ pushTarget(server);
409
+ pushTarget("mcp_list");
410
+ return targets;
411
+ }
412
+
413
+ pushTarget("mcp_status");
414
+ return targets;
415
+ }
416
+
417
+ type CompiledPermissionPatterns =
418
+ readonly CompiledWildcardPattern<PermissionState>[];
419
+
420
+ export interface ResolvedPolicyPaths {
421
+ globalConfigPath: string;
422
+ globalConfigExists: boolean;
423
+ projectConfigPath: string | null;
424
+ projectConfigExists: boolean;
425
+ agentsDir: string;
426
+ agentsDirExists: boolean;
427
+ projectAgentsDir: string | null;
428
+ projectAgentsDirExists: boolean;
429
+ }
430
+
431
+ type ResolvedPermissions = {
432
+ globalConfig: GlobalPermissionConfig;
433
+ agentConfig: AgentPermissions;
434
+ merged: GlobalPermissionConfig;
435
+ compiledSpecial: CompiledPermissionPatterns;
436
+ compiledSkills: CompiledPermissionPatterns;
437
+ compiledMcp: CompiledPermissionPatterns;
438
+ bashFilter: BashFilter;
439
+ };
440
+
441
+ function compilePermissionPatternsFromSources(
442
+ ...sources: Array<Record<string, PermissionState> | undefined>
443
+ ): CompiledPermissionPatterns {
444
+ const entries: Array<readonly [string, PermissionState]> = [];
445
+
446
+ for (const source of sources) {
447
+ if (!source) {
448
+ continue;
449
+ }
450
+
451
+ for (const entry of Object.entries(source)) {
452
+ entries.push(entry);
453
+ }
454
+ }
455
+
456
+ if (entries.length === 0) {
457
+ return [];
458
+ }
459
+
460
+ return compileWildcardPatternEntries(entries);
461
+ }
462
+
463
+ function findCompiledPermissionMatch(
464
+ patterns: CompiledPermissionPatterns,
465
+ name: string,
466
+ ) {
467
+ if (patterns.length === 0) {
468
+ return null;
469
+ }
470
+
471
+ return findCompiledWildcardMatch(patterns, name);
472
+ }
473
+
474
+ function findCompiledPermissionMatchForNames(
475
+ patterns: CompiledPermissionPatterns,
476
+ names: readonly string[],
477
+ ) {
478
+ if (patterns.length === 0) {
479
+ return null;
480
+ }
481
+
482
+ return findCompiledWildcardMatchForNames(patterns, names);
483
+ }
484
+
485
+ type FileCacheEntry<TValue> = {
486
+ stamp: string;
487
+ value: TValue;
488
+ };
489
+
490
+ function getFileStamp(path: string): string {
491
+ try {
492
+ return String(statSync(path).mtimeMs);
493
+ } catch {
494
+ return "missing";
495
+ }
496
+ }
497
+
498
+ export class PermissionManager {
499
+ private readonly globalConfigPath: string;
500
+ private readonly agentsDir: string;
501
+ private readonly projectGlobalConfigPath: string | null;
502
+ private readonly projectAgentsDir: string | null;
503
+ private readonly legacyGlobalSettingsPath: string;
504
+ private readonly globalMcpConfigPath: string;
505
+ private readonly configuredMcpServerNamesOverride: readonly string[] | null;
506
+ private globalConfigCache: FileCacheEntry<GlobalPermissionConfig> | null =
507
+ null;
508
+ private projectGlobalConfigCache: FileCacheEntry<AgentPermissions> | null =
509
+ null;
510
+ private readonly agentConfigCache = new Map<
511
+ string,
512
+ FileCacheEntry<AgentPermissions>
513
+ >();
514
+ private readonly projectAgentConfigCache = new Map<
515
+ string,
516
+ FileCacheEntry<AgentPermissions>
517
+ >();
518
+ private readonly resolvedPermissionsCache = new Map<
519
+ string,
520
+ FileCacheEntry<ResolvedPermissions>
521
+ >();
522
+ private configuredMcpServerNamesCache: FileCacheEntry<
523
+ readonly string[]
524
+ > | null = null;
525
+
526
+ constructor(
527
+ options: {
528
+ globalConfigPath?: string;
529
+ agentsDir?: string;
530
+ projectGlobalConfigPath?: string;
531
+ projectAgentsDir?: string;
532
+ legacyGlobalSettingsPath?: string;
533
+ globalMcpConfigPath?: string;
534
+ mcpServerNames?: readonly string[];
535
+ } = {},
536
+ ) {
537
+ this.globalConfigPath =
538
+ options.globalConfigPath || defaultGlobalConfigPath();
539
+ this.agentsDir = options.agentsDir || defaultAgentsDir();
540
+ this.projectGlobalConfigPath = options.projectGlobalConfigPath || null;
541
+ this.projectAgentsDir = options.projectAgentsDir || null;
542
+ this.legacyGlobalSettingsPath =
543
+ options.legacyGlobalSettingsPath || defaultLegacyGlobalSettingsPath();
544
+ this.globalMcpConfigPath =
545
+ options.globalMcpConfigPath || defaultGlobalMcpConfigPath();
546
+ this.configuredMcpServerNamesOverride = options.mcpServerNames
547
+ ? [
548
+ ...new Set(
549
+ options.mcpServerNames
550
+ .map((name) => name.trim())
551
+ .filter((name) => name.length > 0),
552
+ ),
553
+ ]
554
+ : null;
555
+ }
556
+
557
+ private loadGlobalConfig(): GlobalPermissionConfig {
558
+ const stamp = getFileStamp(this.globalConfigPath);
559
+ if (this.globalConfigCache?.stamp === stamp) {
560
+ return this.globalConfigCache.value;
561
+ }
562
+
563
+ let value: GlobalPermissionConfig;
564
+ try {
565
+ const raw = readFileSync(this.globalConfigPath, "utf-8");
566
+ const parsed = JSON.parse(stripJsonComments(raw)) as unknown;
567
+ const normalized = normalizeRawPermission(parsed);
568
+
569
+ value = {
570
+ defaultPolicy: normalizePolicy(normalized.defaultPolicy),
571
+ tools: normalized.tools || {},
572
+ bash: normalized.bash || {},
573
+ mcp: normalized.mcp || {},
574
+ skills: normalized.skills || {},
575
+ special: normalized.special || {},
576
+ };
577
+ } catch {
578
+ value = EMPTY_GLOBAL_CONFIG;
579
+ }
580
+
581
+ this.globalConfigCache = { stamp, value };
582
+ return value;
583
+ }
584
+
585
+ private loadProjectGlobalConfig(): AgentPermissions {
586
+ if (!this.projectGlobalConfigPath) {
587
+ return {};
588
+ }
589
+
590
+ const stamp = getFileStamp(this.projectGlobalConfigPath);
591
+ if (this.projectGlobalConfigCache?.stamp === stamp) {
592
+ return this.projectGlobalConfigCache.value;
593
+ }
594
+
595
+ let value: AgentPermissions;
596
+ try {
597
+ const raw = readFileSync(this.projectGlobalConfigPath, "utf-8");
598
+ const parsed = JSON.parse(stripJsonComments(raw)) as unknown;
599
+ value = normalizeRawPermission(parsed);
600
+ } catch {
601
+ value = {};
602
+ }
603
+
604
+ this.projectGlobalConfigCache = { stamp, value };
605
+ return value;
606
+ }
607
+
608
+ private loadAgentPermissionsFrom(
609
+ dir: string | null,
610
+ cache: Map<string, FileCacheEntry<AgentPermissions>>,
611
+ agentName?: string,
612
+ ): AgentPermissions {
613
+ if (!dir || !agentName) {
614
+ return {};
615
+ }
616
+
617
+ const filePath = join(dir, `${agentName}.md`);
618
+ const stamp = getFileStamp(filePath);
619
+ const cached = cache.get(agentName);
620
+ if (cached?.stamp === stamp) {
621
+ return cached.value;
622
+ }
623
+
624
+ let value: AgentPermissions;
625
+ try {
626
+ const markdown = readFileSync(filePath, "utf-8");
627
+ const frontmatter = extractFrontmatter(markdown);
628
+ if (!frontmatter) {
629
+ value = {};
630
+ } else {
631
+ const parsed = parseSimpleYamlMap(frontmatter);
632
+ value = normalizeRawPermission(parsed.permission);
633
+ }
634
+ } catch {
635
+ value = {};
636
+ }
637
+
638
+ cache.set(agentName, { stamp, value });
639
+ return value;
640
+ }
641
+
642
+ private loadAgentPermissions(agentName?: string): AgentPermissions {
643
+ return this.loadAgentPermissionsFrom(
644
+ this.agentsDir,
645
+ this.agentConfigCache,
646
+ agentName,
647
+ );
648
+ }
649
+
650
+ private loadProjectAgentPermissions(agentName?: string): AgentPermissions {
651
+ return this.loadAgentPermissionsFrom(
652
+ this.projectAgentsDir,
653
+ this.projectAgentConfigCache,
654
+ agentName,
655
+ );
656
+ }
657
+
658
+ private mergePermissions(
659
+ globalConfig: GlobalPermissionConfig,
660
+ agentConfig: AgentPermissions,
661
+ ): GlobalPermissionConfig {
662
+ return {
663
+ defaultPolicy: {
664
+ ...globalConfig.defaultPolicy,
665
+ ...(agentConfig.defaultPolicy || {}),
666
+ },
667
+ tools: {
668
+ ...(globalConfig.tools || {}),
669
+ ...(agentConfig.tools || {}),
670
+ },
671
+ bash: {
672
+ ...(globalConfig.bash || {}),
673
+ ...(agentConfig.bash || {}),
674
+ },
675
+ mcp: {
676
+ ...(globalConfig.mcp || {}),
677
+ ...(agentConfig.mcp || {}),
678
+ },
679
+ skills: {
680
+ ...(globalConfig.skills || {}),
681
+ ...(agentConfig.skills || {}),
682
+ },
683
+ special: {
684
+ ...(globalConfig.special || {}),
685
+ ...(agentConfig.special || {}),
686
+ },
687
+ };
688
+ }
689
+
690
+ getResolvedPolicyPaths(): ResolvedPolicyPaths {
691
+ return {
692
+ globalConfigPath: this.globalConfigPath,
693
+ globalConfigExists: existsSync(this.globalConfigPath),
694
+ projectConfigPath: this.projectGlobalConfigPath,
695
+ projectConfigExists: this.projectGlobalConfigPath
696
+ ? existsSync(this.projectGlobalConfigPath)
697
+ : false,
698
+ agentsDir: this.agentsDir,
699
+ agentsDirExists: existsSync(this.agentsDir),
700
+ projectAgentsDir: this.projectAgentsDir,
701
+ projectAgentsDirExists: this.projectAgentsDir
702
+ ? existsSync(this.projectAgentsDir)
703
+ : false,
704
+ };
705
+ }
706
+
707
+ getPolicyCacheStamp(agentName?: string): string {
708
+ const agentStamp = agentName
709
+ ? getFileStamp(join(this.agentsDir, `${agentName}.md`))
710
+ : "missing";
711
+ const projectStamp = this.projectGlobalConfigPath
712
+ ? getFileStamp(this.projectGlobalConfigPath)
713
+ : "none";
714
+ const projectAgentStamp =
715
+ this.projectAgentsDir && agentName
716
+ ? getFileStamp(join(this.projectAgentsDir, `${agentName}.md`))
717
+ : "none";
718
+
719
+ return `${getFileStamp(this.globalConfigPath)}|${projectStamp}|${agentStamp}|${projectAgentStamp}`;
720
+ }
721
+
722
+ private resolvePermissions(agentName?: string): ResolvedPermissions {
723
+ const cacheKey = agentName || "__global__";
724
+ const stamp = this.getPolicyCacheStamp(agentName);
725
+ const cached = this.resolvedPermissionsCache.get(cacheKey);
726
+ if (cached?.stamp === stamp) {
727
+ return cached.value;
728
+ }
729
+
730
+ const globalConfig = this.loadGlobalConfig();
731
+ const projectConfig = this.loadProjectGlobalConfig();
732
+ const agentConfig = this.loadAgentPermissions(agentName);
733
+ const projectAgentConfig = this.loadProjectAgentPermissions(agentName);
734
+
735
+ const mergedWithProject = this.mergePermissions(
736
+ globalConfig,
737
+ projectConfig,
738
+ );
739
+ const mergedWithAgent = this.mergePermissions(
740
+ mergedWithProject,
741
+ agentConfig,
742
+ );
743
+ const merged = this.mergePermissions(mergedWithAgent, projectAgentConfig);
744
+
745
+ const bashDefault =
746
+ projectAgentConfig.tools?.bash ||
747
+ agentConfig.tools?.bash ||
748
+ projectConfig.tools?.bash ||
749
+ merged.tools?.bash ||
750
+ merged.defaultPolicy.bash;
751
+ const value: ResolvedPermissions = {
752
+ globalConfig,
753
+ agentConfig,
754
+ merged,
755
+ compiledSpecial: compilePermissionPatternsFromSources(
756
+ globalConfig.special,
757
+ projectConfig.special,
758
+ agentConfig.special,
759
+ projectAgentConfig.special,
760
+ ),
761
+ compiledSkills: compilePermissionPatternsFromSources(
762
+ globalConfig.skills,
763
+ projectConfig.skills,
764
+ agentConfig.skills,
765
+ projectAgentConfig.skills,
766
+ ),
767
+ compiledMcp: compilePermissionPatternsFromSources(
768
+ globalConfig.mcp,
769
+ projectConfig.mcp,
770
+ agentConfig.mcp,
771
+ projectAgentConfig.mcp,
772
+ ),
773
+ bashFilter: new BashFilter(
774
+ compilePermissionPatternsFromSources(
775
+ globalConfig.bash,
776
+ projectConfig.bash,
777
+ agentConfig.bash,
778
+ projectAgentConfig.bash,
779
+ ),
780
+ bashDefault,
781
+ ),
782
+ };
783
+
784
+ this.resolvedPermissionsCache.set(cacheKey, { stamp, value });
785
+ return value;
786
+ }
787
+
788
+ getBashPermissions(agentName?: string): BashPermissions {
789
+ const { merged } = this.resolvePermissions(agentName);
790
+ return merged.bash || {};
791
+ }
792
+
793
+ private getConfiguredMcpServerNames(): readonly string[] {
794
+ if (this.configuredMcpServerNamesOverride) {
795
+ return this.configuredMcpServerNamesOverride;
796
+ }
797
+
798
+ const paths = [this.globalMcpConfigPath, this.legacyGlobalSettingsPath];
799
+ const stamp = paths
800
+ .map((path) => `${path}:${getFileStamp(path)}`)
801
+ .join("|");
802
+ if (this.configuredMcpServerNamesCache?.stamp === stamp) {
803
+ return this.configuredMcpServerNamesCache.value;
804
+ }
805
+
806
+ const value = getConfiguredMcpServerNamesFromPaths(paths);
807
+ this.configuredMcpServerNamesCache = { stamp, value };
808
+ return value;
809
+ }
810
+
811
+ /**
812
+ * Get the tool-level permission state for a tool, without considering command-level rules.
813
+ * This is used for tool injection decisions where we need to know if a tool is allowed/denied
814
+ * at the tool level before checking specific command permissions.
815
+ *
816
+ * Exact-name entries in `tools` work for arbitrary registered extension tools.
817
+ * Canonical Pi tools with dedicated categories still use their specialized fallbacks.
818
+ *
819
+ * @param toolName - The name of the tool (for example "bash", "read", or a third-party tool name)
820
+ * @param agentName - Optional agent name to check agent-specific permissions
821
+ * @returns The permission state for the tool at the tool level
822
+ */
823
+ getToolPermission(toolName: string, agentName?: string): PermissionState {
824
+ const { merged } = this.resolvePermissions(agentName);
825
+ const normalizedToolName = toolName.trim();
826
+
827
+ if (SPECIAL_PERMISSION_KEYS.has(normalizedToolName)) {
828
+ return merged.defaultPolicy.special;
829
+ }
830
+
831
+ if (normalizedToolName === "skill") {
832
+ return merged.defaultPolicy.skills;
833
+ }
834
+
835
+ if (normalizedToolName === "bash") {
836
+ return merged.tools?.bash || merged.defaultPolicy.bash;
837
+ }
838
+
839
+ if (normalizedToolName === "mcp") {
840
+ return merged.tools?.mcp || merged.defaultPolicy.mcp;
841
+ }
842
+
843
+ return merged.tools?.[normalizedToolName] || merged.defaultPolicy.tools;
844
+ }
845
+
846
+ checkPermission(
847
+ toolName: string,
848
+ input: unknown,
849
+ agentName?: string,
850
+ ): PermissionCheckResult {
851
+ const {
852
+ agentConfig: _agentConfig,
853
+ merged,
854
+ compiledSpecial,
855
+ compiledSkills,
856
+ compiledMcp,
857
+ bashFilter,
858
+ } = this.resolvePermissions(agentName);
859
+ const normalizedToolName = toolName.trim();
860
+
861
+ if (SPECIAL_PERMISSION_KEYS.has(normalizedToolName)) {
862
+ const result = findCompiledPermissionMatch(
863
+ compiledSpecial,
864
+ normalizedToolName,
865
+ );
866
+ return {
867
+ toolName,
868
+ state: result?.state || merged.defaultPolicy.special,
869
+ matchedPattern: result?.matchedPattern,
870
+ source: "special",
871
+ };
872
+ }
873
+
874
+ if (normalizedToolName === "skill") {
875
+ const skillName = toRecord(input).name;
876
+ if (typeof skillName === "string") {
877
+ const result = findCompiledPermissionMatch(compiledSkills, skillName);
878
+ return {
879
+ toolName,
880
+ state: result?.state || merged.defaultPolicy.skills,
881
+ matchedPattern: result?.matchedPattern,
882
+ source: "skill",
883
+ };
884
+ }
885
+
886
+ return {
887
+ toolName,
888
+ state: merged.defaultPolicy.skills,
889
+ source: "skill",
890
+ };
891
+ }
892
+
893
+ if (normalizedToolName === "bash") {
894
+ const record = toRecord(input);
895
+ const command = typeof record.command === "string" ? record.command : "";
896
+ const result = bashFilter.check(command);
897
+
898
+ return {
899
+ toolName,
900
+ state: result.state,
901
+ command: result.command,
902
+ matchedPattern: result.matchedPattern,
903
+ source: "bash",
904
+ };
905
+ }
906
+
907
+ if (normalizedToolName === "mcp") {
908
+ const mcpTargets = [
909
+ ...createMcpPermissionTargets(
910
+ input,
911
+ this.getConfiguredMcpServerNames(),
912
+ ),
913
+ "mcp",
914
+ ];
915
+ const fallbackTarget = mcpTargets[0] || "mcp";
916
+ const toolLevelMcpState = merged.tools?.mcp;
917
+
918
+ const mcpMatch = findCompiledPermissionMatchForNames(
919
+ compiledMcp,
920
+ mcpTargets,
921
+ );
922
+ if (mcpMatch) {
923
+ return {
924
+ toolName,
925
+ state: mcpMatch.state,
926
+ matchedPattern: mcpMatch.matchedPattern,
927
+ target: mcpMatch.matchedName,
928
+ source: "mcp",
929
+ };
930
+ }
931
+
932
+ if (toolLevelMcpState) {
933
+ return {
934
+ toolName,
935
+ state: toolLevelMcpState,
936
+ target: fallbackTarget,
937
+ source: "tool",
938
+ };
939
+ }
940
+
941
+ const baselineTarget = mcpTargets.find((target) =>
942
+ MCP_BASELINE_TARGETS.has(target),
943
+ );
944
+ if (baselineTarget) {
945
+ const hasAnyMcpAllowRule = Object.values(merged.mcp || {}).some(
946
+ (state) => state === "allow",
947
+ );
948
+ if (hasAnyMcpAllowRule || merged.defaultPolicy.mcp === "allow") {
949
+ return {
950
+ toolName,
951
+ state: "allow",
952
+ target: baselineTarget,
953
+ source: "mcp",
954
+ };
955
+ }
956
+ }
957
+
958
+ return {
959
+ toolName,
960
+ state: merged.defaultPolicy.mcp || "deny",
961
+ target: fallbackTarget,
962
+ source: "default",
963
+ };
964
+ }
965
+
966
+ if (BUILT_IN_TOOL_PERMISSION_NAMES.has(normalizedToolName)) {
967
+ return {
968
+ toolName,
969
+ state: merged.tools?.[normalizedToolName] || merged.defaultPolicy.tools,
970
+ source: "tool",
971
+ };
972
+ }
973
+
974
+ const explicitToolPermission = merged.tools?.[normalizedToolName];
975
+ if (explicitToolPermission) {
976
+ return {
977
+ toolName,
978
+ state: explicitToolPermission,
979
+ source: "tool",
980
+ };
981
+ }
982
+
983
+ return {
984
+ toolName,
985
+ state: merged.defaultPolicy.tools,
986
+ source: "default",
987
+ };
988
+ }
989
+ }