spb/khaelor Public
KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.
TypeScript 82.9%
HTML 14.9%
CSS 1.1%
JavaScript 0.7%
1/**2 * KHAELOR3 * File: src/config/persist.ts4 * Description: persistPermissionGrant — atomically appends an "always allow" rule to the project's .khaelor/config.json (PERMISSION_MODEL.md §6.2, ADR-9).5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { randomBytes } from "node:crypto";11import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";12import { dirname, join } from "node:path";13import { KhaelorError, err, ok } from "../shared/index.js";14import type { Result } from "../shared/index.js";15import { validatePartialConfig } from "./schema.js";16import type { PermissionAction, PermissionRuleEntry } from "./schema.js";1718/**19 * The grant to persist — structurally accepts a permissions-module20 * `PermissionRule`; the provenance `source` is never written to disk21 * (the loader re-tags rules by layer on the next read).22 */23export interface PermissionGrant {24 capability: string;25 pattern?: string;26 action: PermissionAction;27 source?: string;28}2930export interface PersistPermissionGrantOptions {31 /** Project root — the grant lands in `<projectDir>/.khaelor/config.json`. */32 projectDir: string;33}3435function sameRule(a: PermissionRuleEntry, grant: PermissionGrant): boolean {36 return (37 a.capability === grant.capability &&38 (a.pattern ?? "*") === (grant.pattern ?? "*") &&39 a.action === grant.action40 );41}4243/**44 * Append an "always allow in this project" grant to the project config's45 * `permissions.rules` array (PERMISSION_MODEL.md §6.2):46 *47 * - creates `.khaelor/config.json` (and the directory) when missing;48 * - read-modify-write preserving every existing key untouched;49 * - idempotent — an identical rule is never appended twice;50 * - atomic — temp file in the same directory, then rename;51 * - fails safe — a malformed existing config is never modified; the error52 * is returned for the service to surface (the grant stays in-memory).53 */54export async function persistPermissionGrant(55 grant: PermissionGrant,56 options: PersistPermissionGrantOptions,57): Promise<Result<void, KhaelorError>> {58 const path = join(options.projectDir, ".khaelor", "config.json");5960 // ── read (tolerating a missing file) ──61 let text: string | null = null;62 try {63 text = await readFile(path, "utf8");64 } catch (error) {65 const code = (error as NodeJS.ErrnoException).code;66 if (code !== "ENOENT" && code !== "ENOTDIR") {67 return err(68 new KhaelorError("config-io", `Cannot read config file: ${path}`, {69 cause: String(error),70 }),71 );72 }73 }7475 let root: Record<string, unknown> = {};76 if (text !== null) {77 let parsed: unknown;78 try {79 parsed = JSON.parse(text);80 } catch (error) {81 return err(82 new KhaelorError(83 "config-invalid",84 `Invalid JSON in config file: ${path} — refusing to modify it`,85 { cause: String(error) },86 ),87 );88 }89 // Validation guards the rewrite: a config the loader would reject (bad90 // shapes, secret fields) is left exactly as the user wrote it.91 const validated = validatePartialConfig(parsed, path);92 if (!validated.ok) return err(validated.error);93 root = parsed as Record<string, unknown>;94 }9596 // ── modify: append to permissions.rules, idempotently ──97 const permissions = (root["permissions"] ?? {}) as Record<string, unknown>;98 const rules = (permissions["rules"] ?? []) as PermissionRuleEntry[];99 if (rules.some((rule) => sameRule(rule, grant))) {100 return ok(undefined); // already granted — nothing to write101 }102 rules.push({103 capability: grant.capability,104 ...(grant.pattern !== undefined ? { pattern: grant.pattern } : {}),105 action: grant.action,106 });107 permissions["rules"] = rules;108 root["permissions"] = permissions;109110 // ── atomic write: temp file in the same directory, then rename ──111 const dir = dirname(path);112 const tmp = join(dir, `.config.json.khaelor-tmp-${randomBytes(6).toString("hex")}`);113 try {114 await mkdir(dir, { recursive: true });115 await writeFile(tmp, `${JSON.stringify(root, null, 2)}\n`, "utf8");116 await rename(tmp, path);117 } catch (error) {118 await rm(tmp, { force: true }).catch(() => undefined);119 return err(120 new KhaelorError("config-io", `Cannot update config file: ${path}`, {121 cause: String(error),122 }),123 );124 }125 return ok(undefined);126}127