import { describe, it, expect, afterEach } from 'vitest'; import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { leaseEnforcementActivatable } from './lease-activation-probe.js'; import { ENFORCEMENT_HOOK_MARKERS, FAIL_LOUD_MESSAGE, guardClaudeSettingsWiring, loudOptOutMessage, runInstallOrderingGuard, settingsHasEnforcementHooks, stripEnforcementHooks, } from './install-ordering-guard.js'; /** * Red-first tests for issue #869 Point-1 C2 — the install-ordering guard. * * Root cause under test: `mosaic-link-runtime-assets` copies * `runtime/claude/settings.json` (which embeds the PreToolUse * `mutator-gate.py` hook and the Stop `receipt-observer-client.py` hook) * straight into `~/.claude/settings.json`, unconditionally. If the * activation half (C1: `leaseEnforcementActivatable()`) cannot be confirmed, * wiring those hooks bricks the host with a fail-closed gate that can never * be satisfied. This guard must refuse to wire in that case by default, and * only wire anyway on an explicit, loud opt-out. * * All fixtures use temp directories — this suite never reads or writes the * real `~/.claude/settings.json`. */ const FIXTURE_SETTINGS = { model: 'opus', hooks: { PreCompact: [ { matcher: '.*', hooks: [{ type: 'command', command: 'python3 revoke-lease.py --reason pre-compact' }], }, ], PreToolUse: [ { matcher: '.*', hooks: [ { type: 'command', command: 'python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude', timeout: 3, }, ], }, { matcher: 'Write|Edit|MultiEdit', hooks: [{ type: 'command', command: '~/.config/mosaic/tools/qa/prevent-memory-write.sh' }], }, ], PostToolUse: [ { matcher: 'Edit|MultiEdit|Write', hooks: [{ type: 'command', command: '~/.config/mosaic/tools/qa/qa-hook-stdin.sh' }], }, ], Stop: [ { hooks: [ { type: 'command', command: 'python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude', timeout: 3, }, { type: 'command', command: '~/.config/mosaic/tools/qa/reflect-stop-hook.sh' }, ], }, ], }, enabledPlugins: { 'feature-dev@claude-plugins-official': true }, }; function fixtureJson(): string { return JSON.stringify(FIXTURE_SETTINGS, null, 2) + '\n'; } describe('stripEnforcementHooks', () => { it('removes the PreToolUse mutator-gate trigger entirely', () => { const { settings } = stripEnforcementHooks(FIXTURE_SETTINGS); const hooks = settings['hooks'] as Record; const preToolUse = hooks['PreToolUse'] as Array<{ hooks: Array<{ command: string }> }>; expect(preToolUse.some((t) => t.hooks.some((h) => h.command.includes('mutator-gate.py')))).toBe( false, ); }); it('preserves the sibling prevent-memory-write.sh PreToolUse trigger', () => { const { settings } = stripEnforcementHooks(FIXTURE_SETTINGS); const hooks = settings['hooks'] as Record; const preToolUse = hooks['PreToolUse'] as Array<{ hooks: Array<{ command: string }> }>; expect( preToolUse.some((t) => t.hooks.some((h) => h.command.includes('prevent-memory-write.sh'))), ).toBe(true); }); it('removes only the receipt-observer-client.py hook from Stop, keeping reflect-stop-hook.sh', () => { const { settings } = stripEnforcementHooks(FIXTURE_SETTINGS); const hooks = settings['hooks'] as Record; const stop = hooks['Stop'] as Array<{ hooks: Array<{ command: string }> }>; const commands = stop.flatMap((t) => t.hooks.map((h) => h.command)); expect(commands.some((c) => c.includes('receipt-observer-client.py'))).toBe(false); expect(commands.some((c) => c.includes('reflect-stop-hook.sh'))).toBe(true); }); it('leaves PreCompact/PostToolUse hooks byte-identical', () => { const { settings } = stripEnforcementHooks(FIXTURE_SETTINGS); const hooks = settings['hooks'] as Record; expect(hooks['PreCompact']).toEqual(FIXTURE_SETTINGS.hooks.PreCompact); expect(hooks['PostToolUse']).toEqual(FIXTURE_SETTINGS.hooks.PostToolUse); }); it('reports what it removed', () => { const { removed } = stripEnforcementHooks(FIXTURE_SETTINGS); expect(removed).toContain(`PreToolUse:${ENFORCEMENT_HOOK_MARKERS.preToolUse}`); expect(removed).toContain(`Stop:${ENFORCEMENT_HOOK_MARKERS.stop}`); }); }); describe('settingsHasEnforcementHooks', () => { it('is true for the unmodified fixture', () => { expect(settingsHasEnforcementHooks(FIXTURE_SETTINGS)).toBe(true); }); it('is false after stripping', () => { const { settings } = stripEnforcementHooks(FIXTURE_SETTINGS); expect(settingsHasEnforcementHooks(settings)).toBe(false); }); it('is false for settings with no hooks key at all', () => { expect(settingsHasEnforcementHooks({ model: 'opus' })).toBe(false); }); }); describe('guardClaudeSettingsWiring', () => { it('probe=false (default, no opt-out): strips enforcement hooks and reports non-zero with a loud, actionable message', () => { const outcome = guardClaudeSettingsWiring(fixtureJson(), {}, { activatable: () => false }); expect(outcome.exitCode).toBe(1); expect(outcome.wired).toBe(false); expect(settingsHasEnforcementHooks(JSON.parse(outcome.json) as Record)).toBe( false, ); expect(outcome.logs).toHaveLength(1); expect(outcome.logs[0]?.level).toBe('error'); expect(outcome.logs[0]?.message).toBe(FAIL_LOUD_MESSAGE); expect(outcome.logs[0]?.message).toMatch(/refusing to wire a dead gate/i); expect(outcome.logs[0]?.message).toMatch(/#869/); expect(outcome.logs[0]?.message).toMatch(/--allow-inactive-enforcement/); }); it('probe=false + explicit opt-out flag: wires hooks as-is and emits a loud warning', () => { const outcome = guardClaudeSettingsWiring( fixtureJson(), { allowInactiveEnforcement: true }, { activatable: () => false }, ); expect(outcome.exitCode).toBe(0); expect(outcome.wired).toBe(true); expect(settingsHasEnforcementHooks(JSON.parse(outcome.json) as Record)).toBe( true, ); expect(outcome.logs).toHaveLength(1); expect(outcome.logs[0]?.level).toBe('warn'); expect(outcome.logs[0]?.message).toBe(loudOptOutMessage()); expect(outcome.logs[0]?.message).toMatch(/WITHOUT confirmed activation/); }); it('probe=true: wires hooks normally with no logs, regardless of opt-out', () => { const outcome = guardClaudeSettingsWiring(fixtureJson(), {}, { activatable: () => true }); expect(outcome.exitCode).toBe(0); expect(outcome.wired).toBe(true); expect(outcome.logs).toHaveLength(0); expect(JSON.parse(outcome.json)).toEqual(FIXTURE_SETTINGS); }); it('probe=true + opt-out flag set anyway: still wires normally, no spurious warning', () => { const outcome = guardClaudeSettingsWiring( fixtureJson(), { allowInactiveEnforcement: true }, { activatable: () => true }, ); expect(outcome.exitCode).toBe(0); expect(outcome.wired).toBe(true); expect(outcome.logs).toHaveLength(0); }); it('defaults to the real leaseEnforcementActivatable() when no activatable dep is injected', () => { // Deliberately does not assume a fixed true/false value for the real // probe (whether dist/cli.js happens to be built varies by environment — // asserting a hardcoded expectation here would make the test flaky, not // red-first). Instead it proves the wiring is genuinely delegated: the // no-deps call must agree with an explicit call to the same real // predicate, not some other hardcoded value. const reallyActivatable = leaseEnforcementActivatable(); const outcome = guardClaudeSettingsWiring(fixtureJson()); if (reallyActivatable) { expect(outcome.exitCode).toBe(0); expect(outcome.wired).toBe(true); } else { expect(outcome.exitCode).toBe(1); expect(outcome.wired).toBe(false); } }); }); describe('runInstallOrderingGuard (file-level, temp dirs only)', () => { let dir: string; afterEach(() => { if (dir) rmSync(dir, { recursive: true, force: true }); }); function makeSrc(): string { dir = mkdtempSync(join(tmpdir(), 'mosaic-install-ordering-guard-')); const src = join(dir, 'settings.json'); writeFileSync(src, fixtureJson()); return src; } it('probe=false: writes a dest settings.json with hooks stripped and returns exitCode 1', () => { const src = makeSrc(); const dest = join(dir, 'claude-settings.json'); const result = runInstallOrderingGuard(src, dest, {}, { activatable: () => false }); expect(result.exitCode).toBe(1); expect(result.destWritten).toBe(true); expect(existsSync(dest)).toBe(true); const written = JSON.parse(readFileSync(dest, 'utf-8')) as Record; expect(settingsHasEnforcementHooks(written)).toBe(false); }); it('probe=false + opt-out: writes dest with hooks intact and returns exitCode 0', () => { const src = makeSrc(); const dest = join(dir, 'claude-settings.json'); const result = runInstallOrderingGuard( src, dest, { allowInactiveEnforcement: true }, { activatable: () => false }, ); expect(result.exitCode).toBe(0); const written = JSON.parse(readFileSync(dest, 'utf-8')) as Record; expect(settingsHasEnforcementHooks(written)).toBe(true); }); it('probe=true: writes dest with hooks intact and returns exitCode 0', () => { const src = makeSrc(); const dest = join(dir, 'claude-settings.json'); const result = runInstallOrderingGuard(src, dest, {}, { activatable: () => true }); expect(result.exitCode).toBe(0); const written = JSON.parse(readFileSync(dest, 'utf-8')) as Record; expect(settingsHasEnforcementHooks(written)).toBe(true); }); it('backs up a pre-existing divergent dest before overwriting (copy_file_managed parity)', () => { const src = makeSrc(); const dest = join(dir, 'claude-settings.json'); writeFileSync(dest, JSON.stringify({ preexisting: true })); const result = runInstallOrderingGuard(src, dest, {}, { activatable: () => true }); expect(result.destWritten).toBe(true); expect(result.backupPath).toBeDefined(); expect(existsSync(result.backupPath!)).toBe(true); expect(JSON.parse(readFileSync(result.backupPath!, 'utf-8'))).toEqual({ preexisting: true }); }); it('is a no-op write when dest already matches the guarded content (idempotent)', () => { const src = makeSrc(); const dest = join(dir, 'claude-settings.json'); const first = runInstallOrderingGuard(src, dest, {}, { activatable: () => true }); expect(first.destWritten).toBe(true); const second = runInstallOrderingGuard(src, dest, {}, { activatable: () => true }); expect(second.destWritten).toBe(false); expect(second.backupPath).toBeUndefined(); }); it('never touches the real home directory settings path used by this test file', () => { // Sanity guard for the suite itself: every dest path used above lives // under the mkdtemp() scratch dir, never under homedir()/.claude. expect(dir).toContain('mosaic-install-ordering-guard-'); }); });