/** * KHAELOR * File: src/config/schema.ts * Description: Configuration schema — types, defaults, and hand-rolled runtime validation (no heavy deps). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { KhaelorError, err, ok } from "../shared/index.js"; import type { Result } from "../shared/index.js"; export type ThinkingMode = "off" | "adaptive" | "always"; export type PermissionAction = "allow" | "ask" | "deny"; /** One explicit ordered rule in a config `permissions.rules` array (PERMISSION_MODEL.md §4.1). */ export interface PermissionRuleEntry { /** Capability pattern; wildcards allowed: "file.write.*", "*". */ capability: string; /** Subject pattern; wildcards allowed: "git push *". Default "*". */ pattern?: string; action: PermissionAction; } /** * The `permissions` config section (PERMISSION_MODEL.md §4.1) — three forms, * key order preserved: shorthand (`capability → action`), nested * (`capability → { subject-pattern → action }`), and the explicit ordered * `rules` array (appended after the shorthand expansion by the normalizer). */ export interface PermissionsSection { [key: string]: | PermissionAction | Record | PermissionRuleEntry[] | undefined; } /** Phase-gate rigor mode (v2 design §1): strict | auto | off. */ export type GateModeSetting = "strict" | "auto" | "off"; /** The `gate` config section (v2 design §1). */ export interface GateSection { mode: GateModeSetting; autoApprove: { maxFiles: number }; } /** User-configurable settings (CLAUDE.md §6). */ export interface KhaelorConfig { /** Anthropic model id — configurable, never a hard-coded permanent list. */ model: string; /** Cheaper Anthropic model for compaction summaries (ADR-10). */ auxModel: string; thinking: ThinkingMode; maxOutputTokens: number; /** Permission policy section (shorthand, nested, and `rules` forms — §4.1). */ permissions: PermissionsSection; /** Phase-gate configuration (v2 §1): understand → design → implement. */ gate: GateSection; } export type PartialKhaelorConfig = Partial; /** Defaults — lowest precedence tier. Model ids are aliases, overridable everywhere. */ export const DEFAULT_CONFIG: Readonly = Object.freeze({ model: "claude-sonnet-4-5", auxModel: "claude-haiku-4-5", thinking: "adaptive" as ThinkingMode, maxOutputTokens: 16000, permissions: Object.freeze({}) as PermissionsSection, gate: Object.freeze({ mode: "auto" as GateModeSetting, autoApprove: Object.freeze({ maxFiles: 3 }), }) as GateSection, }); const GATE_MODES: readonly string[] = ["strict", "auto", "off"]; /** Validate a raw `gate` section (v2 §1). */ export function validateGateSection( value: unknown, source: string, ): Result { if (value === null || typeof value !== "object" || Array.isArray(value)) { return err(invalid(source, `"gate" must be an object`)); } const raw = value as Record; const out: GateSection = { mode: DEFAULT_CONFIG.gate.mode, autoApprove: { ...DEFAULT_CONFIG.gate.autoApprove }, }; if ("mode" in raw) { if (typeof raw["mode"] !== "string" || !GATE_MODES.includes(raw["mode"])) { return err(invalid(source, `"gate.mode" must be one of: ${GATE_MODES.join(", ")}`)); } out.mode = raw["mode"] as GateModeSetting; } if ("autoApprove" in raw) { const auto = raw["autoApprove"]; if (auto === null || typeof auto !== "object" || Array.isArray(auto)) { return err(invalid(source, `"gate.autoApprove" must be an object`)); } const maxFiles = (auto as Record)["maxFiles"]; if (maxFiles !== undefined) { if (typeof maxFiles !== "number" || !Number.isInteger(maxFiles) || maxFiles < 0) { return err(invalid(source, `"gate.autoApprove.maxFiles" must be a non-negative integer`)); } out.autoApprove.maxFiles = maxFiles; } } return ok(out); } const THINKING_MODES: readonly string[] = ["off", "adaptive", "always"]; const PERMISSION_ACTIONS: readonly string[] = ["allow", "ask", "deny"]; /** * Field names that must never appear in config files — secrets belong in the * environment (ANTHROPIC_API_KEY) or the OS keychain, never on disk in JSON. * Error messages never echo the offending value. */ const FORBIDDEN_SECRET_FIELDS: readonly string[] = [ "apiKey", "api_key", "anthropicApiKey", "ANTHROPIC_API_KEY", ]; function invalid(source: string, message: string): KhaelorError { return new KhaelorError("config-invalid", `${source}: ${message}`, { source }); } /** * Validate a raw `permissions` section against the three PERMISSION_MODEL.md * §4.1 forms: shorthand (`capability → action`), nested (`capability → * { pattern → action }`), and the explicit ordered `rules` array. Source key * order is preserved (last-match-wins depends on it). */ export function validatePermissionsSection( value: unknown, source: string, ): Result { if (value === null || typeof value !== "object" || Array.isArray(value)) { return err( invalid(source, `"permissions" must be an object of capability → allow|ask|deny`), ); } const out: PermissionsSection = {}; for (const [key, entry] of Object.entries(value as Record)) { if (key === "rules") { if (!Array.isArray(entry)) { return err(invalid(source, `"permissions.rules" must be an array`)); } const rules: PermissionRuleEntry[] = []; for (const [index, item] of entry.entries()) { if (item === null || typeof item !== "object" || Array.isArray(item)) { return err(invalid(source, `"permissions.rules[${index}]" must be an object`)); } const rule = item as Record; const capability = rule["capability"]; const action = rule["action"]; const pattern = rule["pattern"]; if (typeof capability !== "string" || capability.length === 0) { return err( invalid(source, `"permissions.rules[${index}].capability" must be a non-empty string`), ); } if (typeof action !== "string" || !PERMISSION_ACTIONS.includes(action)) { return err( invalid( source, `"permissions.rules[${index}].action" must be one of: ${PERMISSION_ACTIONS.join(", ")}`, ), ); } if (pattern !== undefined && typeof pattern !== "string") { return err( invalid(source, `"permissions.rules[${index}].pattern" must be a string when present`), ); } rules.push({ capability, ...(pattern !== undefined ? { pattern } : {}), action: action as PermissionAction, }); } out["rules"] = rules; continue; } if (typeof entry === "string") { if (!PERMISSION_ACTIONS.includes(entry)) { return err( invalid(source, `"permissions.${key}" must be one of: ${PERMISSION_ACTIONS.join(", ")}`), ); } out[key] = entry as PermissionAction; continue; } if (entry !== null && typeof entry === "object" && !Array.isArray(entry)) { const nested: Record = {}; for (const [pattern, action] of Object.entries(entry as Record)) { if (typeof action !== "string" || !PERMISSION_ACTIONS.includes(action)) { return err( invalid( source, `"permissions.${key}.${pattern}" must be one of: ${PERMISSION_ACTIONS.join(", ")}`, ), ); } nested[pattern] = action as PermissionAction; } out[key] = nested; continue; } return err( invalid( source, `"permissions.${key}" must be an action string or a pattern → action object`, ), ); } return ok(out); } /** * Validate an untrusted partial config object (file contents or CLI flags). * Unknown fields are ignored (forward compatibility) except forbidden secret * fields, which are rejected without echoing their values. */ export function validatePartialConfig( value: unknown, source: string, ): Result { if (value === null || typeof value !== "object" || Array.isArray(value)) { return err(invalid(source, "config must be a JSON object")); } const raw = value as Record; for (const forbidden of FORBIDDEN_SECRET_FIELDS) { if (forbidden in raw) { return err( invalid( source, `field "${forbidden}" is not allowed in config files — provide the API key via the ANTHROPIC_API_KEY environment variable`, ), ); } } const out: PartialKhaelorConfig = {}; if ("model" in raw) { if (typeof raw["model"] !== "string" || raw["model"].length === 0) { return err(invalid(source, `"model" must be a non-empty string`)); } out.model = raw["model"]; } if ("auxModel" in raw) { if (typeof raw["auxModel"] !== "string" || raw["auxModel"].length === 0) { return err(invalid(source, `"auxModel" must be a non-empty string`)); } out.auxModel = raw["auxModel"]; } if ("thinking" in raw) { if (typeof raw["thinking"] !== "string" || !THINKING_MODES.includes(raw["thinking"])) { return err(invalid(source, `"thinking" must be one of: ${THINKING_MODES.join(", ")}`)); } out.thinking = raw["thinking"] as ThinkingMode; } if ("maxOutputTokens" in raw) { const n = raw["maxOutputTokens"]; if (typeof n !== "number" || !Number.isInteger(n) || n <= 0) { return err(invalid(source, `"maxOutputTokens" must be a positive integer`)); } out.maxOutputTokens = n; } if ("permissions" in raw) { const permissions = validatePermissionsSection(raw["permissions"], source); if (!permissions.ok) return permissions; out.permissions = permissions.value; } if ("gate" in raw) { const gate = validateGateSection(raw["gate"], source); if (!gate.ok) return gate; out.gate = gate.value; } return ok(out); }