SPB Git

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%
4.3 KB · 127 lines typescript
Raw Blame History
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