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/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