/** * Module jail policy command (per-module-jail-policy task 2.1/2.2). * * The operator's handle on one module's hook jail posture. Absent a recorded * row, a module follows the system's `hooks.jail_policy`; this verb records, * clears, and shows the resolution. The precedence itself lives in * `resolveJailPolicy` (hooks/jail.ts) and is NOT re-derived here — the read * form calls the same function the executor calls, so what the operator sees * is what a hook will get. * * Weakening below the system's posture asks first (task 2.2), the same shape * `system config set hooks.jail_policy off` uses: an event-bus interview, * answered by whatever responder is attached, skipped only by the registered * `--force`. Strengthening never asks. */ import { eq } from 'drizzle-orm'; import { getDb } from '../../db/client'; import { moduleJailPolicies, modules, systemConfig } from '../../db/schema'; import { type JailPolicy, POLICY_STRENGTH, resolveJailPolicy } from '../../hooks/jail'; import { askConfirm, withInterviewSession } from '../../services/bus-interview'; import { getArg, hasFlag, validateRequiredArgs } from '../parser'; import type { CommandResult } from '../types'; const JAIL_SYSTEM_KEY = 'hooks.jail_policy'; const JAIL_POLICIES: readonly JailPolicy[] = ['auto', 'off', 'required']; function isJailPolicy(value: string): value is JailPolicy { return (JAIL_POLICIES as readonly string[]).includes(value); } /** * The system-wide policy a module would follow with no row of its own. * * Throws on a stored value outside the accepted set, matching the read-time * refusal rule — a hand-edited or restored bad row must fail loudly, not * quietly count as `off` for the weakening comparison. */ function systemJailPolicy(): { value: JailPolicy | undefined; effective: JailPolicy } { const stored = getDb() .select() .from(systemConfig) .where(eq(systemConfig.key, JAIL_SYSTEM_KEY)) .get()?.value; if (stored === undefined) return { value: undefined, effective: 'off' }; if (!isJailPolicy(stored)) { throw new Error( `Stored ${JAIL_SYSTEM_KEY}='${stored}' is not a hook jail policy. Fix it with 'celilo system config set ${JAIL_SYSTEM_KEY} ' before setting a per-module policy.`, ); } return { value: stored, effective: stored }; } /** * Handle module jail command. * * Usage: * celilo module jail — show the effective policy and its source * celilo module jail — record auto|off|required for this module * celilo module jail --clear — remove the row; follow the system again * * @param args - Command arguments (module id, optional policy) * @param flags - Command flags (--clear, --force) * @returns Command result */ export async function handleModuleJail( args: string[], flags: Record = {}, ): Promise { const error = validateRequiredArgs(args, 1); if (error) { return { success: false, error: `${error}\n\nUsage:\n celilo module jail # show the effective policy\n celilo module jail \n celilo module jail --clear`, }; } const moduleId = getArg(args, 0); const policyArg = getArg(args, 1); const clear = hasFlag(flags, 'clear'); if (policyArg && clear) { return { success: false, error: 'Pass either a policy or --clear, not both.', }; } const db = getDb(); const module = moduleId ? db.select().from(modules).where(eq(modules.id, moduleId)).get() : undefined; if (!moduleId || !module) { return { success: false, error: `Module not found: ${moduleId}` }; } if (clear) return clearModuleJailPolicy(moduleId); if (policyArg) return setModuleJailPolicy(moduleId, policyArg, hasFlag(flags, 'force')); return showModuleJailPolicy(moduleId); } /** The read form: the three sources, the effective value, and which one won. */ function showModuleJailPolicy(moduleId: string): CommandResult { const db = getDb(); const moduleRow = db .select() .from(moduleJailPolicies) .where(eq(moduleJailPolicies.moduleId, moduleId)) .get(); const configRow = getDb() .select() .from(systemConfig) .where(eq(systemConfig.key, JAIL_SYSTEM_KEY)) .get(); const envValue = process.env.CELILO_HOOK_JAIL; let effective: { policy: JailPolicy; source: string }; try { effective = resolveJailPolicy(envValue, moduleRow?.policy, configRow?.value); } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error), }; } const lines = [`Hook jail policy for ${moduleId}:`]; lines.push( ` environment: ${envValue === undefined || envValue === '' ? '(CELILO_HOOK_JAIL unset)' : envValue}`, ); lines.push( ` module: ${moduleRow ? `${moduleRow.policy} (recorded ${moduleRow.updatedAt.toISOString()})` : '(none — follows the system)'}`, ); lines.push( ` system: ${configRow ? `${JAIL_SYSTEM_KEY} = ${configRow.value}` : `(${JAIL_SYSTEM_KEY} unset — default off)`}`, ); lines.push(` effective: ${effective.policy} — source: ${effective.source}`); return { success: true, message: lines.join('\n'), data: { moduleId, modulePolicy: moduleRow?.policy, systemPolicy: configRow?.value, envPolicy: envValue || undefined, effective: effective.policy, source: effective.source, }, }; } /** The write form. Weakening below the system's posture asks first. */ async function setModuleJailPolicy( moduleId: string, policyArg: string, force: boolean, ): Promise { if (!isJailPolicy(policyArg)) { return { success: false, error: `Invalid jail policy '${policyArg}'. Use 'auto' (jail when a backend is available), 'required' (an unavailable jail is a hard failure), or 'off'.`, }; } const system = systemJailPolicy(); if (POLICY_STRENGTH[policyArg] < POLICY_STRENGTH[system.effective] && !force) { const confirmed = await withInterviewSession(() => askConfirm({ scope: 'module-jail-policy', key: 'weaken', message: `Set ${moduleId}'s jail policy to ${policyArg}, weaker than the system's ${system.effective}? This module's hooks will run unjailed while the rest of the fleet jails.`, defaultValue: false, }), ); if (!confirmed) { return { success: false, error: 'Cancelled by user' }; } } const db = getDb(); db.insert(moduleJailPolicies) .values({ moduleId, policy: policyArg, updatedAt: new Date() }) .onConflictDoUpdate({ target: moduleJailPolicies.moduleId, set: { policy: policyArg, updatedAt: new Date() }, }) .run(); const weakerNote = POLICY_STRENGTH[policyArg] < POLICY_STRENGTH[system.effective] ? ` (weaker than the system's ${system.effective} — this module's hooks run unjailed)` : ''; return { success: true, message: `Set jail policy for ${moduleId}: ${policyArg}${weakerNote}`, }; } /** * Remove the module's row so it follows the system again. Clearing can never * weaken the posture — the module resolves to exactly the system value — so * there is no interview on this path. */ function clearModuleJailPolicy(moduleId: string): CommandResult { const db = getDb(); const existing = db .select() .from(moduleJailPolicies) .where(eq(moduleJailPolicies.moduleId, moduleId)) .get(); if (!existing) { return { success: true, message: `No jail policy recorded for ${moduleId} (already follows the system)`, }; } db.delete(moduleJailPolicies).where(eq(moduleJailPolicies.moduleId, moduleId)).run(); const system = systemJailPolicy(); return { success: true, message: `Cleared jail policy for ${moduleId} (now follows the system: ${system.effective})`, }; }