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