/** * KHAELOR * File: tests/config/persist.test.ts * Description: persistPermissionGrant tests — creation, merging, idempotency, atomicity, round-trip into the evaluator, and fail-safe on corrupted config. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { loadConfig, persistPermissionGrant } from "../../src/config/index.js"; import { evaluate, normalizePermissionsSection } from "../../src/permissions/index.js"; import type { CapabilityRequest } from "../../src/permissions/index.js"; import { unwrap } from "../../src/shared/index.js"; const GRANT = { capability: "process.execute", pattern: "npm install *", action: "allow" as const, source: "project", }; describe("persistPermissionGrant", () => { let projectDir: string; let configPath: string; beforeEach(async () => { projectDir = await mkdtemp(join(tmpdir(), "khaelor-persist-")); configPath = join(projectDir, ".khaelor", "config.json"); }); afterEach(async () => { await rm(projectDir, { recursive: true, force: true }); }); const readJson = async (): Promise> => JSON.parse(await readFile(configPath, "utf8")) as Record; it("creates .khaelor/config.json when missing and writes the rule", async () => { const result = await persistPermissionGrant(GRANT, { projectDir }); expect(result.ok).toBe(true); expect(await readJson()).toEqual({ permissions: { rules: [{ capability: "process.execute", pattern: "npm install *", action: "allow" }], }, }); }); it("preserves existing config content and appends to existing rules", async () => { await mkdir(join(projectDir, ".khaelor"), { recursive: true }); await writeFile( configPath, JSON.stringify({ model: "project-model", futureFeature: { keep: true }, permissions: { "file.read": "allow", "process.execute": { "git push *": "ask" }, rules: [{ capability: "network.access", pattern: "curl *", action: "allow" }], }, }), ); unwrap(await persistPermissionGrant(GRANT, { projectDir })); expect(await readJson()).toEqual({ model: "project-model", futureFeature: { keep: true }, permissions: { "file.read": "allow", "process.execute": { "git push *": "ask" }, rules: [ { capability: "network.access", pattern: "curl *", action: "allow" }, { capability: "process.execute", pattern: "npm install *", action: "allow" }, ], }, }); }); it("is idempotent — an identical grant is never appended twice", async () => { unwrap(await persistPermissionGrant(GRANT, { projectDir })); unwrap(await persistPermissionGrant(GRANT, { projectDir })); unwrap(await persistPermissionGrant({ ...GRANT, pattern: undefined }, { projectDir })); unwrap(await persistPermissionGrant({ ...GRANT, pattern: undefined }, { projectDir })); const config = await readJson(); const permissions = config["permissions"] as { rules: unknown[] }; expect(permissions.rules).toEqual([ { capability: "process.execute", pattern: "npm install *", action: "allow" }, { capability: "process.execute", action: "allow" }, ]); }); it("never writes the provenance source field to disk", async () => { unwrap(await persistPermissionGrant(GRANT, { projectDir })); expect(await readFile(configPath, "utf8")).not.toContain("source"); }); it("leaves no temp files behind after a successful write", async () => { unwrap(await persistPermissionGrant(GRANT, { projectDir })); expect(await readdir(join(projectDir, ".khaelor"))).toEqual(["config.json"]); }); it("round-trips: persist → reload → the evaluator now allows the subject", async () => { unwrap(await persistPermissionGrant(GRANT, { projectDir })); const config = await loadConfig({ cwd: projectDir, env: {}, userConfigDir: join(projectDir, "no-user") }); const rules = unwrap(normalizePermissionsSection(config.permissions, "project")); const request: CapabilityRequest = { capability: "process.execute", subject: "npm install left-pad", display: "Run npm install left-pad", alwaysPatterns: ["npm install *"], riskNotes: [], }; const decision = evaluate(rules, request); expect(decision.action).toBe("allow"); expect(decision.rule?.source).toBe("project"); expect(decision.rule?.pattern).toBe("npm install *"); }); it("fails safe on invalid JSON — clear error, file left untouched", async () => { await mkdir(join(projectDir, ".khaelor"), { recursive: true }); await writeFile(configPath, "{ not json"); const result = await persistPermissionGrant(GRANT, { projectDir }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error.code).toBe("config-invalid"); expect(result.error.message).toContain(configPath); } expect(await readFile(configPath, "utf8")).toBe("{ not json"); }); it("fails safe on a config the loader would reject — no rewrite", async () => { await mkdir(join(projectDir, ".khaelor"), { recursive: true }); const original = JSON.stringify({ permissions: { "process.execute": "maybe" } }); await writeFile(configPath, original); const result = await persistPermissionGrant(GRANT, { projectDir }); expect(result.ok).toBe(false); if (!result.ok) expect(result.error.code).toBe("config-invalid"); expect(await readFile(configPath, "utf8")).toBe(original); }); it("refuses to rewrite a config carrying a secret field", async () => { await mkdir(join(projectDir, ".khaelor"), { recursive: true }); const original = JSON.stringify({ apiKey: "sk-ant-oops" }); await writeFile(configPath, original); const result = await persistPermissionGrant(GRANT, { projectDir }); expect(result.ok).toBe(false); expect(await readFile(configPath, "utf8")).toBe(original); }); });