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%
8.0 KB · 233 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/** User-configurable settings (CLAUDE.md §6). */40export interface KhaelorConfig {41  /** Anthropic model id — configurable, never a hard-coded permanent list. */42  model: string;43  /** Cheaper Anthropic model for compaction summaries (ADR-10). */44  auxModel: string;45  thinking: ThinkingMode;46  maxOutputTokens: number;47  /** Permission policy section (shorthand, nested, and `rules` forms — §4.1). */48  permissions: PermissionsSection;49}5051export type PartialKhaelorConfig = Partial<KhaelorConfig>;5253/** Defaults — lowest precedence tier. Model ids are aliases, overridable everywhere. */54export const DEFAULT_CONFIG: Readonly<KhaelorConfig> = Object.freeze({55  model: "claude-sonnet-4-5",56  auxModel: "claude-haiku-4-5",57  thinking: "adaptive" as ThinkingMode,58  maxOutputTokens: 16000,59  permissions: Object.freeze({}) as PermissionsSection,60});6162const THINKING_MODES: readonly string[] = ["off", "adaptive", "always"];63const PERMISSION_ACTIONS: readonly string[] = ["allow", "ask", "deny"];6465/**66 * Field names that must never appear in config files — secrets belong in the67 * environment (ANTHROPIC_API_KEY) or the OS keychain, never on disk in JSON.68 * Error messages never echo the offending value.69 */70const FORBIDDEN_SECRET_FIELDS: readonly string[] = [71  "apiKey",72  "api_key",73  "anthropicApiKey",74  "ANTHROPIC_API_KEY",75];7677function invalid(source: string, message: string): KhaelorError {78  return new KhaelorError("config-invalid", `${source}: ${message}`, { source });79}8081/**82 * Validate a raw `permissions` section against the three PERMISSION_MODEL.md83 * §4.1 forms: shorthand (`capability → action`), nested (`capability →84 * { pattern → action }`), and the explicit ordered `rules` array. Source key85 * order is preserved (last-match-wins depends on it).86 */87export function validatePermissionsSection(88  value: unknown,89  source: string,90): Result<PermissionsSection, KhaelorError> {91  if (value === null || typeof value !== "object" || Array.isArray(value)) {92    return err(93      invalid(source, `"permissions" must be an object of capability → allow|ask|deny`),94    );95  }96  const out: PermissionsSection = {};97  for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {98    if (key === "rules") {99      if (!Array.isArray(entry)) {100        return err(invalid(source, `"permissions.rules" must be an array`));101      }102      const rules: PermissionRuleEntry[] = [];103      for (const [index, item] of entry.entries()) {104        if (item === null || typeof item !== "object" || Array.isArray(item)) {105          return err(invalid(source, `"permissions.rules[${index}]" must be an object`));106        }107        const rule = item as Record<string, unknown>;108        const capability = rule["capability"];109        const action = rule["action"];110        const pattern = rule["pattern"];111        if (typeof capability !== "string" || capability.length === 0) {112          return err(113            invalid(source, `"permissions.rules[${index}].capability" must be a non-empty string`),114          );115        }116        if (typeof action !== "string" || !PERMISSION_ACTIONS.includes(action)) {117          return err(118            invalid(119              source,120              `"permissions.rules[${index}].action" must be one of: ${PERMISSION_ACTIONS.join(", ")}`,121            ),122          );123        }124        if (pattern !== undefined && typeof pattern !== "string") {125          return err(126            invalid(source, `"permissions.rules[${index}].pattern" must be a string when present`),127          );128        }129        rules.push({130          capability,131          ...(pattern !== undefined ? { pattern } : {}),132          action: action as PermissionAction,133        });134      }135      out["rules"] = rules;136      continue;137    }138    if (typeof entry === "string") {139      if (!PERMISSION_ACTIONS.includes(entry)) {140        return err(141          invalid(source, `"permissions.${key}" must be one of: ${PERMISSION_ACTIONS.join(", ")}`),142        );143      }144      out[key] = entry as PermissionAction;145      continue;146    }147    if (entry !== null && typeof entry === "object" && !Array.isArray(entry)) {148      const nested: Record<string, PermissionAction> = {};149      for (const [pattern, action] of Object.entries(entry as Record<string, unknown>)) {150        if (typeof action !== "string" || !PERMISSION_ACTIONS.includes(action)) {151          return err(152            invalid(153              source,154              `"permissions.${key}.${pattern}" must be one of: ${PERMISSION_ACTIONS.join(", ")}`,155            ),156          );157        }158        nested[pattern] = action as PermissionAction;159      }160      out[key] = nested;161      continue;162    }163    return err(164      invalid(165        source,166        `"permissions.${key}" must be an action string or a pattern → action object`,167      ),168    );169  }170  return ok(out);171}172173/**174 * Validate an untrusted partial config object (file contents or CLI flags).175 * Unknown fields are ignored (forward compatibility) except forbidden secret176 * fields, which are rejected without echoing their values.177 */178export function validatePartialConfig(179  value: unknown,180  source: string,181): Result<PartialKhaelorConfig, KhaelorError> {182  if (value === null || typeof value !== "object" || Array.isArray(value)) {183    return err(invalid(source, "config must be a JSON object"));184  }185  const raw = value as Record<string, unknown>;186187  for (const forbidden of FORBIDDEN_SECRET_FIELDS) {188    if (forbidden in raw) {189      return err(190        invalid(191          source,192          `field "${forbidden}" is not allowed in config files — provide the API key via the ANTHROPIC_API_KEY environment variable`,193        ),194      );195    }196  }197198  const out: PartialKhaelorConfig = {};199200  if ("model" in raw) {201    if (typeof raw["model"] !== "string" || raw["model"].length === 0) {202      return err(invalid(source, `"model" must be a non-empty string`));203    }204    out.model = raw["model"];205  }206  if ("auxModel" in raw) {207    if (typeof raw["auxModel"] !== "string" || raw["auxModel"].length === 0) {208      return err(invalid(source, `"auxModel" must be a non-empty string`));209    }210    out.auxModel = raw["auxModel"];211  }212  if ("thinking" in raw) {213    if (typeof raw["thinking"] !== "string" || !THINKING_MODES.includes(raw["thinking"])) {214      return err(invalid(source, `"thinking" must be one of: ${THINKING_MODES.join(", ")}`));215    }216    out.thinking = raw["thinking"] as ThinkingMode;217  }218  if ("maxOutputTokens" in raw) {219    const n = raw["maxOutputTokens"];220    if (typeof n !== "number" || !Number.isInteger(n) || n <= 0) {221      return err(invalid(source, `"maxOutputTokens" must be a positive integer`));222    }223    out.maxOutputTokens = n;224  }225  if ("permissions" in raw) {226    const permissions = validatePermissionsSection(raw["permissions"], source);227    if (!permissions.ok) return permissions;228    out.permissions = permissions.value;229  }230231  return ok(out);232}233