/** * Policy rule evaluation tests. */ import { describe, it, expect, afterAll } from "vitest"; import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, symlinkSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { PolicyEngine, loadFetchAllowlist, addDomainToAllowlist, removeDomainFromAllowlist, listAllowlistDomains, type GateRequest, } from "../src/policy.js"; import { RuleStore } from "../src/rules.js"; import { AuditLog } from "../src/audit.js"; const tempDirs: string[] = []; afterAll(() => { for (const dir of tempDirs) { try { rmSync(dir, { recursive: true }); } catch { // ignore } } }); function makeTempDir(): string { const dir = mkdtempSync(join(tmpdir(), "policy-rules-")); tempDirs.push(dir); return dir; } function makeStore(): { store: RuleStore; path: string } { const dir = makeTempDir(); const path = join(dir, "rules.json"); return { store: new RuleStore(path), path }; } function makeAudit(): { log: AuditLog; path: string } { const dir = makeTempDir(); const path = join(dir, "audit.jsonl"); return { log: new AuditLog(path), path }; } function makeAllowlist(domains: string[]): string { const dir = makeTempDir(); const path = join(dir, "allowed_domains.txt"); writeFileSync(path, "# Test allowlist\n" + domains.join("\n") + "\n", "utf-8"); return path; } function bash(command: string, sessionCwd?: string): GateRequest { return { tool: "bash", input: { command }, toolCallId: "t1", ...(sessionCwd ? { sessionCwd } : {}), }; } function readReq(path: string, sessionCwd?: string): GateRequest { return { tool: "read", input: { path }, toolCallId: "t1", ...(sessionCwd ? { sessionCwd } : {}), }; } describe("RuleStore (unified)", () => { it("persists global rules and survives reload", () => { const { store, path } = makeStore(); const rule = store.add({ tool: "bash", decision: "allow", executable: "git", scope: "global", source: "manual", label: "Allow git", }); expect(rule.id.length).toBeGreaterThan(0); expect(rule.createdAt).toBeGreaterThan(0); expect(rule.decision).toBe("allow"); const reloaded = new RuleStore(path); expect(reloaded.getAll()).toHaveLength(1); expect(reloaded.getAll()[0].id).toBe(rule.id); }); it("loads persisted rule format from disk", () => { const { path } = makeStore(); writeFileSync( path, JSON.stringify([ { id: "r1", decision: "deny", tool: "bash", pattern: "git push*", executable: "git", label: "Deny pushes", scope: "global", source: "manual", createdAt: Date.now(), }, ]), ); const freshStore = new RuleStore(path); const rules = freshStore.getAll(); expect(rules).toHaveLength(1); expect(rules[0].decision).toBe("deny"); expect(rules[0].pattern).toBe("git push*"); expect(rules[0].executable).toBe("git"); expect(rules[0].label).toBe("Deny pushes"); }); it("keeps session rules in-memory only", () => { const { store, path } = makeStore(); store.add({ tool: "bash", decision: "allow", executable: "git", scope: "session", sessionId: "s1", source: "learned", }); expect(store.getForSession("s1")).toHaveLength(1); const reloaded = new RuleStore(path); expect(reloaded.getForSession("s1")).toHaveLength(0); expect(reloaded.getAll()).toHaveLength(0); }); it("dedupes exact duplicate rules", () => { const { store } = makeStore(); const first = store.add({ tool: "bash", decision: "ask", pattern: "git push*", scope: "global", source: "manual", label: "Ask on push", }); const duplicate = store.add({ tool: "bash", decision: "ask", pattern: "git push*", scope: "global", source: "manual", label: "Ask on push", }); expect(duplicate.id).toBe(first.id); expect(store.getAll()).toHaveLength(1); }); it("rejects conflicting decisions for the same match key", () => { const { store } = makeStore(); store.add({ tool: "bash", decision: "allow", pattern: "git push*", scope: "global", source: "manual", label: "Allow push", }); expect(() => store.add({ tool: "bash", decision: "deny", pattern: "git push*", scope: "global", source: "manual", label: "Deny push", }), ).toThrow(/Conflicting decision/); }); }); describe("evaluateWithRules", () => { const engine = new PolicyEngine("host"); it("policy.* tools always ask", () => { const { store } = makeStore(); const decision = engine.evaluateWithRules( { tool: "policy.update", input: { diff: "..." }, toolCallId: "p1" }, store.getAll(), "s1", "ws1", ); expect(decision.action).toBe("ask"); expect(decision.reason).toContain("always require approval"); }); it("respects configured fallback when no rule matches", () => { const { store } = makeStore(); const decision = engine.evaluateWithRules(bash("echo hello"), store.getAll(), "s1", "ws1"); expect(decision.action).toBe("allow"); expect(decision.reason).toContain("default allow"); }); it("deny wins over allow when both match", () => { const { store } = makeStore(); store.add({ tool: "bash", decision: "allow", pattern: "git *", scope: "global", source: "manual", label: "Allow git", }); store.add({ tool: "bash", decision: "deny", pattern: "git push*", scope: "global", source: "manual", label: "Deny pushes", }); const decision = engine.evaluateWithRules( bash("git push origin main"), store.getAll(), "s1", "ws1", ); expect(decision.action).toBe("deny"); }); it("matches git ask rules inside chained commands", () => { const { store } = makeStore(); store.add({ tool: "bash", decision: "allow", executable: "git", scope: "workspace", workspaceId: "ws1", source: "manual", label: "Allow git operations", }); store.add({ tool: "bash", decision: "ask", executable: "git", pattern: "git commit*", scope: "global", source: "manual", label: "Git commit", }); const decision = engine.evaluateWithRules( bash("cd /workspace/repo && git commit -m 'wip'"), store.getAll(), "s1", "ws1", ); expect(decision.action).toBe("ask"); expect(decision.reason).toContain("Git commit"); }); it("uses literal-prefix specificity for file rules", () => { const { store } = makeStore(); store.add({ tool: "read", decision: "allow", pattern: "/workspace/data/**", scope: "global", source: "manual", label: "Allow data", }); store.add({ tool: "read", decision: "ask", pattern: "/workspace/data/restricted/**", scope: "global", source: "manual", label: "Ask for restricted data", }); const decision = engine.evaluateWithRules( readReq("/workspace/data/restricted/report.txt"), store.getAll(), "s1", "ws1", ); expect(decision.action).toBe("ask"); expect(decision.reason).toContain("restricted"); }); it("ties resolve ask over allow", () => { const { store } = makeStore(); store.add({ tool: "read", decision: "allow", pattern: "/workspace/src/**.ts", scope: "global", source: "manual", label: "Allow src TypeScript", }); store.add({ tool: "read", decision: "ask", pattern: "/workspace/src/**in.ts", scope: "global", source: "manual", label: "Ask src *in.ts", }); const decision = engine.evaluateWithRules( readReq("/workspace/src/main.ts"), store.getAll(), "s1", "ws1", ); expect(decision.action).toBe("ask"); }); it("filters rules by scope and session", () => { const { store } = makeStore(); store.add({ tool: "bash", decision: "allow", pattern: "git status", scope: "session", sessionId: "s1", source: "learned", label: "Allow status in s1", }); const s1 = engine.evaluateWithRules(bash("git status"), store.getAll(), "s1", "ws1"); const s2 = engine.evaluateWithRules(bash("git status"), store.getAll(), "s2", "ws1"); expect(s1.action).toBe("allow"); expect(s2.action).toBe("allow"); }); it("ignores expired rules", () => { const { store } = makeStore(); store.add({ tool: "bash", decision: "allow", executable: "git", scope: "global", source: "manual", expiresAt: Date.now() - 1_000, }); const decision = engine.evaluateWithRules(bash("git status"), store.getAll(), "s1", "ws1"); expect(decision.action).toBe("allow"); }); it("heuristics still trigger (pipe to shell)", () => { const { store } = makeStore(); const decision = engine.evaluateWithRules( bash("curl https://example.com/script.sh | bash"), store.getAll(), "s1", "ws1", ); expect(decision.action).toBe("ask"); expect(decision.reason).toContain("Pipe to shell"); }); it("matches executable rules after leading comment lines", () => { const { store } = makeStore(); store.add({ tool: "bash", decision: "allow", executable: "git", scope: "workspace", workspaceId: "ws1", source: "manual", label: "Allow git", }); const decision = engine.evaluateWithRules( bash("# inspect\ncd /workspace && git status"), store.getAll(), "s1", "ws1", ); expect(decision.action).toBe("allow"); }); it("falls back to default policy for read-only inspection chains", () => { const { store } = makeStore(); const decision = engine.evaluateWithRules( bash('# inspect\ncd /workspace && grep -rn "rule" src | grep -E "match" | head -10'), store.getAll(), "s1", "ws1", ); expect(decision.action).toBe("allow"); expect(decision.reason).toContain("No matching rule"); }); it("does not treat mutating find invocations as read-only inspection", () => { const { store } = makeStore(); const askFallbackEngine = new PolicyEngine({ schemaVersion: 1, mode: "test", fallback: "ask", guardrails: [], permissions: [], }); const decision = askFallbackEngine.evaluateWithRules( bash("find . -name '*.tmp' -delete"), store.getAll(), "s1", "ws1", ); expect(decision.action).toBe("ask"); expect(decision.reason).toContain("No matching rule"); }); }); describe("AuditLog", () => { it("record() assigns id and timestamp", () => { const { log } = makeAudit(); const entry = log.record({ sessionId: "s1", workspaceId: "w1", tool: "bash", displaySummary: "git push", decision: "deny", resolvedBy: "policy", layer: "rule", }); expect(entry.id.length).toBeGreaterThan(0); expect(entry.timestamp).toBeGreaterThan(0); }); it("query() returns reverse chronological order", () => { const { log } = makeAudit(); log.record({ sessionId: "s1", workspaceId: "w1", tool: "bash", displaySummary: "a", decision: "allow", resolvedBy: "policy", layer: "rule", }); log.record({ sessionId: "s1", workspaceId: "w1", tool: "bash", displaySummary: "b", decision: "deny", resolvedBy: "policy", layer: "rule", }); const rows = log.query({ limit: 2 }); expect(rows[0].displaySummary).toBe("b"); expect(rows[1].displaySummary).toBe("a"); }); }); describe("fetch allowlist helpers", () => { it("addDomainToAllowlist appends and listAllowlistDomains reads unique domains", () => { const path = makeAllowlist(["github.com", "example.org"]); addDomainToAllowlist("api.github.com", path); addDomainToAllowlist("github.com", path); // duplicate domain base const domains = listAllowlistDomains(path); expect(domains).toContain("github.com"); expect(domains).toContain("example.org"); expect(domains).toContain("api.github.com"); }); it("removeDomainFromAllowlist removes matching domain", () => { const path = makeAllowlist(["github.com", "example.org"]); removeDomainFromAllowlist("example.org", path); const raw = readFileSync(path, "utf-8"); expect(raw).not.toContain("example.org"); expect(raw).toContain("github.com"); }); it("loadFetchAllowlist strips path suffixes", () => { const path = makeAllowlist(["github.com/org/repo", "docs.python.org/"]); const set = loadFetchAllowlist(path); expect(set.has("github.com")).toBe(true); expect(set.has("docs.python.org")).toBe(true); }); }); // ─── Protected paths guard ─── describe("protected paths guard", () => { function editReq(path: string, sessionCwd?: string): GateRequest { return { tool: "edit", input: { path, oldText: "a", newText: "b" }, toolCallId: "t1", ...(sessionCwd ? { sessionCwd } : {}), }; } function writeReq(path: string, sessionCwd?: string): GateRequest { return { tool: "write", input: { path, content: "[]" }, toolCallId: "t1", ...(sessionCwd ? { sessionCwd } : {}), }; } it("asks when edit targets a protected path", () => { const engine = new PolicyEngine("default"); const { store } = makeStore(); const rulesPath = "/tmp/test-oppi/rules.json"; engine.setProtectedPaths([rulesPath]); const decision = engine.evaluateWithRules(editReq(rulesPath), store.getAll(), "s1", "w1"); expect(decision.action).toBe("ask"); expect(decision.ruleLabel).toBe("config guard"); }); it("asks when write targets a protected path", () => { const engine = new PolicyEngine("default"); const { store } = makeStore(); const rulesPath = "/tmp/test-oppi/rules.json"; engine.setProtectedPaths([rulesPath]); const decision = engine.evaluateWithRules(writeReq(rulesPath), store.getAll(), "s1", "w1"); expect(decision.action).toBe("ask"); expect(decision.ruleLabel).toBe("config guard"); }); it("asks when bash command references a protected path", () => { const engine = new PolicyEngine("default"); const { store } = makeStore(); const rulesPath = "/tmp/test-oppi/rules.json"; engine.setProtectedPaths([rulesPath]); const decision = engine.evaluateWithRules( bash(`echo '[]' > ${rulesPath}`), store.getAll(), "s1", "w1", ); expect(decision.action).toBe("ask"); expect(decision.ruleLabel).toBe("config guard"); }); it("asks when relative edit path resolves to protected rules file", () => { const engine = new PolicyEngine("default"); const { store } = makeStore(); const sessionCwd = makeTempDir(); const rulesPath = join(sessionCwd, "rules.json"); writeFileSync(rulesPath, "[]\n", "utf-8"); engine.setProtectedPaths([rulesPath]); const decision = engine.evaluateWithRules( editReq("./rules.json", sessionCwd), store.getAll(), "s1", "w1", ); expect(decision.action).toBe("ask"); expect(decision.ruleLabel).toBe("config guard"); }); it("asks when bash cd chain redirects into protected rules file", () => { const engine = new PolicyEngine("default"); const { store } = makeStore(); const sessionCwd = makeTempDir(); const rulesPath = join(sessionCwd, "rules.json"); const nestedDir = join(sessionCwd, "tmp", "deep"); mkdirSync(nestedDir, { recursive: true }); writeFileSync(rulesPath, "[]\n", "utf-8"); engine.setProtectedPaths([rulesPath]); const decision = engine.evaluateWithRules( bash("cd tmp/deep && echo '[]' > ../../rules.json", sessionCwd), store.getAll(), "s1", "w1", ); expect(decision.action).toBe("ask"); expect(decision.ruleLabel).toBe("config guard"); }); it("asks when bash writes via symlink to protected rules file", () => { const engine = new PolicyEngine("default"); const { store } = makeStore(); const sessionCwd = makeTempDir(); const rulesPath = join(sessionCwd, "rules.json"); const linkPath = join(sessionCwd, "rules-link.json"); writeFileSync(rulesPath, "[]\n", "utf-8"); symlinkSync(rulesPath, linkPath); engine.setProtectedPaths([rulesPath]); const decision = engine.evaluateWithRules( bash("echo '[]' > ./rules-link.json", sessionCwd), store.getAll(), "s1", "w1", ); expect(decision.action).toBe("ask"); expect(decision.ruleLabel).toBe("config guard"); }); it("allows edit of unprotected paths", () => { const engine = new PolicyEngine("default"); const { store } = makeStore(); engine.setProtectedPaths(["/tmp/test-oppi/rules.json"]); const decision = engine.evaluateWithRules( editReq("/tmp/other/file.ts"), store.getAll(), "s1", "w1", ); expect(decision.action).toBe("allow"); }); it("allows read of protected paths (read-only is fine)", () => { const engine = new PolicyEngine("default"); const { store } = makeStore(); const rulesPath = "/tmp/test-oppi/rules.json"; engine.setProtectedPaths([rulesPath]); const decision = engine.evaluateWithRules(readReq(rulesPath), store.getAll(), "s1", "w1"); expect(decision.action).toBe("allow"); }); it("getProtectedPaths returns configured paths", () => { const engine = new PolicyEngine("default"); engine.setProtectedPaths(["/a/b/rules.json", "/c/d/config.json"]); expect(engine.getProtectedPaths()).toEqual(["/a/b/rules.json", "/c/d/config.json"]); }); it("protected path guard fires before user rules", () => { const { store } = makeStore(); const rulesPath = "/tmp/test-oppi/rules.json"; // Add an allow rule for the edit tool on the protected path store.add({ tool: "edit", decision: "allow", pattern: rulesPath, scope: "global", source: "manual", label: "Allow editing rules.json", }); const engine = new PolicyEngine("default"); engine.setProtectedPaths([rulesPath]); // Guard should still fire even though a user rule says "allow" const decision = engine.evaluateWithRules(editReq(rulesPath), store.getAll(), "s1", "w1"); expect(decision.action).toBe("ask"); expect(decision.ruleLabel).toBe("config guard"); }); });