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%
10.0 KB · 290 lines typescript
Raw Blame History
1/**2 * KHAELOR3 * File: src/config/schema.ts4 * Description: Configuration schema — types, defaults, and hand-rolled runtime validation (no heavy deps).5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { KhaelorError, err, ok } from "../shared/index.js";11import type { Result } from "../shared/index.js";1213export type ThinkingMode = "off" | "adaptive" | "always";14export type PermissionAction = "allow" | "ask" | "deny";1516/** One explicit ordered rule in a config `permissions.rules` array (PERMISSION_MODEL.md §4.1). */17export interface PermissionRuleEntry {18  /** Capability pattern; wildcards allowed: "file.write.*", "*". */19  capability: string;20  /** Subject pattern; wildcards allowed: "git push *". Default "*". */21  pattern?: string;22  action: PermissionAction;23}2425/**26 * The `permissions` config section (PERMISSION_MODEL.md §4.1) — three forms,27 * key order preserved: shorthand (`capability → action`), nested28 * (`capability → { subject-pattern → action }`), and the explicit ordered29 * `rules` array (appended after the shorthand expansion by the normalizer).30 */31export interface PermissionsSection {32  [key: string]:33    | PermissionAction34    | Record<string, PermissionAction>35    | PermissionRuleEntry[]36    | undefined;37}3839/** Phase-gate rigor mode (v2 design §1): strict | auto | off. */40export type GateModeSetting = "strict" | "auto" | "off";4142/** The `gate` config section (v2 design §1). */43export interface GateSection {44  mode: GateModeSetting;45  autoApprove: { maxFiles: number };46}4748/** User-configurable settings (CLAUDE.md §6). */49export interface KhaelorConfig {50  /** Anthropic model id — configurable, never a hard-coded permanent list. */51  model: string;52  /** Cheaper Anthropic model for compaction summaries (ADR-10). */53  auxModel: string;54  thinking: ThinkingMode;55  maxOutputTokens: number;56  /** Permission policy section (shorthand, nested, and `rules` forms — §4.1). */57  permissions: PermissionsSection;58  /** Phase-gate configuration (v2 §1): understand → design → implement. */59  gate: GateSection;60}6162export type PartialKhaelorConfig = Partial<KhaelorConfig>;6364/** Defaults — lowest precedence tier. Model ids are aliases, overridable everywhere. */65export const DEFAULT_CONFIG: Readonly<KhaelorConfig> = Object.freeze({66  model: "claude-sonnet-4-5",67  auxModel: "claude-haiku-4-5",68  thinking: "adaptive" as ThinkingMode,69  maxOutputTokens: 16000,70  permissions: Object.freeze({}) as PermissionsSection,71  gate: Object.freeze({72    mode: "auto" as GateModeSetting,73    autoApprove: Object.freeze({ maxFiles: 3 }),74  }) as GateSection,75});7677const GATE_MODES: readonly string[] = ["strict", "auto", "off"];7879/** Validate a raw `gate` section (v2 §1). */80export function validateGateSection(81  value: unknown,82  source: string,83): Result<GateSection, KhaelorError> {84  if (value === null || typeof value !== "object" || Array.isArray(value)) {85    return err(invalid(source, `"gate" must be an object`));86  }87  const raw = value as Record<string, unknown>;88  const out: GateSection = {89    mode: DEFAULT_CONFIG.gate.mode,90    autoApprove: { ...DEFAULT_CONFIG.gate.autoApprove },91  };92  if ("mode" in raw) {93    if (typeof raw["mode"] !== "string" || !GATE_MODES.includes(raw["mode"])) {94      return err(invalid(source, `"gate.mode" must be one of: ${GATE_MODES.join(", ")}`));95    }96    out.mode = raw["mode"] as GateModeSetting;97  }98  if ("autoApprove" in raw) {99    const auto = raw["autoApprove"];100    if (auto === null || typeof auto !== "object" || Array.isArray(auto)) {101      return err(invalid(source, `"gate.autoApprove" must be an object`));102    }103    const maxFiles = (auto as Record<string, unknown>)["maxFiles"];104    if (maxFiles !== undefined) {105      if (typeof maxFiles !== "number" || !Number.isInteger(maxFiles) || maxFiles < 0) {106        return err(invalid(source, `"gate.autoApprove.maxFiles" must be a non-negative integer`));107      }108      out.autoApprove.maxFiles = maxFiles;109    }110  }111  return ok(out);112}113114const THINKING_MODES: readonly string[] = ["off", "adaptive", "always"];115const PERMISSION_ACTIONS: readonly string[] = ["allow", "ask", "deny"];116117/**118 * Field names that must never appear in config files — secrets belong in the119 * environment (ANTHROPIC_API_KEY) or the OS keychain, never on disk in JSON.120 * Error messages never echo the offending value.121 */122const FORBIDDEN_SECRET_FIELDS: readonly string[] = [123  "apiKey",124  "api_key",125  "anthropicApiKey",126  "ANTHROPIC_API_KEY",127];128129function invalid(source: string, message: string): KhaelorError {130  return new KhaelorError("config-invalid", `${source}: ${message}`, { source });131}132133/**134 * Validate a raw `permissions` section against the three PERMISSION_MODEL.md135 * §4.1 forms: shorthand (`capability → action`), nested (`capability →136 * { pattern → action }`), and the explicit ordered `rules` array. Source key137 * order is preserved (last-match-wins depends on it).138 */139export function validatePermissionsSection(140  value: unknown,141  source: string,142): Result<PermissionsSection, KhaelorError> {143  if (value === null || typeof value !== "object" || Array.isArray(value)) {144    return err(145      invalid(source, `"permissions" must be an object of capability → allow|ask|deny`),146    );147  }148  const out: PermissionsSection = {};149  for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {150    if (key === "rules") {151      if (!Array.isArray(entry)) {152        return err(invalid(source, `"permissions.rules" must be an array`));153      }154      const rules: PermissionRuleEntry[] = [];155      for (const [index, item] of entry.entries()) {156        if (item === null || typeof item !== "object" || Array.isArray(item)) {157          return err(invalid(source, `"permissions.rules[${index}]" must be an object`));158        }159        const rule = item as Record<string, unknown>;160        const capability = rule["capability"];161        const action = rule["action"];162        const pattern = rule["pattern"];163        if (typeof capability !== "string" || capability.length === 0) {164          return err(165            invalid(source, `"permissions.rules[${index}].capability" must be a non-empty string`),166          );167        }168        if (typeof action !== "string" || !PERMISSION_ACTIONS.includes(action)) {169          return err(170            invalid(171              source,172              `"permissions.rules[${index}].action" must be one of: ${PERMISSION_ACTIONS.join(", ")}`,173            ),174          );175        }176        if (pattern !== undefined && typeof pattern !== "string") {177          return err(178            invalid(source, `"permissions.rules[${index}].pattern" must be a string when present`),179          );180        }181        rules.push({182          capability,183          ...(pattern !== undefined ? { pattern } : {}),184          action: action as PermissionAction,185        });186      }187      out["rules"] = rules;188      continue;189    }190    if (typeof entry === "string") {191      if (!PERMISSION_ACTIONS.includes(entry)) {192        return err(193          invalid(source, `"permissions.${key}" must be one of: ${PERMISSION_ACTIONS.join(", ")}`),194        );195      }196      out[key] = entry as PermissionAction;197      continue;198    }199    if (entry !== null && typeof entry === "object" && !Array.isArray(entry)) {200      const nested: Record<string, PermissionAction> = {};201      for (const [pattern, action] of Object.entries(entry as Record<string, unknown>)) {202        if (typeof action !== "string" || !PERMISSION_ACTIONS.includes(action)) {203          return err(204            invalid(205              source,206              `"permissions.${key}.${pattern}" must be one of: ${PERMISSION_ACTIONS.join(", ")}`,207            ),208          );209        }210        nested[pattern] = action as PermissionAction;211      }212      out[key] = nested;213      continue;214    }215    return err(216      invalid(217        source,218        `"permissions.${key}" must be an action string or a pattern → action object`,219      ),220    );221  }222  return ok(out);223}224225/**226 * Validate an untrusted partial config object (file contents or CLI flags).227 * Unknown fields are ignored (forward compatibility) except forbidden secret228 * fields, which are rejected without echoing their values.229 */230export function validatePartialConfig(231  value: unknown,232  source: string,233): Result<PartialKhaelorConfig, KhaelorError> {234  if (value === null || typeof value !== "object" || Array.isArray(value)) {235    return err(invalid(source, "config must be a JSON object"));236  }237  const raw = value as Record<string, unknown>;238239  for (const forbidden of FORBIDDEN_SECRET_FIELDS) {240    if (forbidden in raw) {241      return err(242        invalid(243          source,244          `field "${forbidden}" is not allowed in config files — provide the API key via the ANTHROPIC_API_KEY environment variable`,245        ),246      );247    }248  }249250  const out: PartialKhaelorConfig = {};251252  if ("model" in raw) {253    if (typeof raw["model"] !== "string" || raw["model"].length === 0) {254      return err(invalid(source, `"model" must be a non-empty string`));255    }256    out.model = raw["model"];257  }258  if ("auxModel" in raw) {259    if (typeof raw["auxModel"] !== "string" || raw["auxModel"].length === 0) {260      return err(invalid(source, `"auxModel" must be a non-empty string`));261    }262    out.auxModel = raw["auxModel"];263  }264  if ("thinking" in raw) {265    if (typeof raw["thinking"] !== "string" || !THINKING_MODES.includes(raw["thinking"])) {266      return err(invalid(source, `"thinking" must be one of: ${THINKING_MODES.join(", ")}`));267    }268    out.thinking = raw["thinking"] as ThinkingMode;269  }270  if ("maxOutputTokens" in raw) {271    const n = raw["maxOutputTokens"];272    if (typeof n !== "number" || !Number.isInteger(n) || n <= 0) {273      return err(invalid(source, `"maxOutputTokens" must be a positive integer`));274    }275    out.maxOutputTokens = n;276  }277  if ("permissions" in raw) {278    const permissions = validatePermissionsSection(raw["permissions"], source);279    if (!permissions.ok) return permissions;280    out.permissions = permissions.value;281  }282  if ("gate" in raw) {283    const gate = validateGateSection(raw["gate"], source);284    if (!gate.ok) return gate;285    out.gate = gate.value;286  }287288  return ok(out);289}290