diff --git a/packages/mosaic/src/commands/launch.ts b/packages/mosaic/src/commands/launch.ts index 6f4e5041..3a0dd998 100644 --- a/packages/mosaic/src/commands/launch.ts +++ b/packages/mosaic/src/commands/launch.ts @@ -28,6 +28,7 @@ import { readRegularFileSecure } from '../fleet/secure-file.js'; import { readPersonaContractBlock } from '../fleet/persona-contract.js'; import { canonicalizeRoleClass } from './fleet-personas.js'; import { launchClaudex, type ClaudexHarnessAdapter } from './claudex.js'; +import { runLeaseEnforcementDoctorCheck } from './lease-doctor-check.js'; const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic'); const MAX_INSTALLED_TOOLS_BYTES = 256 * 1024; @@ -1237,7 +1238,6 @@ export function registerLaunchCommands(program: Command): void { // Direct framework script delegates const directCommands: Record = { init: { desc: 'Generate SOUL.md (agent identity contract)', script: 'mosaic-init' }, - doctor: { desc: 'Health audit — detect drift and missing files', script: 'mosaic-doctor' }, sync: { desc: 'Sync skills from canonical source', script: 'mosaic-sync-skills' }, bootstrap: { desc: 'Bootstrap a repo with Mosaic standards', @@ -1256,4 +1256,67 @@ export function registerLaunchCommands(program: Command): void { delegateToScript(fwScript(script), cmd.args); }); } + + // `doctor` — the framework drift audit (bash script) PLUS the #869 + // Point-1 C5 lease-enforcement activation check (TS, reusing C1's + // `leaseEnforcementActivatable()` and C3's `checkBrokerSupervisorHealth()`). + // Kept out of the generic `directCommands` loop above because this check + // must run and report BEFORE the bash script's own exit, and must be able + // to force a non-zero exit on its own — a silent pass on "enforcement + // hooks wired but activation absent" would leave a bricked host + // undiagnosed (see lease-doctor-check.ts docstring). + program + .command('doctor') + .description('Health audit — detect drift, missing files, and #869 lease-activation gaps') + .allowUnknownOption(true) + .allowExcessArguments(true) + .action(async (_opts: unknown, cmd: Command) => { + checkMosaicHome(); + const leaseCheck = await runLeaseEnforcementDoctorCheck(); + const leaseCheckFailed = printLeaseDoctorCheck(leaseCheck); + runDoctorScriptAndExit(fwScript('mosaic-doctor'), cmd.args, leaseCheckFailed); + }); +} + +/** + * Print the #869 C5 lease-enforcement doctor result using the same + * `[mosaic-doctor]` prefix the bash audit script uses, but with a distinct + * `[ERROR]` severity token (louder than the script's own `[WARN]`) — this is + * a hard, actionable brick warning, not a soft drift warning, and must never + * read as just one more line among the script's routine warnings. Silent on + * an `ok` result, matching this file's other pre-flight checks + * (`checkMosaicHome`, `checkFile`, `checkRuntime`) which only print on + * failure. Returns whether the check failed, so the caller can force a + * non-zero exit regardless of the bash script's own exit code. + */ +function printLeaseDoctorCheck( + result: Awaited>, +): boolean { + if (result.status === 'error') { + console.error(`[mosaic-doctor] [ERROR] ${result.message}`); + return true; + } + return false; +} + +/** + * Run the bash `mosaic-doctor` audit script (inheriting stdio, same as + * {@link delegateToScript}) and exit with a non-zero code if EITHER the + * script itself reported failure OR the lease-enforcement check above did — + * so `--fail-on-warn` and other script-level exit semantics are preserved, + * but the lease-enforcement ERROR can never be masked by an otherwise-green + * script run. + */ +function runDoctorScriptAndExit(scriptPath: string, args: string[], forceFailure: boolean): never { + if (!existsSync(scriptPath)) { + console.error(`[mosaic] Script not found: ${scriptPath}`); + process.exit(1); + } + let scriptExitCode = 0; + try { + execFileSync('bash', [scriptPath, ...args], { stdio: 'inherit', env: process.env }); + } catch (err) { + scriptExitCode = (err as { status?: number }).status ?? 1; + } + process.exit(forceFailure ? 1 : scriptExitCode); } diff --git a/packages/mosaic/src/commands/lease-doctor-check.spec.ts b/packages/mosaic/src/commands/lease-doctor-check.spec.ts new file mode 100644 index 00000000..31a194c7 --- /dev/null +++ b/packages/mosaic/src/commands/lease-doctor-check.spec.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from 'vitest'; +import { + detectEnforcementHooksWired, + runLeaseEnforcementDoctorCheck, +} from './lease-doctor-check.js'; + +/** + * Red-first tests for issue #869 Point-1 C5 — the `mosaic doctor` + * lease-enforcement surfacing check. + * + * Root cause under test: enforcement hooks (`mutator-gate.py`, + * `receipt-observer-client.py`) can be wired into `~/.claude/settings.json` + * on a host where C1's `leaseEnforcementActivatable()` is false and/or C3's + * `checkBrokerSupervisorHealth()` reports unhealthy. That combination fails + * closed correctly, but must be surfaced LOUDLY by `mosaic doctor` rather + * than silently passing — this test suite exercises the three primary + * branches (wired+not-activatable, wired+healthy, not-wired) plus the + * broker-unhealthy variant. + * + * Every dependency is injected — no real `~/.claude/settings.json` and no + * real broker are ever touched. + */ + +const WIRED_SETTINGS_JSON = JSON.stringify({ + hooks: { + PreToolUse: [ + { + matcher: '.*', + hooks: [ + { + type: 'command', + command: 'python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude', + }, + ], + }, + ], + Stop: [ + { + hooks: [ + { + type: 'command', + command: + 'python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude --latest-entry', + }, + ], + }, + ], + }, +}); + +const UNWIRED_SETTINGS_JSON = JSON.stringify({ + hooks: { + PostToolUse: [ + { + matcher: 'Edit|MultiEdit|Write', + hooks: [{ type: 'command', command: '~/.config/mosaic/tools/qa/qa-hook-stdin.sh' }], + }, + ], + }, +}); + +describe('detectEnforcementHooksWired', () => { + it('detects the mutator-gate + receipt-observer markers when wired', () => { + const result = detectEnforcementHooksWired(JSON.parse(WIRED_SETTINGS_JSON)); + expect(result.wired).toBe(true); + expect(result.matchedMarkers).toEqual( + expect.arrayContaining(['mutator-gate.py', 'receipt-observer-client.py']), + ); + }); + + it('reports not wired when no enforcement markers are present', () => { + const result = detectEnforcementHooksWired(JSON.parse(UNWIRED_SETTINGS_JSON)); + expect(result.wired).toBe(false); + expect(result.matchedMarkers).toEqual([]); + }); + + it('reports not wired for an empty settings object', () => { + expect(detectEnforcementHooksWired({}).wired).toBe(false); + }); + + it('detects wiring from just ONE marker (partial wiring is still dangerous)', () => { + const onlyMutatorGate = JSON.stringify({ + hooks: { + PreToolUse: [ + { + hooks: [{ type: 'command', command: 'python3 .../mutator-gate.py --runtime claude' }], + }, + ], + }, + }); + const result = detectEnforcementHooksWired(JSON.parse(onlyMutatorGate)); + expect(result.wired).toBe(true); + expect(result.matchedMarkers).toEqual(['mutator-gate.py']); + }); +}); + +describe('runLeaseEnforcementDoctorCheck', () => { + it('RED: wired + not-activatable ⇒ LOUD error (not a silent pass)', async () => { + const result = await runLeaseEnforcementDoctorCheck({ + readSettingsRaw: () => WIRED_SETTINGS_JSON, + isActivatable: () => false, + isBrokerHealthy: async () => true, + }); + + expect(result.status).toBe('error'); + expect(result.wired).toBe(true); + expect(result.activatable).toBe(false); + expect(result.message).toMatch(/activation absent/); + expect(result.message).toMatch(/#869/); + expect(result.message.toLowerCase()).toMatch(/brick/); + }); + + it('wired + activatable + broker-unhealthy ⇒ LOUD error', async () => { + const result = await runLeaseEnforcementDoctorCheck({ + readSettingsRaw: () => WIRED_SETTINGS_JSON, + isActivatable: () => true, + isBrokerHealthy: async () => false, + }); + + expect(result.status).toBe('error'); + expect(result.wired).toBe(true); + expect(result.brokerHealthy).toBe(false); + expect(result.message).toMatch(/broker not healthy/); + }); + + it('wired + not-activatable + broker-unhealthy ⇒ LOUD error citing both reasons', async () => { + const result = await runLeaseEnforcementDoctorCheck({ + readSettingsRaw: () => WIRED_SETTINGS_JSON, + isActivatable: () => false, + isBrokerHealthy: async () => false, + }); + + expect(result.status).toBe('error'); + expect(result.message).toMatch(/activation absent/); + expect(result.message).toMatch(/broker not healthy/); + }); + + it('GREEN: wired + activatable + broker-healthy ⇒ ok', async () => { + const result = await runLeaseEnforcementDoctorCheck({ + readSettingsRaw: () => WIRED_SETTINGS_JSON, + isActivatable: () => true, + isBrokerHealthy: async () => true, + }); + + expect(result.status).toBe('ok'); + expect(result.wired).toBe(true); + expect(result.activatable).toBe(true); + expect(result.brokerHealthy).toBe(true); + }); + + it('GREEN: not-wired ⇒ ok, no false alarm (activation/broker never probed)', async () => { + let activatableCalled = false; + let brokerCalled = false; + + const result = await runLeaseEnforcementDoctorCheck({ + readSettingsRaw: () => UNWIRED_SETTINGS_JSON, + isActivatable: () => { + activatableCalled = true; + return false; + }, + isBrokerHealthy: async () => { + brokerCalled = true; + return false; + }, + }); + + expect(result.status).toBe('ok'); + expect(result.wired).toBe(false); + expect(result.activatable).toBeNull(); + expect(result.brokerHealthy).toBeNull(); + // Not wired must short-circuit — never even consult activation/broker. + expect(activatableCalled).toBe(false); + expect(brokerCalled).toBe(false); + }); + + it('GREEN: settings.json absent ⇒ ok (never touches a real file — readSettingsRaw is injected)', async () => { + const result = await runLeaseEnforcementDoctorCheck({ + readSettingsRaw: () => null, + isActivatable: () => false, + isBrokerHealthy: async () => false, + }); + + expect(result.status).toBe('ok'); + expect(result.wired).toBe(false); + }); + + it('GREEN: malformed settings.json ⇒ ok (parse errors are not this card’s failure class)', async () => { + const result = await runLeaseEnforcementDoctorCheck({ + readSettingsRaw: () => '{ not valid json', + isActivatable: () => false, + isBrokerHealthy: async () => false, + }); + + expect(result.status).toBe('ok'); + }); +}); diff --git a/packages/mosaic/src/commands/lease-doctor-check.ts b/packages/mosaic/src/commands/lease-doctor-check.ts new file mode 100644 index 00000000..866d7986 --- /dev/null +++ b/packages/mosaic/src/commands/lease-doctor-check.ts @@ -0,0 +1,210 @@ +/** + * 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.', + }; +}