import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { FilePolicyLoader } from "../src/policy-loader"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function makeTempDir(): string { return mkdtempSync(join(tmpdir(), "policy-loader-test-")); } function makeLoader( baseDir: string, options: { globalConfig?: Record; mcpServerNames?: readonly string[]; } = {}, ) { const agentsDir = join(baseDir, "agents"); mkdirSync(agentsDir, { recursive: true }); const globalConfigPath = join(baseDir, "config.json"); writeFileSync( globalConfigPath, JSON.stringify(options.globalConfig ?? {}, null, 2), ); return new FilePolicyLoader({ globalConfigPath, agentsDir, mcpServerNames: options.mcpServerNames ? [...options.mcpServerNames] : undefined, }); } // --------------------------------------------------------------------------- // loadGlobalConfig // --------------------------------------------------------------------------- describe("FilePolicyLoader.loadGlobalConfig", () => { it("returns ScopeConfig with permission from a valid config file", () => { const baseDir = makeTempDir(); try { const loader = makeLoader(baseDir, { globalConfig: { permission: { "*": "allow", read: "ask" } }, }); const config = loader.loadGlobalConfig(); expect(config.permission).toEqual({ "*": "allow", read: "ask" }); } finally { rmSync(baseDir, { recursive: true, force: true }); } }); it("returns empty ScopeConfig when config file is missing", () => { const loader = new FilePolicyLoader({ globalConfigPath: "/nonexistent/config.json", agentsDir: "/nonexistent/agents", }); const config = loader.loadGlobalConfig(); expect(config.permission).toBeUndefined(); }); it("returns empty ScopeConfig when config file has no permission key", () => { const baseDir = makeTempDir(); try { const loader = makeLoader(baseDir, { globalConfig: { debugLog: true }, }); const config = loader.loadGlobalConfig(); expect(config.permission).toBeUndefined(); } finally { rmSync(baseDir, { recursive: true, force: true }); } }); }); // --------------------------------------------------------------------------- // loadProjectConfig // --------------------------------------------------------------------------- describe("FilePolicyLoader.loadProjectConfig", () => { it("returns empty ScopeConfig when no project path is configured", () => { const loader = new FilePolicyLoader({ globalConfigPath: "/nonexistent/config.json", agentsDir: "/nonexistent/agents", }); const config = loader.loadProjectConfig(); expect(config).toEqual({}); }); it("returns ScopeConfig from a project config file", () => { const baseDir = makeTempDir(); try { const projectConfigPath = join(baseDir, "project-config.json"); writeFileSync( projectConfigPath, JSON.stringify({ permission: { bash: "allow" } }), ); const loader = new FilePolicyLoader({ globalConfigPath: "/nonexistent/config.json", agentsDir: "/nonexistent/agents", projectGlobalConfigPath: projectConfigPath, }); const config = loader.loadProjectConfig(); expect(config.permission).toEqual({ bash: "allow" }); } finally { rmSync(baseDir, { recursive: true, force: true }); } }); }); // --------------------------------------------------------------------------- // loadAgentConfig / loadProjectAgentConfig // --------------------------------------------------------------------------- describe("FilePolicyLoader.loadAgentConfig", () => { it("returns empty ScopeConfig when agentName is undefined", () => { const baseDir = makeTempDir(); try { const loader = makeLoader(baseDir); expect(loader.loadAgentConfig()).toEqual({}); } finally { rmSync(baseDir, { recursive: true, force: true }); } }); it("returns ScopeConfig from agent frontmatter", () => { const baseDir = makeTempDir(); try { const agentsDir = join(baseDir, "agents"); mkdirSync(agentsDir, { recursive: true }); writeFileSync( join(agentsDir, "coder.md"), `---\npermission:\n bash: allow\n---\n# Coder agent\n`, ); const loader = new FilePolicyLoader({ globalConfigPath: join(baseDir, "config.json"), agentsDir, }); writeFileSync(join(baseDir, "config.json"), "{}"); const config = loader.loadAgentConfig("coder"); expect(config.permission).toEqual({ bash: "allow" }); } finally { rmSync(baseDir, { recursive: true, force: true }); } }); it("returns empty ScopeConfig when agent file does not exist", () => { const baseDir = makeTempDir(); try { const loader = makeLoader(baseDir); expect(loader.loadAgentConfig("nonexistent")).toEqual({}); } finally { rmSync(baseDir, { recursive: true, force: true }); } }); }); describe("FilePolicyLoader.loadProjectAgentConfig", () => { it("returns empty ScopeConfig when no projectAgentsDir is configured", () => { const baseDir = makeTempDir(); try { const loader = makeLoader(baseDir); expect(loader.loadProjectAgentConfig("coder")).toEqual({}); } finally { rmSync(baseDir, { recursive: true, force: true }); } }); }); // --------------------------------------------------------------------------- // getConfigIssues // --------------------------------------------------------------------------- describe("FilePolicyLoader.getConfigIssues", () => { it("returns empty array before any loads", () => { const loader = new FilePolicyLoader({ globalConfigPath: "/nonexistent/config.json", agentsDir: "/nonexistent/agents", }); expect(loader.getConfigIssues()).toEqual([]); }); it("returns empty array for valid config", () => { const baseDir = makeTempDir(); try { const loader = makeLoader(baseDir, { globalConfig: { permission: { "*": "ask" } }, }); loader.loadGlobalConfig(); expect(loader.getConfigIssues()).toEqual([]); } finally { rmSync(baseDir, { recursive: true, force: true }); } }); }); // --------------------------------------------------------------------------- // getResolvedPolicyPaths // --------------------------------------------------------------------------- describe("FilePolicyLoader.getResolvedPolicyPaths", () => { it("returns correct paths and existence when files exist", () => { const baseDir = makeTempDir(); try { const globalConfigPath = join(baseDir, "config.json"); const agentsDir = join(baseDir, "agents"); writeFileSync(globalConfigPath, "{}"); mkdirSync(agentsDir, { recursive: true }); const loader = new FilePolicyLoader({ globalConfigPath, agentsDir }); const paths = loader.getResolvedPolicyPaths(); expect(paths.globalConfigPath).toBe(globalConfigPath); expect(paths.globalConfigExists).toBe(true); expect(paths.agentsDir).toBe(agentsDir); expect(paths.agentsDirExists).toBe(true); expect(paths.projectConfigPath).toBeNull(); expect(paths.projectConfigExists).toBe(false); expect(paths.projectAgentsDir).toBeNull(); expect(paths.projectAgentsDirExists).toBe(false); } finally { rmSync(baseDir, { recursive: true, force: true }); } }); }); // --------------------------------------------------------------------------- // getCacheStamp // --------------------------------------------------------------------------- describe("FilePolicyLoader.getCacheStamp", () => { it("returns a string stamp", () => { const loader = new FilePolicyLoader({ globalConfigPath: "/nonexistent/config.json", agentsDir: "/nonexistent/agents", }); const stamp = loader.getCacheStamp(); expect(typeof stamp).toBe("string"); expect(stamp.length).toBeGreaterThan(0); }); it("changes when the global config file changes", () => { const baseDir = makeTempDir(); try { const globalConfigPath = join(baseDir, "config.json"); writeFileSync(globalConfigPath, "{}"); const agentsDir = join(baseDir, "agents"); mkdirSync(agentsDir, { recursive: true }); const loader = new FilePolicyLoader({ globalConfigPath, agentsDir }); const stamp1 = loader.getCacheStamp(); // Wait a tick so mtime changes const now = Date.now(); while (Date.now() - now < 50) { // busy-wait for mtime resolution } writeFileSync(globalConfigPath, '{"permission": {}}'); const stamp2 = loader.getCacheStamp(); expect(stamp1).not.toBe(stamp2); } finally { rmSync(baseDir, { recursive: true, force: true }); } }); it("includes agent stamp when agentName is provided and agent file exists", () => { const baseDir = makeTempDir(); try { const agentsDir = join(baseDir, "agents"); mkdirSync(agentsDir, { recursive: true }); writeFileSync(join(baseDir, "config.json"), "{}"); writeFileSync( join(agentsDir, "coder.md"), "---\npermission:\n read: allow\n---\n", ); const loader = new FilePolicyLoader({ globalConfigPath: join(baseDir, "config.json"), agentsDir, }); const stampWithout = loader.getCacheStamp(); const stampWith = loader.getCacheStamp("coder"); // Agent file exists, so the stamp differs from the no-agent case. expect(stampWithout).not.toBe(stampWith); } finally { rmSync(baseDir, { recursive: true, force: true }); } }); }); // --------------------------------------------------------------------------- // Mtime cache invalidation // --------------------------------------------------------------------------- describe("FilePolicyLoader mtime caching", () => { it("returns cached value on second call with unchanged file", () => { const baseDir = makeTempDir(); try { const loader = makeLoader(baseDir, { globalConfig: { permission: { "*": "allow" } }, }); const first = loader.loadGlobalConfig(); const second = loader.loadGlobalConfig(); // Same reference — cache hit expect(second).toBe(first); } finally { rmSync(baseDir, { recursive: true, force: true }); } }); it("reloads when file mtime changes", () => { const baseDir = makeTempDir(); try { const globalConfigPath = join(baseDir, "config.json"); const agentsDir = join(baseDir, "agents"); mkdirSync(agentsDir, { recursive: true }); writeFileSync( globalConfigPath, JSON.stringify({ permission: { "*": "allow" } }), ); const loader = new FilePolicyLoader({ globalConfigPath, agentsDir }); const first = loader.loadGlobalConfig(); expect(first.permission?.["*"]).toBe("allow"); // busy-wait for mtime resolution const now = Date.now(); while (Date.now() - now < 50) { /* spin */ } writeFileSync( globalConfigPath, JSON.stringify({ permission: { "*": "deny" } }), ); const second = loader.loadGlobalConfig(); expect(second.permission?.["*"]).toBe("deny"); expect(second).not.toBe(first); } finally { rmSync(baseDir, { recursive: true, force: true }); } }); }); // --------------------------------------------------------------------------- // Agent frontmatter loading // --------------------------------------------------------------------------- describe("FilePolicyLoader agent frontmatter", () => { it("loads permission from agent frontmatter with pattern map", () => { const baseDir = makeTempDir(); try { const agentsDir = join(baseDir, "agents"); mkdirSync(agentsDir, { recursive: true }); writeFileSync(join(baseDir, "config.json"), "{}"); writeFileSync( join(agentsDir, "coder.md"), [ "---", "permission:", " bash:", ' "git *": allow', ' "rm *": deny', "---", "# Coder", ].join("\n"), ); const loader = new FilePolicyLoader({ globalConfigPath: join(baseDir, "config.json"), agentsDir, }); const config = loader.loadAgentConfig("coder"); expect(config.permission).toBeDefined(); expect(config.permission?.bash).toEqual({ "git *": "allow", "rm *": "deny", }); } finally { rmSync(baseDir, { recursive: true, force: true }); } }); it("returns empty config for agent file without frontmatter", () => { const baseDir = makeTempDir(); try { const agentsDir = join(baseDir, "agents"); mkdirSync(agentsDir, { recursive: true }); writeFileSync(join(baseDir, "config.json"), "{}"); writeFileSync(join(agentsDir, "plain.md"), "# No frontmatter\n"); const loader = new FilePolicyLoader({ globalConfigPath: join(baseDir, "config.json"), agentsDir, }); expect(loader.loadAgentConfig("plain")).toEqual({}); } finally { rmSync(baseDir, { recursive: true, force: true }); } }); it("project agent config loads from projectAgentsDir", () => { const baseDir = makeTempDir(); try { const agentsDir = join(baseDir, "agents"); const projectAgentsDir = join(baseDir, "project-agents"); mkdirSync(agentsDir, { recursive: true }); mkdirSync(projectAgentsDir, { recursive: true }); writeFileSync(join(baseDir, "config.json"), "{}"); writeFileSync( join(projectAgentsDir, "coder.md"), "---\npermission:\n write: allow\n---\n# Coder\n", ); const loader = new FilePolicyLoader({ globalConfigPath: join(baseDir, "config.json"), agentsDir, projectAgentsDir, }); const config = loader.loadProjectAgentConfig("coder"); expect(config.permission).toEqual({ write: "allow" }); } finally { rmSync(baseDir, { recursive: true, force: true }); } }); }); // --------------------------------------------------------------------------- // MCP server name reading // --------------------------------------------------------------------------- describe("FilePolicyLoader.getConfiguredMcpServerNames", () => { it("returns override names when provided", () => { const loader = new FilePolicyLoader({ globalConfigPath: "/nonexistent/config.json", agentsDir: "/nonexistent/agents", mcpServerNames: ["exa", "research"], }); expect(loader.getConfiguredMcpServerNames()).toEqual( expect.arrayContaining(["exa", "research"]), ); }); it("deduplicates and trims override names", () => { const loader = new FilePolicyLoader({ globalConfigPath: "/nonexistent/config.json", agentsDir: "/nonexistent/agents", mcpServerNames: [" exa ", "exa", "research"], }); const names = loader.getConfiguredMcpServerNames(); expect(names.filter((n) => n === "exa")).toHaveLength(1); }); it("reads server names from mcp.json on disk", () => { const baseDir = makeTempDir(); try { const mcpConfigPath = join(baseDir, "mcp.json"); writeFileSync( mcpConfigPath, JSON.stringify({ mcpServers: { exa: {}, research: {} } }), ); const loader = new FilePolicyLoader({ globalConfigPath: "/nonexistent/config.json", agentsDir: "/nonexistent/agents", globalMcpConfigPath: mcpConfigPath, }); const names = loader.getConfiguredMcpServerNames(); expect(names).toEqual(expect.arrayContaining(["exa", "research"])); } finally { rmSync(baseDir, { recursive: true, force: true }); } }); it("returns empty array when mcp.json is missing", () => { const loader = new FilePolicyLoader({ globalConfigPath: "/nonexistent/config.json", agentsDir: "/nonexistent/agents", globalMcpConfigPath: "/nonexistent/mcp.json", }); expect(loader.getConfiguredMcpServerNames()).toEqual([]); }); it("caches MCP server names across calls", () => { const baseDir = makeTempDir(); try { const mcpConfigPath = join(baseDir, "mcp.json"); writeFileSync(mcpConfigPath, JSON.stringify({ mcpServers: { exa: {} } })); const loader = new FilePolicyLoader({ globalConfigPath: "/nonexistent/config.json", agentsDir: "/nonexistent/agents", globalMcpConfigPath: mcpConfigPath, }); const first = loader.getConfiguredMcpServerNames(); const second = loader.getConfiguredMcpServerNames(); // Same reference — cache hit expect(second).toBe(first); } finally { rmSync(baseDir, { recursive: true, force: true }); } }); }); // --------------------------------------------------------------------------- // Config issue accumulation // --------------------------------------------------------------------------- describe("FilePolicyLoader config issue accumulation", () => { it("accumulates issues from malformed config files", () => { const baseDir = makeTempDir(); try { // Write invalid JSON to trigger a parse error issue const globalConfigPath = join(baseDir, "config.json"); const agentsDir = join(baseDir, "agents"); mkdirSync(agentsDir, { recursive: true }); writeFileSync(globalConfigPath, "{ INVALID JSON"); const loader = new FilePolicyLoader({ globalConfigPath, agentsDir }); loader.loadGlobalConfig(); const issues = loader.getConfigIssues(); expect(issues.length).toBeGreaterThanOrEqual(1); expect(issues[0]).toContain("Failed to read config"); } finally { rmSync(baseDir, { recursive: true, force: true }); } }); it("does not duplicate issues on repeated loads", () => { const baseDir = makeTempDir(); try { const globalConfigPath = join(baseDir, "config.json"); const agentsDir = join(baseDir, "agents"); mkdirSync(agentsDir, { recursive: true }); writeFileSync(globalConfigPath, "{ INVALID JSON"); const loader = new FilePolicyLoader({ globalConfigPath, agentsDir }); loader.loadGlobalConfig(); const issuesBefore = loader.getConfigIssues(); // Bust cache by waiting for mtime change const now = Date.now(); while (Date.now() - now < 50) { /* spin */ } writeFileSync(globalConfigPath, "{ INVALID JSON"); loader.loadGlobalConfig(); const issuesAfter = loader.getConfigIssues(); expect(issuesAfter.length).toBe(issuesBefore.length); } finally { rmSync(baseDir, { recursive: true, force: true }); } }); });