/** * Lease-enforcement doctor check (issue #869, Point-1 card C5). * * Root cause this guards against (#828 version skew, the same one C1/C3 * exist for): the Claude Code enforcement hooks (`mutator-gate.py` gating * PreToolUse, `receipt-observer-client.py` observing Stop) can be WIRED into * `~/.claude/settings.json` on a host where the ACTIVATION half is absent — * no compatible CLI build (C1's `leaseEnforcementActivatable()`), or no * healthy broker supervisor (C3's `checkBrokerSupervisorHealth()`). That * combination is a silent brick: every gated tool call denies with * GATE_UNAVAILABLE, and the fail-closed behavior is *correct* — but nothing * surfaces it to an operator running `mosaic doctor` on an already-bricked * host. * * This module answers one question — "if I ran right now, would I be * bricked?" — by combining: * * 1. wiring detection: does `~/.claude/settings.json` reference either * enforcement-hook marker (`mutator-gate.py` / `receipt-observer-client.py`)? * 2. C1's `leaseEnforcementActivatable()` — could activation satisfy * enforcement if it were exercised right now? * 3. C3's `checkBrokerSupervisorHealth()` — is the broker supervisor * actually healthy? * * Not wired ⇒ ok (nothing to activate, no false alarm). Wired AND activatable * AND broker-healthy ⇒ ok. Wired AND (NOT activatable OR broker unhealthy) ⇒ * a LOUD, actionable error — this module never silently passes that state. * * Every dependency (settings read, activation probe, broker-health check) is * injectable so tests can drive every branch without ever touching a real * `~/.claude/settings.json` or a real broker. */ import { readFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { leaseEnforcementActivatable, type ActivationProbeDeps } from './lease-activation-probe.js'; import { checkBrokerSupervisorHealth, resolveBrokerSupervisorPaths, } from '../lease-broker/broker-supervisor.js'; import { DEFAULT_MOSAIC_HOME } from '../constants.js'; /** Markers identifying the two enforcement-hook halves wired via the * framework reseed. Either marker's presence in `settings.json` means * enforcement is wired — a host can be bricked with just one half present. */ const ENFORCEMENT_HOOK_MARKERS = ['mutator-gate.py', 'receipt-observer-client.py'] as const; export interface EnforcementHooksWiredResult { readonly wired: boolean; readonly matchedMarkers: readonly string[]; } /** * Detect whether the Claude Code enforcement hooks (mutator-gate / * receipt-observer) are wired into an already-parsed `settings.json`. * Pure/testable — takes parsed JSON, never touches the filesystem itself. */ export function detectEnforcementHooksWired(settings: unknown): EnforcementHooksWiredResult { const serialized = JSON.stringify(settings ?? {}); const matchedMarkers = ENFORCEMENT_HOOK_MARKERS.filter((marker) => serialized.includes(marker)); return { wired: matchedMarkers.length > 0, matchedMarkers }; } export interface LeaseDoctorCheckDeps { /** * Read raw `settings.json` text; return `null` if the file is absent. * Defaults to reading the real `~/.claude/settings.json`. ALWAYS inject a * fake in tests — never point this at a real host's settings file. */ readSettingsRaw?: () => string | null; /** Defaults to {@link leaseEnforcementActivatable} (C1). Inject for tests. */ isActivatable?: (deps?: ActivationProbeDeps) => boolean; /** * Defaults to a real broker-supervisor health check (C3) rooted at * `mosaicHome`. Inject for tests — never point this at a real broker. */ isBrokerHealthy?: () => Promise; /** Mosaic home used to resolve default broker-supervisor paths. Defaults to * `$MOSAIC_HOME` or `~/.config/mosaic`. */ mosaicHome?: string; } export type LeaseDoctorCheckStatus = 'ok' | 'error'; export interface LeaseDoctorCheckResult { readonly status: LeaseDoctorCheckStatus; readonly wired: boolean; /** `null` when hooks are not wired (activation/broker were never probed). */ readonly activatable: boolean | null; /** `null` when hooks are not wired (activation/broker were never probed). */ readonly brokerHealthy: boolean | null; readonly message: string; } function defaultReadSettingsRaw(): string | null { const settingsPath = join(homedir(), '.claude', 'settings.json'); try { return readFileSync(settingsPath, 'utf8'); } catch (error) { if (isEnoent(error)) return null; throw error; } } function isEnoent(error: unknown): boolean { return ( typeof error === 'object' && error !== null && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT' ); } function defaultMosaicHome(): string { return process.env['MOSAIC_HOME'] ?? DEFAULT_MOSAIC_HOME; } async function defaultIsBrokerHealthy(mosaicHome: string): Promise { // `frameworkRoot` only feeds SOURCE paths (unit/wrapper/daemon file // locations for `applyBrokerSupervisor`); the health check only reads // TARGET paths (`unitTargetPath`, `socketPath`), both derived from // `mosaicHome`/`homeDir`/`env` alone. Passing `mosaicHome` again here is // therefore safe and never resolves or touches a framework checkout. const paths = resolveBrokerSupervisorPaths({ mosaicHome, frameworkRoot: mosaicHome }); return (await checkBrokerSupervisorHealth(paths)).healthy; } /** * Surface the #869 fail-closed brick scenario as a LOUD `mosaic doctor` * error. See module docstring for the full decision table. */ export async function runLeaseEnforcementDoctorCheck( deps: LeaseDoctorCheckDeps = {}, ): Promise { const readSettingsRaw = deps.readSettingsRaw ?? defaultReadSettingsRaw; const mosaicHome = deps.mosaicHome ?? defaultMosaicHome(); const isActivatable = deps.isActivatable ?? leaseEnforcementActivatable; const isBrokerHealthy = deps.isBrokerHealthy ?? (() => defaultIsBrokerHealthy(mosaicHome)); const raw = readSettingsRaw(); if (raw === null) { return { status: 'ok', wired: false, activatable: null, brokerHealthy: null, message: 'Claude Code settings.json not found — lease-enforcement hooks not wired.', }; } let parsed: unknown; try { parsed = JSON.parse(raw); } catch { // Malformed settings.json is a different failure class than this card // owns (C2 guards install-time writes); report ok rather than // misattributing a parse error to the #869 activation gap. return { status: 'ok', wired: false, activatable: null, brokerHealthy: null, message: 'Claude Code settings.json could not be parsed — skipping lease-enforcement wiring check.', }; } const { wired, matchedMarkers } = detectEnforcementHooksWired(parsed); if (!wired) { return { status: 'ok', wired: false, activatable: null, brokerHealthy: null, message: 'Lease-enforcement hooks not wired in ~/.claude/settings.json — nothing to activate.', }; } const activatable = isActivatable(); const brokerHealthy = await isBrokerHealthy(); if (activatable && brokerHealthy) { return { status: 'ok', wired: true, activatable, brokerHealthy, message: `Lease-enforcement hooks wired (${matchedMarkers.join(', ')}) — activation capability present and broker healthy.`, }; } const reasons: string[] = []; if (!activatable) reasons.push('activation absent (leaseEnforcementActivatable() is false)'); if (!brokerHealthy) { reasons.push('broker not healthy (checkBrokerSupervisorHealth() reports unhealthy)'); } return { status: 'error', wired: true, activatable, brokerHealthy, message: `Lease-enforcement hooks (${matchedMarkers.join(', ')}) are wired in ~/.claude/settings.json, but ${reasons.join(' and ')}. ` + 'Every gated tool call will fail closed and BRICK this agent (see #869). ' + 'Remediate by activating the lease-broker supervisor (systemd unit + socket) or by removing the enforcement hooks from ~/.claude/settings.json.', }; }