Files
my-pi/pi-permission-system/test/policy-loader.test.ts
T

613 lines
21 KiB
TypeScript

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<string, unknown>;
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" });
expect(config.invalid).toBeUndefined();
} finally {
rmSync(baseDir, { recursive: true, force: true });
}
});
it("marks the scope invalid when the project config file is rejected", () => {
const baseDir = makeTempDir();
try {
const projectConfigPath = join(baseDir, "project-config.json");
// Unrecognized key — schema rejection, the issue's "typo" scenario.
writeFileSync(projectConfigPath, JSON.stringify({ debugLo: true }));
const loader = new FilePolicyLoader({
globalConfigPath: "/nonexistent/config.json",
agentsDir: "/nonexistent/agents",
projectGlobalConfigPath: projectConfigPath,
});
const config = loader.loadProjectConfig();
expect(config.invalid).toBe(true);
} finally {
rmSync(baseDir, { recursive: true, force: true });
}
});
it("leaves invalid unset when no project file is present", () => {
const loader = new FilePolicyLoader({
globalConfigPath: "/nonexistent/config.json",
agentsDir: "/nonexistent/agents",
projectGlobalConfigPath: "/nonexistent/project-config.json",
});
const config = loader.loadProjectConfig();
expect(config.invalid).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// 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);
const config = loader.loadAgentConfig("nonexistent");
expect(config).toEqual({});
expect(config.invalid).toBeUndefined();
} finally {
rmSync(baseDir, { recursive: true, force: true });
}
});
it("marks the scope invalid when an existing agent file cannot be read", () => {
const baseDir = makeTempDir();
try {
const agentsDir = join(baseDir, "agents");
mkdirSync(agentsDir, { recursive: true });
// A directory at the agent's file path exists (stat succeeds) but
// readFileSync throws EISDIR — a present-but-unreadable file.
mkdirSync(join(agentsDir, "coder.md"));
const loader = new FilePolicyLoader({
globalConfigPath: join(baseDir, "config.json"),
agentsDir,
});
writeFileSync(join(baseDir, "config.json"), "{}");
const config = loader.loadAgentConfig("coder");
expect(config.invalid).toBe(true);
} 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 });
}
});
});