/** * Install-ordering guard (issue #869, Point-1 card C2). * * Root cause this exists to guard against (#828 version skew, restated): the * framework reseed / install path (`framework/install.sh` → * `mosaic-link-runtime-assets` → copies `runtime/claude/settings.json` to * `~/.claude/settings.json`) wires the ENFORCEMENT half of the lease broker — * the `PreToolUse` `mutator-gate.py` hook and the `Stop` * `receipt-observer-client.py` hook — unconditionally. If the ACTIVATION half * (a CLI build advertising launch-runtime activation + a running broker * supervisor — see `lease-activation-probe.ts`, C1) is absent, the fail-closed * gate then denies every tool call with GATE_UNAVAILABLE: a bricked host. * * This module is the WIRING gate, not the enforcement gate: it decides * whether the enforcement hook entries are written into the settings.json * that ships to `~/.claude/`. It never touches `mutator-gate.py`'s own * fail-closed-on-absent-identity runtime behavior (test-locked in * `runtime_tools_unittest.py` / `fail-closed-regression.spec.ts`). * * Default (no opt-out): NOT activatable → strip the enforcement hook entries * from the written settings.json and report a non-zero outcome with a loud, * actionable message (see FAIL_LOUD_MESSAGE below). * * Opt-out: `--allow-inactive-enforcement` (an explicit, per-invocation CLI * flag — deliberately NOT an environment variable, so it can never sit as a * silently-inherited default in a shell profile). When set on a NOT * activatable host, the hooks ARE wired but a loud warning is emitted saying * so, and the outcome is reported ok (this is a conscious, informed choice). */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname } from 'node:path'; import type { Command } from 'commander'; import { leaseEnforcementActivatable } from './lease-activation-probe.js'; // ─── Enforcement hook identification ──────────────────────────────────────── /** Substrings that identify the two enforcement hook commands #828 wired * unconditionally. Matches the marker strings documented in * `lease-activation-probe.ts`. */ export const ENFORCEMENT_HOOK_MARKERS = { preToolUse: 'mutator-gate.py', stop: 'receipt-observer-client.py', } as const; interface HookEntry { command?: string; [key: string]: unknown; } interface HookTrigger { matcher?: string; hooks?: HookEntry[]; [key: string]: unknown; } type HooksMap = Record; function cloneJson(value: T): T { return JSON.parse(JSON.stringify(value)) as T; } function commandIncludes(hook: HookEntry, marker: string): boolean { return String(hook.command ?? '').includes(marker); } /** * Return a deep clone of `settings` with the enforcement hook entries removed: * - Any `PreToolUse` trigger group containing a `mutator-gate.py` command is * dropped in full (that trigger exists solely to run the gate). * - Within `Stop` trigger groups, only the individual `receipt-observer-client.py` * hook entry is dropped; sibling hooks in the same trigger (e.g. * `reflect-stop-hook.sh`) are preserved. * Every other hook (PreCompact/SessionStart revoke-lease, the * `prevent-memory-write.sh` PreToolUse trigger, PostToolUse qa/typecheck * hooks) is left byte-identical — this function only ever removes the two * markers above. */ export function stripEnforcementHooks(settings: Record): { settings: Record; removed: string[]; } { const cloned = cloneJson(settings); const removed: string[] = []; const hooks = cloned['hooks'] as HooksMap | undefined; if (!hooks || typeof hooks !== 'object') { return { settings: cloned, removed }; } const preToolUse = hooks['PreToolUse']; if (Array.isArray(preToolUse)) { const kept = preToolUse.filter((trigger) => { const hasGate = (trigger.hooks ?? []).some((h) => commandIncludes(h, ENFORCEMENT_HOOK_MARKERS.preToolUse), ); if (hasGate) removed.push('PreToolUse:mutator-gate.py'); return !hasGate; }); if (kept.length > 0) hooks['PreToolUse'] = kept; else delete hooks['PreToolUse']; } const stop = hooks['Stop']; if (Array.isArray(stop)) { const rebuilt: HookTrigger[] = []; for (const trigger of stop) { const innerHooks = trigger.hooks ?? []; const keptHooks = innerHooks.filter((h) => { const isReceiptObserver = commandIncludes(h, ENFORCEMENT_HOOK_MARKERS.stop); if (isReceiptObserver) removed.push('Stop:receipt-observer-client.py'); return !isReceiptObserver; }); if (keptHooks.length > 0) { rebuilt.push({ ...trigger, hooks: keptHooks }); } } if (rebuilt.length > 0) hooks['Stop'] = rebuilt; else delete hooks['Stop']; } if (Object.keys(hooks).length === 0) { delete cloned['hooks']; } else { cloned['hooks'] = hooks; } return { settings: cloned, removed }; } /** True iff `settings` currently wires either enforcement hook. */ export function settingsHasEnforcementHooks(settings: Record): boolean { const hooks = settings['hooks'] as HooksMap | undefined; if (!hooks || typeof hooks !== 'object') return false; const preToolUse = hooks['PreToolUse'] ?? []; const preHit = preToolUse.some((trigger) => (trigger.hooks ?? []).some((h) => commandIncludes(h, ENFORCEMENT_HOOK_MARKERS.preToolUse)), ); if (preHit) return true; const stop = hooks['Stop'] ?? []; return stop.some((trigger) => (trigger.hooks ?? []).some((h) => commandIncludes(h, ENFORCEMENT_HOOK_MARKERS.stop)), ); } // ─── Guard predicate ──────────────────────────────────────────────────────── export const FAIL_LOUD_MESSAGE = '[mosaic] ERROR: enforcement requested but activation half absent — needs a published CLI ' + 'carrying launch-runtime activation + a broker supervisor; refusing to wire a dead gate (see #869). ' + 'The PreToolUse mutator-gate.py hook and Stop receipt-observer-client.py hook were NOT written to ' + 'settings.json. Fix by installing/updating the CLI and broker, then re-run the framework reseed. ' + 'To wire anyway (NOT recommended — the fail-closed gate will deny every tool call with ' + 'GATE_UNAVAILABLE until activation is restored), re-run with --allow-inactive-enforcement.'; export function loudOptOutMessage(): string { return ( '[mosaic] WARNING: wiring lease-enforcement hooks (mutator-gate.py / receipt-observer-client.py) ' + 'WITHOUT confirmed activation — --allow-inactive-enforcement was set explicitly. The fail-closed ' + 'gate will deny every tool call (GATE_UNAVAILABLE) until the activation half (launch-runtime ' + 'activation capability + a running broker supervisor) is present on this host (see #869).' ); } export type GuardLogLevel = 'error' | 'warn'; export interface GuardLogLine { level: GuardLogLevel; message: string; } export interface InstallOrderingGuardOptions { /** Explicit, per-invocation opt-out. Never source this from an environment * variable — see module doc. */ allowInactiveEnforcement?: boolean; } export interface InstallOrderingGuardDeps { /** Defaults to {@link leaseEnforcementActivatable}. Injectable for tests. */ activatable?: () => boolean; } export interface InstallOrderingGuardOutcome { /** The settings.json content to write (pretty-printed, trailing newline). */ json: string; /** Whether the enforcement hooks are present in `json`. */ wired: boolean; /** 0 = proceed normally; 1 = enforcement was refused (fail-loud default path). */ exitCode: 0 | 1; logs: GuardLogLine[]; } /** * The install-ordering guard: decide whether the enforcement hooks embedded * in the Claude settings.json template may be wired into the settings.json * actually shipped to `~/.claude/`. * * - activatable → wire as-is. exitCode 0, no logs. * - NOT activatable, no opt-out → strip enforcement hooks. exitCode 1, * one 'error' log with the actionable FAIL_LOUD_MESSAGE. * - NOT activatable, opt-out set → wire as-is anyway. exitCode 0, one * 'warn' log making the risk explicit and loud. * * Pure function: takes the raw settings.json text, returns the text to write * plus metadata. No filesystem access — callers (the hidden CLI subcommand * below, or a test) own reading/writing so this stays trivially testable with * fakes/temp files and never risks touching a real `~/.claude/settings.json`. */ export function guardClaudeSettingsWiring( rawSettingsJson: string, options: InstallOrderingGuardOptions = {}, deps: InstallOrderingGuardDeps = {}, ): InstallOrderingGuardOutcome { const parsed = JSON.parse(rawSettingsJson) as Record; const activatable = deps.activatable ?? leaseEnforcementActivatable; const isActivatable = activatable(); const serialize = (settings: Record): string => JSON.stringify(settings, null, 2) + '\n'; if (isActivatable) { return { json: serialize(parsed), wired: settingsHasEnforcementHooks(parsed), exitCode: 0, logs: [], }; } if (options.allowInactiveEnforcement === true) { return { json: serialize(parsed), wired: settingsHasEnforcementHooks(parsed), exitCode: 0, logs: [{ level: 'warn', message: loudOptOutMessage() }], }; } const { settings: stripped } = stripEnforcementHooks(parsed); return { json: serialize(stripped), wired: settingsHasEnforcementHooks(stripped), exitCode: 1, logs: [{ level: 'error', message: FAIL_LOUD_MESSAGE }], }; } // ─── File-level runner (shared by the CLI action + tests) ────────────────── export interface RunInstallOrderingGuardResult extends InstallOrderingGuardOutcome { destWritten: boolean; backupPath?: string; } /** * Read `src`, guard it, and write the result to `dest` — mirroring * `copy_file_managed`'s backup-on-change semantics from * `mosaic-link-runtime-assets` (skip the write if content is unchanged; * back up an existing divergent file once, timestamped). Exported standalone * (not only reachable via the CLI action closure) so tests can exercise real * file I/O against temp directories without ever touching `~/.claude/`. */ export function runInstallOrderingGuard( src: string, dest: string, options: InstallOrderingGuardOptions = {}, deps: InstallOrderingGuardDeps = {}, ): RunInstallOrderingGuardResult { const raw = readFileSync(src, 'utf-8'); const outcome = guardClaudeSettingsWiring(raw, options, deps); mkdirSync(dirname(dest), { recursive: true }); const existing = existsSync(dest) ? readFileSync(dest, 'utf-8') : null; let destWritten = false; let backupPath: string | undefined; if (existing !== outcome.json) { if (existing !== null) { const stamp = new Date() .toISOString() .replace(/[-:]/g, '') .replace(/\..+$/, '') .replace('T', ''); backupPath = `${dest}.mosaic-bak-${stamp}`; writeFileSync(backupPath, existing); } writeFileSync(dest, outcome.json); destWritten = true; } return { ...outcome, destWritten, backupPath }; } // ─── Hidden CLI bridge (bash → TS) ────────────────────────────────────────── /** Hidden CLI subcommand name. `mosaic-link-runtime-assets` (bash) invokes * this instead of its generic `copy_file_managed` for the settings.json * runtime file specifically, so the guard's decision is made by importing * `leaseEnforcementActivatable()` directly rather than re-implementing the * capability/supervisor probes in shell. Deliberately undocumented (hidden * from `--help`) — internal wiring, not a user-facing command. */ export const INSTALL_ORDERING_GUARD_COMMAND = '__link-claude-settings'; export function registerInstallOrderingGuardCommand(program: Command): void { program .command(`${INSTALL_ORDERING_GUARD_COMMAND} `, { hidden: true }) .description( 'Internal: copy the Claude settings.json template, gating enforcement-hook ' + 'wiring on lease-activation capability (#869 Point-1 C2)', ) .option( '--allow-inactive-enforcement', 'Wire enforcement hooks even when activation cannot be confirmed on this host ' + '(explicit, loud, non-default opt-out — see #869)', ) .action((src: string, dest: string, opts: { allowInactiveEnforcement?: boolean }) => { const result = runInstallOrderingGuard(src, dest, { allowInactiveEnforcement: opts.allowInactiveEnforcement === true, }); for (const line of result.logs) { (line.level === 'error' ? console.error : console.warn)(line.message); } process.exit(result.exitCode); }); }