From 6a3505dfbc2cfeee6b651cea84f7050b90f4f786 Mon Sep 17 00:00:00 2001 From: ms-lead-reviewer Date: Fri, 17 Jul 2026 23:10:58 -0500 Subject: [PATCH] fix(#829): gate claudex and close branch coverage gaps --- docs/architecture/lease-broker-security.md | 2 +- docs/architecture/mutator-class-gate.md | 3 +- docs/guides/lease-broker-operations.md | 6 +- docs/scratchpads/829-mutator-gate.md | 11 +++ .../tools/lease-broker/launch-runtime.py | 23 +++-- .../tools/lease-broker/mutator-gate.py | 29 +++--- packages/mosaic/src/commands/claudex.spec.ts | 62 ++++++++++++- packages/mosaic/src/commands/claudex.ts | 89 ++++++++++++++++++- packages/mosaic/src/commands/launch.ts | 14 +-- .../mutator-gate.acceptance.spec.ts | 54 +++++++++-- 10 files changed, 253 insertions(+), 40 deletions(-) diff --git a/docs/architecture/lease-broker-security.md b/docs/architecture/lease-broker-security.md index b90466f..6d7991e 100644 --- a/docs/architecture/lease-broker-security.md +++ b/docs/architecture/lease-broker-security.md @@ -6,7 +6,7 @@ - Session IDs and cycle tokens use the OS cryptographic RNG. `Math.random` and model output are not token sources. - Framing and persistence failures fail closed. Sensitive tokens are not logged. - Built-in `0700`/`0600` filesystem modes provide same-principal hardening only, not socket authenticity against the same UID. WI-1 provides no distinct-principal isolation. That stronger deployment requires an external protected proxy, ACL, or service boundary, and the boundary must preserve authenticated client identity for the broker's `SO_PEERCRED` and ancestry authorization rather than substituting a shared proxy identity. -- WI-2 whole-class authorization denies every consequential, unknown, and custom tool while UNVERIFIED; it does not inspect shell strings or trust wrapper selection. +- WI-2 whole-class authorization denies every consequential, unknown, and custom tool while UNVERIFIED; it does not inspect shell strings or trust wrapper selection. First-class Claude/Pi and both Claudex dispatch modes use broker register-before-exec; Claudex additionally installs the mandatory all-tools hook inside its preserved isolated config and fails closed on unsafe settings. - WI-2 promotion consumes a WI-1 cycle token before VERIFIED becomes visible. Observer revocation, runtime-generation replacement, broker restart, and monotonic TTL expiry remove authority. - Receipt observation, payload construction, compaction observers, and constrained recovery implementation remain later surfaces. A receipt can become a promotion prerequisite but is never the safety mechanism. diff --git a/docs/architecture/mutator-class-gate.md b/docs/architecture/mutator-class-gate.md index dbb1f6c..cd03af4 100644 --- a/docs/architecture/mutator-class-gate.md +++ b/docs/architecture/mutator-class-gate.md @@ -1,6 +1,6 @@ # Whole mutator-class lease gate -WI-2 adds the framework-native authorization boundary for Claude and Pi. Every runtime-reported tool name reaches the lease broker before execution. The gate classifies capabilities by the whole tool class; it never parses a Bash command to decide whether that particular string looks read-only. +WI-2 adds the framework-native authorization boundary for Claude (including the supported Claudex overlay) and Pi. Every runtime-reported tool name reaches the lease broker before execution. The gate classifies capabilities by the whole tool class; it never parses a Bash command to decide whether that particular string looks read-only. ## Default-deny policy @@ -30,6 +30,7 @@ A receipt is only a future promotion prerequisite. It is not an obedience, resid `launch-runtime.py` registers itself with the broker and then `exec`s Claude or Pi so PID/starttime remain the authenticated parent anchor. It exports only the broker-minted session ID and current generation to descendants. - Claude installs `mutator-gate.py` as an all-tools (`.*`) `PreToolUse` hook. +- `mosaic claudex` and `mosaic yolo claudex` preserve their isolated `CLAUDE_CONFIG_DIR`, merge the mandatory hook into that isolated `settings.json`, and use the same register-before-exec launcher. Malformed or symlinked isolated settings deny launch. - Pi invokes the same executable from its `tool_call` handler. The executable submits the runtime's actual tool name to `authorize_tool`. Missing identity, malformed input/reply, timeout, broker unavailability, or denial exits with status 2 and blocks fail-closed. diff --git a/docs/guides/lease-broker-operations.md b/docs/guides/lease-broker-operations.md index 9f16613..53c69e0 100644 --- a/docs/guides/lease-broker-operations.md +++ b/docs/guides/lease-broker-operations.md @@ -10,14 +10,14 @@ python3 "$MOSAIC_HOME/tools/lease-broker/daemon.py" \ The broker refuses an existing parent directory whose mode is not exactly `0700`, an existing state file not at `0600`, corrupt/incompatible state, or an already-existing socket path. After bind it sets the socket to `0600`. It never silently unlinks a pre-existing socket. On normal termination it unlinks only the socket inode it created, so it does not remove a replacement path. -Before launching Claude or Pi, export the socket path; `mosaic` then runs the runtime through the packaged register-and-exec wrapper: +Before launching Claude, Claudex, or Pi, export the socket path; `mosaic` then runs the runtime through the packaged register-and-exec wrapper: ```bash export MOSAIC_LEASE_BROKER_SOCKET=/run/user/1000/mosaic-lease/broker.sock -mosaic claude # or: mosaic pi +mosaic claude # or: mosaic claudex, mosaic yolo claudex, mosaic pi ``` -The wrapper obtains a broker-minted session ID and `exec`s the runtime without changing its PID/starttime anchor. The all-tools Claude `PreToolUse` hook and Pi `tool_call` handler inherit that identity. Broker registration failure denies runtime launch; missing identity, broker timeout/unavailability, and malformed replies block tools fail-closed. +The wrapper obtains a broker-minted session ID and `exec`s the runtime without changing its PID/starttime anchor. The all-tools Claude `PreToolUse` hook and Pi `tool_call` handler inherit that identity. Claudex retains its isolated proxy environment and config directory; Mosaic merges the mandatory all-tools hook into that isolated `settings.json` before invoking the same wrapper. Broker registration failure, unsafe isolated settings, or missing identity denies launch/tool execution fail-closed; broker timeout/unavailability and malformed replies also block tools. Clients must complete the request boundary before waiting for a reply. After sending the single JSON object and its terminating newline, the client **MUST half-close the socket's write side** (`shutdown(SHUT_WR)` in POSIX clients; `socket.end()` in Node) and only then await the response. Merely calling `write()` and waiting is invalid: the broker waits for EOF to enforce the exact-one-frame contract and fails closed at its one-second deadline. Do not replace `end()` with `write()` in client helpers. A delayed second frame remains malformed and is rejected. diff --git a/docs/scratchpads/829-mutator-gate.md b/docs/scratchpads/829-mutator-gate.md index 8c5cee9..19b7279 100644 --- a/docs/scratchpads/829-mutator-gate.md +++ b/docs/scratchpads/829-mutator-gate.md @@ -71,3 +71,14 @@ Carried authority chain also verified/read for the locked T-B gate contract: SPE - BLOCKER 1 verified: first-class Claude/Pi route through `execLeaseGatedRuntime`, while the supported Claudex path preserves isolation but directly invokes `claude`; it therefore registers no anchor, injects no lease session, and the isolated config has no guaranteed all-tools gate hook. - BLOCKER 2 accepted: prior 88% was aggregate evidence. Remediation must produce independently measured branch coverage of at least 85% for each new executable (`launch-runtime.py`, `mutator-gate.py`, and daemon delta evidence), including successful exec-boundary collection and validation/error branches. - Remediation discipline: RED tests first; preserve the reviewed-good broker lock/state-transition ordering; update PR #837 on the same branch; no self-review or merge. + +### Remediation evidence + +- RED commit `046896c6`: both `mosaic claudex` and `mosaic yolo claudex` behavioral probes exited 1 because the direct path supplied neither a broker session nor the isolated all-tools hook; branch-focused Python tests failed on the absent injectable boundaries. Fresh WI-2 daemon-delta instrumentation also failed the ≥85% branch gate at 75%. +- GREEN: Claudex now exposes only `execLeaseGated`, passes the preserved isolated proxy environment through the shared register-before-exec wrapper, and merges the exact `.*` mutator hook into isolated `settings.json` with mode `0600`. Missing broker/identity, malformed or symlinked settings, and an unverified consequential tool all fail closed. The broker transition/lock implementation was not changed. +- Behavioral regression: normal and YOLO Claudex both receive a 64-hex broker session, retain their mode-specific arguments, observe the exact all-tools hook, and receive status 2 for unverified `Bash`. +- Independent branch coverage: `launch-runtime.py` 16/18 = **89%** (statements 98%); `mutator-gate.py` 21/22 = **95%** (statements 99%); `daemon.py` WI-2 delta 35/40 = **88%** (whole-file branch 80%, statements 90%). +- Fresh focused real-socket coverage run: WI-1 + WI-2 acceptance `46/46`; persistence `10/10`; branch unit suite `10/10`. +- Fresh full repository suite: `43/43` Turbo tasks; `@mosaicstack/mosaic` `72/72` files and `1,381/1,381` tests. +- Fresh root gates: typecheck `42/42`; lint `23/23`; format and `git diff --check` GREEN. +- PR #837 remains open and unmerged. Terra CODE and Opus SECREV must both rerun from zero on the exact remediated head before coordinator-owned merge authorization. diff --git a/packages/mosaic/framework/tools/lease-broker/launch-runtime.py b/packages/mosaic/framework/tools/lease-broker/launch-runtime.py index c6a3f4f..b68de1b 100644 --- a/packages/mosaic/framework/tools/lease-broker/launch-runtime.py +++ b/packages/mosaic/framework/tools/lease-broker/launch-runtime.py @@ -8,6 +8,7 @@ import json import os import socket import sys +from collections.abc import Callable, Mapping, Sequence from pathlib import Path from typing import Final @@ -36,11 +37,17 @@ def broker_request(socket_path: Path, request: dict[str, object]) -> dict[str, o return value -def main() -> int: +def main( + argv: Sequence[str] | None = None, + *, + environ: Mapping[str, str] | None = None, + request: Callable[[Path, dict[str, object]], dict[str, object]] = broker_request, + execute: Callable[[str, list[str], dict[str, str]], object] = os.execvpe, +) -> int: parser = argparse.ArgumentParser() parser.add_argument("--runtime", required=True, choices=("claude", "pi")) parser.add_argument("command", nargs=argparse.REMAINDER) - arguments = parser.parse_args() + arguments = parser.parse_args(argv) command = arguments.command if command and command[0] == "--": command = command[1:] @@ -48,12 +55,13 @@ def main() -> int: print("lease-gated runtime command is required", file=sys.stderr) return 64 + source_environment = os.environ if environ is None else environ try: - socket_path = Path(os.environ["MOSAIC_LEASE_BROKER_SOCKET"]) - generation = int(os.environ.get("MOSAIC_RUNTIME_GENERATION", "1")) + socket_path = Path(source_environment["MOSAIC_LEASE_BROKER_SOCKET"]) + generation = int(source_environment.get("MOSAIC_RUNTIME_GENERATION", "1")) if generation < 0: raise ValueError("invalid generation") - reply = broker_request( + reply = request( socket_path, { "action": "register_anchor", @@ -72,15 +80,16 @@ def main() -> int: print("Mosaic lease broker registration failed; runtime launch denied.", file=sys.stderr) return 1 - environment = dict(os.environ) + environment = dict(source_environment) environment["MOSAIC_LEASE_SESSION_ID"] = session_id environment["MOSAIC_RUNTIME_GENERATION"] = str(generation) environment["MOSAIC_LEASE_RUNTIME"] = arguments.runtime try: - os.execvpe(command[0], command, environment) + execute(command[0], command, environment) except OSError: print("Mosaic lease-gated runtime exec failed.", file=sys.stderr) return 1 + return 0 if __name__ == "__main__": diff --git a/packages/mosaic/framework/tools/lease-broker/mutator-gate.py b/packages/mosaic/framework/tools/lease-broker/mutator-gate.py index c66cbbb..d622b33 100644 --- a/packages/mosaic/framework/tools/lease-broker/mutator-gate.py +++ b/packages/mosaic/framework/tools/lease-broker/mutator-gate.py @@ -8,8 +8,9 @@ import json import os import socket import sys +from collections.abc import Callable, Mapping, Sequence from pathlib import Path -from typing import Final +from typing import BinaryIO, Final MAX_FRAME: Final = 64 * 1024 BROKER_TIMEOUT_SECONDS: Final = 1.5 @@ -20,8 +21,9 @@ def deny(code: str) -> int: return 2 -def read_tool_name() -> str: - raw = sys.stdin.buffer.read(MAX_FRAME + 1) +def read_tool_name(stream: BinaryIO | None = None) -> str: + source = sys.stdin.buffer if stream is None else stream + raw = source.read(MAX_FRAME + 1) if len(raw) > MAX_FRAME: raise ValueError("INVALID_GATE_INPUT") value = json.loads(raw) @@ -56,19 +58,26 @@ def broker_request(socket_path: Path, request: dict[str, object]) -> dict[str, o return value -def main() -> int: +def main( + argv: Sequence[str] | None = None, + *, + environ: Mapping[str, str] | None = None, + stream: BinaryIO | None = None, + request: Callable[[Path, dict[str, object]], dict[str, object]] = broker_request, +) -> int: parser = argparse.ArgumentParser() parser.add_argument("--runtime", required=True, choices=("claude", "pi")) - arguments = parser.parse_args() + arguments = parser.parse_args(argv) + source_environment = os.environ if environ is None else environ try: - tool_name = read_tool_name() - socket_value = os.environ["MOSAIC_LEASE_BROKER_SOCKET"] - session_id = os.environ["MOSAIC_LEASE_SESSION_ID"] - generation = int(os.environ["MOSAIC_RUNTIME_GENERATION"]) + tool_name = read_tool_name(stream) + socket_value = source_environment["MOSAIC_LEASE_BROKER_SOCKET"] + session_id = source_environment["MOSAIC_LEASE_SESSION_ID"] + generation = int(source_environment["MOSAIC_RUNTIME_GENERATION"]) if generation < 0: raise ValueError("INVALID_GENERATION") - reply = broker_request( + reply = request( Path(socket_value), { "action": "authorize_tool", diff --git a/packages/mosaic/src/commands/claudex.spec.ts b/packages/mosaic/src/commands/claudex.spec.ts index fab7cdc..65a1305 100644 --- a/packages/mosaic/src/commands/claudex.spec.ts +++ b/packages/mosaic/src/commands/claudex.spec.ts @@ -1,5 +1,13 @@ import { describe, it, expect, vi } from 'vitest'; -import { mkdtempSync, mkdirSync, symlinkSync, rmSync, lstatSync, writeFileSync } from 'node:fs'; +import { + lstatSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; import { tmpdir, homedir } from 'node:os'; import { join } from 'node:path'; import { @@ -14,6 +22,7 @@ import { buildClaudexEnv, buildClaudexBanner, buildClaudexContractNote, + ensureClaudexMutatorGateSettings, runClaudexProxyGate, launchClaudex, type ClaudexHarnessAdapter, @@ -36,12 +45,17 @@ function makeReport(overrides: Partial = {}): PreflightReport { }; } -function okAdapter(overrides: Partial = {}): ClaudexHarnessAdapter { +type TestAdapterOverrides = Partial & { + exec?: (cmd: string, args: string[], env: NodeJS.ProcessEnv) => void; +}; + +function okAdapter(overrides: TestAdapterOverrides = {}): ClaudexHarnessAdapter { + const { exec, ...adapterOverrides } = overrides; return { harnessPreflight: () => {}, composePrompt: () => '# Composed Claude contract', - exec: () => {}, - ...overrides, + execLeaseGated: adapterOverrides.execLeaseGated ?? ((args, env) => exec?.('claude', args, env)), + ...adapterOverrides, }; } @@ -561,11 +575,51 @@ describe('runClaudexProxyGate', () => { // ─── launch orchestration (fail-closed ordering) ────────────────────────────── +describe('ensureClaudexMutatorGateSettings', () => { + it('does not accept a lookalike hook and preserves isolated settings', () => { + const root = mkdtempSync(join(tmpdir(), 'claudex-gate-settings-')); + try { + writeFileSync( + join(root, 'settings.json'), + JSON.stringify({ + preserved: true, + hooks: { + PreToolUse: [ + { + matcher: '.*', + hooks: [{ type: 'command', command: 'echo lease-broker/mutator-gate.py' }], + }, + ], + }, + }), + ); + + ensureClaudexMutatorGateSettings(root); + + const settings = JSON.parse(readFileSync(join(root, 'settings.json'), 'utf8')) as { + preserved: boolean; + hooks: { PreToolUse: Array<{ hooks: Array<{ command: string }> }> }; + }; + const commands = settings.hooks.PreToolUse.flatMap((entry) => + entry.hooks.map((hook) => hook.command), + ); + expect(settings.preserved).toBe(true); + expect(commands).toContain( + 'python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude', + ); + expect(lstatSync(join(root, 'settings.json')).mode & 0o777).toBe(0o600); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + describe('launchClaudex', () => { const baseDeps = { baseEnv: {}, proxyGate: () => Promise.resolve({ ok: true, report: makeReport(), problems: [] }), resolveConfigDir: () => '/home/agent/.config/mosaic/claudex/home', + prepareConfig: () => {}, log: () => {}, errorLog: () => {}, fail: (() => { diff --git a/packages/mosaic/src/commands/claudex.ts b/packages/mosaic/src/commands/claudex.ts index f6f47d6..5fdc5c6 100644 --- a/packages/mosaic/src/commands/claudex.ts +++ b/packages/mosaic/src/commands/claudex.ts @@ -27,7 +27,14 @@ * unit-testable without spawning Claude Code or touching a real config dir. */ -import { lstatSync, mkdirSync, realpathSync } from 'node:fs'; +import { + chmodSync, + lstatSync, + mkdirSync, + readFileSync, + realpathSync, + writeFileSync, +} from 'node:fs'; import { homedir } from 'node:os'; import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; import { @@ -278,6 +285,77 @@ export function buildClaudexEnv( return env; } +const CLAUDEX_MUTATOR_GATE_COMMAND = + 'python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude'; + +const CLAUDEX_MUTATOR_GATE_HOOK = { + matcher: '.*', + hooks: [ + { + type: 'command', + command: CLAUDEX_MUTATOR_GATE_COMMAND, + timeout: 3, + }, + ], +}; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Install the mandatory all-tools gate in Claudex's isolated config without + * discarding any existing isolated settings. Malformed settings and symlinked + * settings files fail closed rather than launching with uncertain hook state. + */ +export function ensureClaudexMutatorGateSettings(configDir: string): void { + mkdirSync(configDir, { recursive: true, mode: 0o700 }); + const settingsPath = join(configDir, 'settings.json'); + let settings: Record = {}; + + try { + if (lstatSync(settingsPath).isSymbolicLink()) { + throw new Error('claudex: isolated settings.json must not be a symlink (fail closed).'); + } + const parsed: unknown = JSON.parse(readFileSync(settingsPath, 'utf8')); + if (!isRecord(parsed)) { + throw new Error('claudex: isolated settings.json must contain a JSON object (fail closed).'); + } + settings = parsed; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + } + + const hooksValue = settings['hooks']; + if (hooksValue !== undefined && !isRecord(hooksValue)) { + throw new Error('claudex: isolated settings hooks must be an object (fail closed).'); + } + const hooks = hooksValue ?? {}; + const preToolUseValue = hooks['PreToolUse']; + if (preToolUseValue !== undefined && !Array.isArray(preToolUseValue)) { + throw new Error('claudex: isolated PreToolUse hooks must be an array (fail closed).'); + } + const preToolUse = preToolUseValue ?? []; + const gatePresent = preToolUse.some( + (entry) => + isRecord(entry) && + entry['matcher'] === '.*' && + Array.isArray(entry['hooks']) && + entry['hooks'].some( + (hook) => + isRecord(hook) && + hook['type'] === 'command' && + hook['command'] === CLAUDEX_MUTATOR_GATE_COMMAND, + ), + ); + if (!gatePresent) preToolUse.unshift(CLAUDEX_MUTATOR_GATE_HOOK); + + hooks['PreToolUse'] = preToolUse; + settings['hooks'] = hooks; + writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600 }); + chmodSync(settingsPath, 0o600); +} + // ─── EXPERIMENTAL classification (P4) ───────────────────────────────────────── /** Console banner shown at launch. Contains no token material by construction. */ @@ -396,8 +474,8 @@ export interface ClaudexHarnessAdapter { harnessPreflight: () => void; /** Compose the full Claude runtime contract (== `composeContract('claude')`). */ composePrompt: () => string; - /** Replace the current process with `claude` using the composed env. */ - exec: (cmd: string, args: string[], env: NodeJS.ProcessEnv) => void; + /** Register the Claude parent with the broker, then exec with the same PID. */ + execLeaseGated: (args: string[], env: NodeJS.ProcessEnv) => void; } export interface LaunchClaudexDeps { @@ -406,6 +484,7 @@ export interface LaunchClaudexDeps { resolveConfigDir?: () => string; models?: () => ClaudexModels; buildEnv?: (base: NodeJS.ProcessEnv, opts: BuildClaudexEnvOptions) => NodeJS.ProcessEnv; + prepareConfig?: (configDir: string) => void; log?: (message: string) => void; errorLog?: (message: string) => void; fail?: (code: number) => never; @@ -431,6 +510,7 @@ export async function launchClaudex( const resolveConfigDir = deps.resolveConfigDir ?? (() => resolveClaudexConfigDir(baseEnv)); const models = deps.models ?? (() => resolveClaudexModels(baseEnv)); const buildEnv = deps.buildEnv ?? buildClaudexEnv; + const prepareConfig = deps.prepareConfig ?? ensureClaudexMutatorGateSettings; try { // Harness readiness first (claude on PATH, mosaic home, sequential-thinking). @@ -447,6 +527,7 @@ export async function launchClaudex( // Compose the isolated launch env (guard throws → caught below, fail closed). const resolvedModels = models(); const configDir = resolveConfigDir(); + prepareConfig(configDir); const env = buildEnv(baseEnv, { configDir, models: resolvedModels }); const prompt = `${adapter.composePrompt()}\n\n${buildClaudexContractNote(resolvedModels)}`; @@ -454,7 +535,7 @@ export async function launchClaudex( const cliArgs = yolo ? ['--dangerously-skip-permissions'] : []; cliArgs.push('--append-system-prompt', prompt, ...args); - adapter.exec('claude', cliArgs, env); + adapter.execLeaseGated(cliArgs, env); } catch (err) { errorLog( `[mosaic] claudex launch aborted: ${err instanceof Error ? err.message : String(err)}`, diff --git a/packages/mosaic/src/commands/launch.ts b/packages/mosaic/src/commands/launch.ts index ecfb620..2cc78d4 100644 --- a/packages/mosaic/src/commands/launch.ts +++ b/packages/mosaic/src/commands/launch.ts @@ -814,12 +814,16 @@ function defaultLeaseBrokerSocket(env: NodeJS.ProcessEnv = process.env): string return join('/run/user', String(uid), 'mosaic-lease', 'broker.sock'); } -function execLeaseGatedRuntime(runtime: 'claude' | 'pi', args: string[]): void { +function execLeaseGatedRuntime( + runtime: 'claude' | 'pi', + args: string[], + baseEnv: NodeJS.ProcessEnv = process.env, +): void { const launcher = resolveTool('lease-broker', 'launch-runtime.py'); execRuntime('python3', [launcher, '--runtime', runtime, '--', runtime, ...args], { - ...process.env, - MOSAIC_LEASE_BROKER_SOCKET: defaultLeaseBrokerSocket(), - MOSAIC_RUNTIME_GENERATION: process.env['MOSAIC_RUNTIME_GENERATION'] ?? '1', + ...baseEnv, + MOSAIC_LEASE_BROKER_SOCKET: defaultLeaseBrokerSocket(baseEnv), + MOSAIC_RUNTIME_GENERATION: baseEnv['MOSAIC_RUNTIME_GENERATION'] ?? '1', }); } @@ -856,7 +860,7 @@ function launchClaudexProduction(args: string[], yolo: boolean): void { checkSequentialThinking('claude'); }, composePrompt: () => buildRuntimePrompt('claude'), - exec: (cmd, cmdArgs, env) => execRuntime(cmd, cmdArgs, env), + execLeaseGated: (cmdArgs, env) => execLeaseGatedRuntime('claude', cmdArgs, env), }; void launchClaudex(args, yolo, adapter); } diff --git a/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts b/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts index 2b40738..78ca520 100644 --- a/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts +++ b/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts @@ -244,6 +244,52 @@ describe('whole mutator-class lease gate', () => { } }); + 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); @@ -400,9 +446,7 @@ raise SystemExit(0 if len(session_id) == 64 and hook_present and denied else 1) const adapter = { harnessPreflight: () => {}, composePrompt: () => '# composed Claude contract', - // Pre-remediation Claudex uses this direct boundary and therefore bypasses registration. - exec: run, - // The remediated orchestration must select this shared register-before-exec boundary. + // 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; @@ -438,8 +482,8 @@ raise SystemExit(0 if len(session_id) == 64 and hook_present and denied else 1) }); expect(execution).toBeDefined(); - expect(execution!.status, execution!.stderr).toBe(0); - expect(JSON.parse(execution!.stdout)).toEqual({ + 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,