/** * KHAELOR * File: src/config/persist.ts * Description: persistPermissionGrant — atomically appends an "always allow" rule to the project's .khaelor/config.json (PERMISSION_MODEL.md §6.2, ADR-9). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { randomBytes } from "node:crypto"; import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { KhaelorError, err, ok } from "../shared/index.js"; import type { Result } from "../shared/index.js"; import { validatePartialConfig } from "./schema.js"; import type { PermissionAction, PermissionRuleEntry } from "./schema.js"; /** * The grant to persist — structurally accepts a permissions-module * `PermissionRule`; the provenance `source` is never written to disk * (the loader re-tags rules by layer on the next read). */ export interface PermissionGrant { capability: string; pattern?: string; action: PermissionAction; source?: string; } export interface PersistPermissionGrantOptions { /** Project root — the grant lands in `/.khaelor/config.json`. */ projectDir: string; } function sameRule(a: PermissionRuleEntry, grant: PermissionGrant): boolean { return ( a.capability === grant.capability && (a.pattern ?? "*") === (grant.pattern ?? "*") && a.action === grant.action ); } /** * Append an "always allow in this project" grant to the project config's * `permissions.rules` array (PERMISSION_MODEL.md §6.2): * * - creates `.khaelor/config.json` (and the directory) when missing; * - read-modify-write preserving every existing key untouched; * - idempotent — an identical rule is never appended twice; * - atomic — temp file in the same directory, then rename; * - fails safe — a malformed existing config is never modified; the error * is returned for the service to surface (the grant stays in-memory). */ export async function persistPermissionGrant( grant: PermissionGrant, options: PersistPermissionGrantOptions, ): Promise> { const path = join(options.projectDir, ".khaelor", "config.json"); // ── read (tolerating a missing file) ── let text: string | null = null; try { text = await readFile(path, "utf8"); } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code !== "ENOENT" && code !== "ENOTDIR") { return err( new KhaelorError("config-io", `Cannot read config file: ${path}`, { cause: String(error), }), ); } } let root: Record = {}; if (text !== null) { let parsed: unknown; try { parsed = JSON.parse(text); } catch (error) { return err( new KhaelorError( "config-invalid", `Invalid JSON in config file: ${path} — refusing to modify it`, { cause: String(error) }, ), ); } // Validation guards the rewrite: a config the loader would reject (bad // shapes, secret fields) is left exactly as the user wrote it. const validated = validatePartialConfig(parsed, path); if (!validated.ok) return err(validated.error); root = parsed as Record; } // ── modify: append to permissions.rules, idempotently ── const permissions = (root["permissions"] ?? {}) as Record; const rules = (permissions["rules"] ?? []) as PermissionRuleEntry[]; if (rules.some((rule) => sameRule(rule, grant))) { return ok(undefined); // already granted — nothing to write } rules.push({ capability: grant.capability, ...(grant.pattern !== undefined ? { pattern: grant.pattern } : {}), action: grant.action, }); permissions["rules"] = rules; root["permissions"] = permissions; // ── atomic write: temp file in the same directory, then rename ── const dir = dirname(path); const tmp = join(dir, `.config.json.khaelor-tmp-${randomBytes(6).toString("hex")}`); try { await mkdir(dir, { recursive: true }); await writeFile(tmp, `${JSON.stringify(root, null, 2)}\n`, "utf8"); await rename(tmp, path); } catch (error) { await rm(tmp, { force: true }).catch(() => undefined); return err( new KhaelorError("config-io", `Cannot update config file: ${path}`, { cause: String(error), }), ); } return ok(undefined); }