import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { createConnection } from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { spawn, spawnSync, type ChildProcess } from 'node:child_process'; import { afterEach, describe, expect, test } from 'vitest'; import { launchClaudex, type ClaudexHarnessAdapter } from '../commands/claudex.js'; interface BrokerReply { ok: boolean; code?: string; decision?: 'allow' | 'deny'; state?: 'UNVERIFIED' | 'PENDING_VERIFICATION' | 'VERIFIED'; session_id?: string; promotion_token?: string; } interface BrokerPaths { socket: string; } const frameworkRoot = new URL('../../framework/', import.meta.url).pathname; const daemonPath = join(frameworkRoot, 'tools/lease-broker/daemon.py'); const gatePath = join(frameworkRoot, 'tools/lease-broker/mutator-gate.py'); const launcherPath = join(frameworkRoot, 'tools/lease-broker/launch-runtime.py'); const claudeSettingsPath = join(frameworkRoot, 'runtime/claude/settings.json'); const piExtensionPath = join(frameworkRoot, 'runtime/pi/mosaic-extension.ts'); const children: ChildProcess[] = []; const temporaryRoots: string[] = []; const binding = (compaction_epoch = 1) => ({ compaction_epoch, request_epoch: 0, h_source: 'a'.repeat(64), h_payload: 'b'.repeat(64), schema_version: 1, }); async function request(socketPath: string, requestValue: object): Promise { return await new Promise((resolve, reject) => { const socket = createConnection(socketPath); let response = ''; socket.setEncoding('utf8'); socket.once('error', reject); socket.on('data', (chunk: string) => { response += chunk; }); socket.once('end', () => resolve(JSON.parse(response) as BrokerReply)); socket.once('connect', () => socket.end(`${JSON.stringify(requestValue)}\n`)); }); } async function startBroker(): Promise { const root = await mkdtemp(join(tmpdir(), 'mosaic-mutator-gate-')); await chmod(root, 0o700); const socket = join(root, 'broker.sock'); const child = spawn( 'python3', [daemonPath, '--socket', socket, '--state', join(root, 'state.json')], { stdio: ['ignore', 'pipe', 'pipe'], }, ); children.push(child); await new Promise((resolve, reject) => { let stderr = ''; child.stderr?.setEncoding('utf8'); child.stderr?.on('data', (chunk: string) => (stderr += chunk)); child.once('error', reject); child.once('exit', (code: number | null) => reject(new Error(`broker exited ${code}: ${stderr}`)), ); child.stdout?.once('data', () => resolve()); }); return { socket }; } async function register(socket: string, runtime_generation = 1): Promise { const reply = await request(socket, { action: 'register_anchor', runtime_generation }); expect(reply.ok).toBe(true); expect(reply.session_id).toMatch(/^[a-f0-9]{64}$/); return reply.session_id!; } async function beginVerification( socket: string, session_id: string, runtime: 'claude' | 'pi', runtime_generation = 1, ttl_seconds = 300, compactionEpoch = 1, ): Promise { return await request(socket, { action: 'begin_verification', session_id, runtime_generation, runtime, ttl_seconds, binding: binding(compactionEpoch), }); } async function promote( socket: string, session_id: string, promotion_token: string, runtime_generation = 1, ): Promise { return await request(socket, { action: 'promote_lease', session_id, runtime_generation, promotion_token, }); } async function authorize( socket: string, session_id: string, runtime: 'claude' | 'pi', tool_name: string, runtime_generation = 1, ): Promise { return await request(socket, { action: 'authorize_tool', session_id, runtime_generation, runtime, tool_name, }); } function runRuntimeGate( socket: string, sessionId: string, runtime: 'claude' | 'pi', toolName: string, generation = 1, ) { return spawnSync('python3', [gatePath, '--runtime', runtime], { input: `${JSON.stringify({ tool_name: toolName })}\n`, encoding: 'utf8', env: { ...process.env, MOSAIC_LEASE_BROKER_SOCKET: socket, MOSAIC_LEASE_SESSION_ID: sessionId, MOSAIC_RUNTIME_GENERATION: String(generation), }, }); } afterEach(async () => { for (const child of children.splice(0)) child.kill('SIGTERM'); await Promise.all( temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })), ); }); describe('whole mutator-class lease gate', () => { test('revoke-first and promote-last structurally bracket mutator authority', async () => { const { socket } = await startBroker(); const sessionId = await register(socket); expect(await promote(socket, sessionId, 'c'.repeat(64))).toMatchObject({ ok: false, code: 'INVALID_LEASE_TRANSITION', }); expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({ ok: false, code: 'MUTATOR_UNVERIFIED', decision: 'deny', }); const pending = await beginVerification(socket, sessionId, 'claude'); expect(pending).toMatchObject({ ok: true, state: 'PENDING_VERIFICATION' }); expect(pending.promotion_token).toMatch(/^[a-f0-9]{64}$/); expect(await authorize(socket, sessionId, 'claude', 'Write')).toMatchObject({ ok: false, decision: 'deny', }); expect(await promote(socket, sessionId, pending.promotion_token!)).toMatchObject({ ok: true, state: 'VERIFIED', }); expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({ ok: true, decision: 'allow', state: 'VERIFIED', }); const nextCycle = await beginVerification(socket, sessionId, 'claude', 1, 300, 2); expect(nextCycle).toMatchObject({ ok: true, state: 'PENDING_VERIFICATION' }); expect(await authorize(socket, sessionId, 'claude', 'Edit')).toMatchObject({ ok: false, decision: 'deny', }); expect(await promote(socket, sessionId, pending.promotion_token!)).toMatchObject({ ok: false, code: 'PROMOTION_TOKEN_MISMATCH', }); }); test('T-B raw and custom mutator tools are default-denied without shell parsing', async () => { const { socket } = await startBroker(); const sessionId = await register(socket); const mutators: Array<['claude' | 'pi', string]> = [ ['claude', 'Bash'], ['claude', 'Edit'], ['claude', 'Write'], ['claude', 'NotebookEdit'], ['claude', 'mcp__provider__close_issue'], ['pi', 'bash'], ['pi', 'edit'], ['pi', 'write'], ['pi', 'deploy'], ['pi', 'unknown_custom_tool'], ]; for (const [runtime, toolName] of mutators) { expect( await authorize(socket, sessionId, runtime, toolName), `${runtime}:${toolName}`, ).toMatchObject({ ok: false, code: 'MUTATOR_UNVERIFIED', decision: 'deny', }); } for (const [runtime, toolName] of [ ['claude', 'Read'], ['claude', 'Grep'], ['pi', 'read'], ['pi', 'grep'], ['pi', 'mosaic_context_recover'], ] as const) { expect(await authorize(socket, sessionId, runtime, toolName)).toMatchObject({ ok: true, decision: 'allow', }); } }); test('lease and tool validation failures remain fail-closed at the broker boundary', async () => { const { socket } = await startBroker(); const sessionId = await register(socket); const baseRequest = { action: 'begin_verification', session_id: sessionId, runtime_generation: 1, runtime: 'claude', ttl_seconds: 300, binding: binding(), }; expect(await request(socket, { ...baseRequest, runtime: 'codex' })).toMatchObject({ ok: false, code: 'INVALID_RUNTIME', }); expect( await request(socket, { ...baseRequest, binding: { ...binding(), h_source: 'bad' } }), ).toMatchObject({ ok: false, code: 'INVALID_BINDING', }); expect(await request(socket, { ...baseRequest, ttl_seconds: 0 })).toMatchObject({ ok: false, code: 'INVALID_LEASE_TTL', }); expect( await request(socket, { action: 'authorize_tool', session_id: sessionId, runtime_generation: 1, runtime: 'codex', tool_name: 'Read', }), ).toMatchObject({ ok: false, code: 'INVALID_RUNTIME' }); expect( await request(socket, { action: 'authorize_tool', session_id: sessionId, runtime_generation: 1, runtime: 'claude', tool_name: 'x'.repeat(257), }), ).toMatchObject({ ok: false, code: 'INVALID_TOOL' }); }); test('observer revocation and monotonic TTL expiry deny the next mutator', async () => { const { socket } = await startBroker(); const sessionId = await register(socket); const pending = await beginVerification(socket, sessionId, 'claude', 1, 1); await promote(socket, sessionId, pending.promotion_token!); expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({ ok: true, decision: 'allow', }); await new Promise((resolve) => setTimeout(resolve, 1_100)); expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({ ok: false, code: 'LEASE_EXPIRED', decision: 'deny', }); const refreshed = await beginVerification(socket, sessionId, 'claude', 1, 300, 2); await promote(socket, sessionId, refreshed.promotion_token!); expect( await request(socket, { action: 'revoke_lease', session_id: sessionId, runtime_generation: 1, reason: 'compaction_observer', }), ).toMatchObject({ ok: true, state: 'UNVERIFIED' }); expect(await authorize(socket, sessionId, 'claude', 'Write')).toMatchObject({ ok: false, code: 'MUTATOR_UNVERIFIED', decision: 'deny', }); }); test('runtime-generation replacement cannot inherit a verified lease', async () => { const { socket } = await startBroker(); const sessionId = await register(socket); const pending = await beginVerification(socket, sessionId, 'pi'); await promote(socket, sessionId, pending.promotion_token!); expect(await authorize(socket, sessionId, 'pi', 'bash', 2)).toMatchObject({ ok: false, code: 'MUTATOR_UNVERIFIED', decision: 'deny', }); expect(await authorize(socket, sessionId, 'pi', 'bash', 1)).toMatchObject({ ok: false, code: 'STALE_GENERATION', }); }); test('runtime launcher anchors broker identity before exec and fails closed without broker', async () => { const { socket } = await startBroker(); const probe = [ 'import json,os,socket', 's=socket.socket(socket.AF_UNIX,socket.SOCK_STREAM)', "s.connect(os.environ['MOSAIC_LEASE_BROKER_SOCKET'])", "request={'action':'authorize_tool','session_id':os.environ['MOSAIC_LEASE_SESSION_ID'],'runtime_generation':int(os.environ['MOSAIC_RUNTIME_GENERATION']),'runtime':'claude','tool_name':'Read'}", "s.sendall((json.dumps(request)+'\\n').encode())", 's.shutdown(socket.SHUT_WR)', "print(json.dumps({'session_id':os.environ['MOSAIC_LEASE_SESSION_ID'],'reply':json.loads(s.recv(65536))}))", ].join(';'); const launched = spawnSync( 'python3', [launcherPath, '--runtime', 'claude', '--', 'python3', '-c', probe], { encoding: 'utf8', env: { ...process.env, MOSAIC_LEASE_BROKER_SOCKET: socket, MOSAIC_RUNTIME_GENERATION: '1', }, }, ); expect(launched.status, launched.stderr).toBe(0); expect(JSON.parse(launched.stdout)).toMatchObject({ session_id: expect.stringMatching(/^[a-f0-9]{64}$/), reply: { ok: true, decision: 'allow' }, }); const unavailable = spawnSync( 'python3', [launcherPath, '--runtime', 'claude', '--', 'python3', '-c', "print('EXECUTED')"], { encoding: 'utf8', env: { ...process.env, MOSAIC_LEASE_BROKER_SOCKET: join(tmpdir(), 'missing-mosaic-broker.sock'), MOSAIC_RUNTIME_GENERATION: '1', }, }, ); expect(unavailable.status).not.toBe(0); expect(unavailable.stdout).not.toContain('EXECUTED'); }); test.each([ { command: 'mosaic claudex', yolo: false }, { command: 'mosaic yolo claudex', yolo: true }, ])( '$command registers a broker anchor, installs the all-tools hook, and denies an unverified mutator', async ({ yolo }) => { const { socket } = await startBroker(); const root = await mkdtemp(join(tmpdir(), 'mosaic-claudex-gate-')); temporaryRoots.push(root); const configDir = join(root, 'isolated-claude'); const binDir = join(root, 'bin'); await mkdir(configDir, { recursive: true }); await mkdir(binDir, { recursive: true }); const fakeClaude = join(binDir, 'claude'); const probe = `#!/usr/bin/env python3 import json import os import subprocess import sys from pathlib import Path session_id = os.environ.get("MOSAIC_LEASE_SESSION_ID", "") settings_path = Path(os.environ["CLAUDE_CONFIG_DIR"]) / "settings.json" try: settings = json.loads(settings_path.read_text()) except (OSError, json.JSONDecodeError): settings = {} pre_tool = settings.get("hooks", {}).get("PreToolUse", []) hook_present = any( item.get("matcher") == ".*" and any("mutator-gate.py" in hook.get("command", "") for hook in item.get("hooks", [])) for item in pre_tool ) denied = subprocess.run( ["python3", ${JSON.stringify(gatePath)}, "--runtime", "claude"], input=json.dumps({"tool_name": "Bash"}) + "\\n", text=True, capture_output=True, env=os.environ, ).returncode == 2 is_yolo = "--dangerously-skip-permissions" in sys.argv[1:] result = { "session_id": session_id, "hook_present": hook_present, "denied": denied, "is_yolo": is_yolo, } print(json.dumps(result)) raise SystemExit(0 if len(session_id) == 64 and hook_present and denied else 1) `; await writeFile(fakeClaude, probe, { mode: 0o700 }); await chmod(fakeClaude, 0o700); let execution: ReturnType | undefined; const run = (cmd: string, args: string[], env: NodeJS.ProcessEnv) => { execution = spawnSync(cmd, args, { encoding: 'utf8', env }); }; const adapter = { harnessPreflight: () => {}, composePrompt: () => '# composed Claude contract', // Claudex exposes only the shared register-before-exec boundary. execLeaseGated: (args: string[], env: NodeJS.ProcessEnv) => run('python3', [launcherPath, '--runtime', 'claude', '--', 'claude', ...args], env), } as unknown as ClaudexHarnessAdapter; await launchClaudex([], yolo, adapter, { baseEnv: { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ''}`, MOSAIC_LEASE_BROKER_SOCKET: socket, MOSAIC_RUNTIME_GENERATION: '1', }, proxyGate: () => Promise.resolve({ ok: true, report: { binaryPresent: true, binaryPath: '/test/claude-code-proxy', auth: { state: 'valid' }, live: true, listenerVerdict: 'ok', needsReauth: false, ok: true, problems: [], }, problems: [], }), resolveConfigDir: () => configDir, log: () => {}, errorLog: () => {}, fail: ((code: number) => { throw new Error(`exit ${code}`); }) as (code: number) => never, }); expect(execution).toBeDefined(); expect(execution!.status, String(execution!.stderr)).toBe(0); expect(JSON.parse(String(execution!.stdout))).toEqual({ session_id: expect.stringMatching(/^[a-f0-9]{64}$/), hook_present: true, denied: true, is_yolo: yolo, }); }, ); test('Claude and Pi runtime adapters consult the broker for every tool class', async () => { const { socket } = await startBroker(); const sessionId = await register(socket); expect(runRuntimeGate(socket, sessionId, 'claude', 'Read').status).toBe(0); expect(runRuntimeGate(socket, sessionId, 'claude', 'Bash').status).toBe(2); expect(runRuntimeGate(socket, sessionId, 'pi', 'unknown_custom_tool').status).toBe(2); const pending = await beginVerification(socket, sessionId, 'claude'); await promote(socket, sessionId, pending.promotion_token!); expect(runRuntimeGate(socket, sessionId, 'claude', 'Bash').status).toBe(0); const settings = JSON.parse(await readFile(claudeSettingsPath, 'utf8')) as { hooks: { PreToolUse: Array<{ matcher?: string; hooks: Array<{ command: string }> }> }; }; expect( settings.hooks.PreToolUse.some( (entry) => entry.matcher === '.*' && entry.hooks.some((hook) => hook.command.includes('mutator-gate.py')), ), ).toBe(true); const piExtension = await readFile(piExtensionPath, 'utf8'); expect(piExtension).toContain("pi.on('tool_call'"); expect(piExtension).toContain('mutator-gate.py'); }); });