import { existsSync, readFileSync, statSync } from "node:fs"; import { join } from "node:path"; import { getAgentDir } from "@earendil-works/pi-coding-agent"; import { loadUnifiedConfig, normalizeFlatPermissionValue, stripJsonComments, } from "./config-loader"; import { getGlobalConfigPath } from "./config-paths"; import type { ScopeConfig } from "./types"; import { toRecord } from "./value-guards"; import { extractFrontmatter, parseSimpleYamlMap } from "./yaml-frontmatter"; // --------------------------------------------------------------------------- // File-stamp helper // --------------------------------------------------------------------------- function getFileStamp(path: string): string { try { return String(statSync(path).mtimeMs); } catch { return "missing"; } } // --------------------------------------------------------------------------- // MCP server-name reading helpers // --------------------------------------------------------------------------- function readConfiguredMcpServerNamesFromConfigPath( configPath: string, ): string[] { try { const raw = readFileSync(configPath, "utf-8"); const parsed = JSON.parse(stripJsonComments(raw)) as unknown; const root = toRecord(parsed); const serverRecord = toRecord(root.mcpServers ?? root["mcp-servers"]); return Object.keys(serverRecord) .map((name) => name.trim()) .filter((name) => name.length > 0); } catch { return []; } } function getConfiguredMcpServerNamesFromPaths( paths: readonly string[], ): string[] { const seen = new Set(); for (const path of paths) { for (const name of readConfiguredMcpServerNamesFromConfigPath(path)) { seen.add(name); } } return [...seen].sort( (left, right) => right.length - left.length || left.localeCompare(right), ); } // --------------------------------------------------------------------------- // Resolved policy paths // --------------------------------------------------------------------------- export interface ResolvedPolicyPaths { globalConfigPath: string; globalConfigExists: boolean; projectConfigPath: string | null; projectConfigExists: boolean; agentsDir: string; agentsDirExists: boolean; projectAgentsDir: string | null; projectAgentsDirExists: boolean; } // --------------------------------------------------------------------------- // PolicyLoader interface // --------------------------------------------------------------------------- /** * Abstraction over file I/O for loading permission policy from disk. * Implementations handle caching, path resolution, and config-issue * accumulation. `PermissionManager` depends on this interface so that * merge + evaluation logic can be tested with an in-memory stub. */ export interface PolicyLoader { loadGlobalConfig(): ScopeConfig; loadProjectConfig(): ScopeConfig; loadAgentConfig(agentName?: string): ScopeConfig; loadProjectAgentConfig(agentName?: string): ScopeConfig; getConfiguredMcpServerNames(): readonly string[]; /** Combined mtime stamp for cache invalidation. */ getCacheStamp(agentName?: string): string; /** Accumulated config-parse issues across all loads. */ getConfigIssues(): string[]; /** Resolved paths for the /permission-system show command. */ getResolvedPolicyPaths(): ResolvedPolicyPaths; } // --------------------------------------------------------------------------- // Default path factories (deferred until call-time, not module scope) // --------------------------------------------------------------------------- function defaultGlobalConfigPath(): string { return getGlobalConfigPath(getAgentDir()); } function defaultAgentsDir(): string { return join(getAgentDir(), "agents"); } function defaultGlobalMcpConfigPath(): string { return join(getAgentDir(), "mcp.json"); } // --------------------------------------------------------------------------- // File cache helper type // --------------------------------------------------------------------------- type FileCacheEntry = { stamp: string; value: TValue; }; // --------------------------------------------------------------------------- // Options shared between FilePolicyLoader and the backward-compat // PermissionManager constructor. // --------------------------------------------------------------------------- export interface PolicyLoaderOptions { globalConfigPath?: string; agentsDir?: string; projectGlobalConfigPath?: string; projectAgentsDir?: string; globalMcpConfigPath?: string; mcpServerNames?: readonly string[]; } // --------------------------------------------------------------------------- // FilePolicyLoader — the production implementation // --------------------------------------------------------------------------- /** * Production `PolicyLoader` that reads config files from disk with * mtime-based caching. */ export class FilePolicyLoader implements PolicyLoader { private readonly globalConfigPath: string; private readonly agentsDir: string; private readonly projectGlobalConfigPath: string | null; private readonly projectAgentsDir: string | null; private readonly globalMcpConfigPath: string; private readonly configuredMcpServerNamesOverride: readonly string[] | null; private globalConfigCache: FileCacheEntry | null = null; private projectGlobalConfigCache: FileCacheEntry | null = null; private readonly agentConfigCache = new Map< string, FileCacheEntry >(); private readonly projectAgentConfigCache = new Map< string, FileCacheEntry >(); private configuredMcpServerNamesCache: FileCacheEntry< readonly string[] > | null = null; private accumulatedConfigIssues: string[] = []; constructor(options: PolicyLoaderOptions = {}) { this.globalConfigPath = options.globalConfigPath ?? defaultGlobalConfigPath(); this.agentsDir = options.agentsDir ?? defaultAgentsDir(); this.projectGlobalConfigPath = options.projectGlobalConfigPath ?? null; this.projectAgentsDir = options.projectAgentsDir ?? null; this.globalMcpConfigPath = options.globalMcpConfigPath ?? defaultGlobalMcpConfigPath(); this.configuredMcpServerNamesOverride = options.mcpServerNames ? [ ...new Set( options.mcpServerNames .map((name) => name.trim()) .filter((name) => name.length > 0), ), ] : null; } // ── Config issue accumulation ──────────────────────────────────────── private accumulateConfigIssues(issues: string[]): void { for (const issue of issues) { if (!this.accumulatedConfigIssues.includes(issue)) { this.accumulatedConfigIssues.push(issue); } } } getConfigIssues(): string[] { return [...this.accumulatedConfigIssues]; } // ── Scope loaders ──────────────────────────────────────────────────── loadGlobalConfig(): ScopeConfig { const stamp = getFileStamp(this.globalConfigPath); if (this.globalConfigCache?.stamp === stamp) { return this.globalConfigCache.value; } const { config, issues } = loadUnifiedConfig(this.globalConfigPath); this.accumulateConfigIssues(issues); const value: ScopeConfig = { permission: config.permission, }; this.globalConfigCache = { stamp, value }; return value; } loadProjectConfig(): ScopeConfig { if (!this.projectGlobalConfigPath) { return {}; } const stamp = getFileStamp(this.projectGlobalConfigPath); if (this.projectGlobalConfigCache?.stamp === stamp) { return this.projectGlobalConfigCache.value; } const { config, issues } = loadUnifiedConfig(this.projectGlobalConfigPath); this.accumulateConfigIssues(issues); const value: ScopeConfig = { permission: config.permission, }; this.projectGlobalConfigCache = { stamp, value }; return value; } private loadScopeConfigFrom( dir: string | null, cache: Map>, agentName?: string, ): ScopeConfig { if (!dir || !agentName) { return {}; } const filePath = join(dir, `${agentName}.md`); const stamp = getFileStamp(filePath); const cached = cache.get(agentName); if (cached?.stamp === stamp) { return cached.value; } let value: ScopeConfig; try { const markdown = readFileSync(filePath, "utf-8"); const frontmatter = extractFrontmatter(markdown); if (!frontmatter) { value = {}; } else { // Agent frontmatter carries non-config keys (name, description, model, // …) alongside `permission`, so it is not validated by the strict // config-file schema; only the `permission` block is extracted, and its // malformed entries are dropped tolerantly as before. const parsed = parseSimpleYamlMap(frontmatter); value = { permission: normalizeFlatPermissionValue(parsed.permission), }; } } catch { value = {}; } cache.set(agentName, { stamp, value }); return value; } loadAgentConfig(agentName?: string): ScopeConfig { return this.loadScopeConfigFrom( this.agentsDir, this.agentConfigCache, agentName, ); } loadProjectAgentConfig(agentName?: string): ScopeConfig { return this.loadScopeConfigFrom( this.projectAgentsDir, this.projectAgentConfigCache, agentName, ); } // ── MCP server names ───────────────────────────────────────────────── getConfiguredMcpServerNames(): readonly string[] { if (this.configuredMcpServerNamesOverride) { return this.configuredMcpServerNamesOverride; } const paths = [this.globalMcpConfigPath]; const stamp = paths .map((path) => `${path}:${getFileStamp(path)}`) .join("|"); if (this.configuredMcpServerNamesCache?.stamp === stamp) { return this.configuredMcpServerNamesCache.value; } const value = getConfiguredMcpServerNamesFromPaths(paths); this.configuredMcpServerNamesCache = { stamp, value }; return value; } // ── Cache stamp ─────────────────────────────────────────────────────── getCacheStamp(agentName?: string): string { const agentStamp = agentName ? getFileStamp(join(this.agentsDir, `${agentName}.md`)) : "missing"; const projectStamp = this.projectGlobalConfigPath ? getFileStamp(this.projectGlobalConfigPath) : "none"; const projectAgentStamp = this.projectAgentsDir && agentName ? getFileStamp(join(this.projectAgentsDir, `${agentName}.md`)) : "none"; return `${getFileStamp(this.globalConfigPath)}|${projectStamp}|${agentStamp}|${projectAgentStamp}`; } // ── Resolved paths ──────────────────────────────────────────────────── getResolvedPolicyPaths(): ResolvedPolicyPaths { return { globalConfigPath: this.globalConfigPath, globalConfigExists: existsSync(this.globalConfigPath), projectConfigPath: this.projectGlobalConfigPath, projectConfigExists: this.projectGlobalConfigPath ? existsSync(this.projectGlobalConfigPath) : false, agentsDir: this.agentsDir, agentsDirExists: existsSync(this.agentsDir), projectAgentsDir: this.projectAgentsDir, projectAgentsDirExists: this.projectAgentsDir ? existsSync(this.projectAgentsDir) : false, }; } }