fix(#829): gate claudex and close branch coverage gaps
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful

This commit is contained in:
ms-lead-reviewer
2026-07-17 23:10:58 -05:00
parent 046896c612
commit 6a3505dfbc
10 changed files with 253 additions and 40 deletions

View File

@@ -6,7 +6,7 @@
- Session IDs and cycle tokens use the OS cryptographic RNG. `Math.random` and model output are not token sources. - 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. - 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. - 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. - 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. - 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.

View File

@@ -1,6 +1,6 @@
# Whole mutator-class lease gate # 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 ## 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. `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. - 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. - 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. 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.

View File

@@ -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. 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 ```bash
export MOSAIC_LEASE_BROKER_SOCKET=/run/user/1000/mosaic-lease/broker.sock 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. 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.

View File

@@ -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 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. - 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 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.

View File

@@ -8,6 +8,7 @@ import json
import os import os
import socket import socket
import sys import sys
from collections.abc import Callable, Mapping, Sequence
from pathlib import Path from pathlib import Path
from typing import Final from typing import Final
@@ -36,11 +37,17 @@ def broker_request(socket_path: Path, request: dict[str, object]) -> dict[str, o
return value 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 = argparse.ArgumentParser()
parser.add_argument("--runtime", required=True, choices=("claude", "pi")) parser.add_argument("--runtime", required=True, choices=("claude", "pi"))
parser.add_argument("command", nargs=argparse.REMAINDER) parser.add_argument("command", nargs=argparse.REMAINDER)
arguments = parser.parse_args() arguments = parser.parse_args(argv)
command = arguments.command command = arguments.command
if command and command[0] == "--": if command and command[0] == "--":
command = command[1:] command = command[1:]
@@ -48,12 +55,13 @@ def main() -> int:
print("lease-gated runtime command is required", file=sys.stderr) print("lease-gated runtime command is required", file=sys.stderr)
return 64 return 64
source_environment = os.environ if environ is None else environ
try: try:
socket_path = Path(os.environ["MOSAIC_LEASE_BROKER_SOCKET"]) socket_path = Path(source_environment["MOSAIC_LEASE_BROKER_SOCKET"])
generation = int(os.environ.get("MOSAIC_RUNTIME_GENERATION", "1")) generation = int(source_environment.get("MOSAIC_RUNTIME_GENERATION", "1"))
if generation < 0: if generation < 0:
raise ValueError("invalid generation") raise ValueError("invalid generation")
reply = broker_request( reply = request(
socket_path, socket_path,
{ {
"action": "register_anchor", "action": "register_anchor",
@@ -72,15 +80,16 @@ def main() -> int:
print("Mosaic lease broker registration failed; runtime launch denied.", file=sys.stderr) print("Mosaic lease broker registration failed; runtime launch denied.", file=sys.stderr)
return 1 return 1
environment = dict(os.environ) environment = dict(source_environment)
environment["MOSAIC_LEASE_SESSION_ID"] = session_id environment["MOSAIC_LEASE_SESSION_ID"] = session_id
environment["MOSAIC_RUNTIME_GENERATION"] = str(generation) environment["MOSAIC_RUNTIME_GENERATION"] = str(generation)
environment["MOSAIC_LEASE_RUNTIME"] = arguments.runtime environment["MOSAIC_LEASE_RUNTIME"] = arguments.runtime
try: try:
os.execvpe(command[0], command, environment) execute(command[0], command, environment)
except OSError: except OSError:
print("Mosaic lease-gated runtime exec failed.", file=sys.stderr) print("Mosaic lease-gated runtime exec failed.", file=sys.stderr)
return 1 return 1
return 0
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -8,8 +8,9 @@ import json
import os import os
import socket import socket
import sys import sys
from collections.abc import Callable, Mapping, Sequence
from pathlib import Path from pathlib import Path
from typing import Final from typing import BinaryIO, Final
MAX_FRAME: Final = 64 * 1024 MAX_FRAME: Final = 64 * 1024
BROKER_TIMEOUT_SECONDS: Final = 1.5 BROKER_TIMEOUT_SECONDS: Final = 1.5
@@ -20,8 +21,9 @@ def deny(code: str) -> int:
return 2 return 2
def read_tool_name() -> str: def read_tool_name(stream: BinaryIO | None = None) -> str:
raw = sys.stdin.buffer.read(MAX_FRAME + 1) source = sys.stdin.buffer if stream is None else stream
raw = source.read(MAX_FRAME + 1)
if len(raw) > MAX_FRAME: if len(raw) > MAX_FRAME:
raise ValueError("INVALID_GATE_INPUT") raise ValueError("INVALID_GATE_INPUT")
value = json.loads(raw) value = json.loads(raw)
@@ -56,19 +58,26 @@ def broker_request(socket_path: Path, request: dict[str, object]) -> dict[str, o
return value 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 = argparse.ArgumentParser()
parser.add_argument("--runtime", required=True, choices=("claude", "pi")) 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: try:
tool_name = read_tool_name() tool_name = read_tool_name(stream)
socket_value = os.environ["MOSAIC_LEASE_BROKER_SOCKET"] socket_value = source_environment["MOSAIC_LEASE_BROKER_SOCKET"]
session_id = os.environ["MOSAIC_LEASE_SESSION_ID"] session_id = source_environment["MOSAIC_LEASE_SESSION_ID"]
generation = int(os.environ["MOSAIC_RUNTIME_GENERATION"]) generation = int(source_environment["MOSAIC_RUNTIME_GENERATION"])
if generation < 0: if generation < 0:
raise ValueError("INVALID_GENERATION") raise ValueError("INVALID_GENERATION")
reply = broker_request( reply = request(
Path(socket_value), Path(socket_value),
{ {
"action": "authorize_tool", "action": "authorize_tool",

View File

@@ -1,5 +1,13 @@
import { describe, it, expect, vi } from 'vitest'; 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 { tmpdir, homedir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { import {
@@ -14,6 +22,7 @@ import {
buildClaudexEnv, buildClaudexEnv,
buildClaudexBanner, buildClaudexBanner,
buildClaudexContractNote, buildClaudexContractNote,
ensureClaudexMutatorGateSettings,
runClaudexProxyGate, runClaudexProxyGate,
launchClaudex, launchClaudex,
type ClaudexHarnessAdapter, type ClaudexHarnessAdapter,
@@ -36,12 +45,17 @@ function makeReport(overrides: Partial<PreflightReport> = {}): PreflightReport {
}; };
} }
function okAdapter(overrides: Partial<ClaudexHarnessAdapter> = {}): ClaudexHarnessAdapter { type TestAdapterOverrides = Partial<ClaudexHarnessAdapter> & {
exec?: (cmd: string, args: string[], env: NodeJS.ProcessEnv) => void;
};
function okAdapter(overrides: TestAdapterOverrides = {}): ClaudexHarnessAdapter {
const { exec, ...adapterOverrides } = overrides;
return { return {
harnessPreflight: () => {}, harnessPreflight: () => {},
composePrompt: () => '# Composed Claude contract', composePrompt: () => '# Composed Claude contract',
exec: () => {}, execLeaseGated: adapterOverrides.execLeaseGated ?? ((args, env) => exec?.('claude', args, env)),
...overrides, ...adapterOverrides,
}; };
} }
@@ -561,11 +575,51 @@ describe('runClaudexProxyGate', () => {
// ─── launch orchestration (fail-closed ordering) ────────────────────────────── // ─── 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', () => { describe('launchClaudex', () => {
const baseDeps = { const baseDeps = {
baseEnv: {}, baseEnv: {},
proxyGate: () => Promise.resolve({ ok: true, report: makeReport(), problems: [] }), proxyGate: () => Promise.resolve({ ok: true, report: makeReport(), problems: [] }),
resolveConfigDir: () => '/home/agent/.config/mosaic/claudex/home', resolveConfigDir: () => '/home/agent/.config/mosaic/claudex/home',
prepareConfig: () => {},
log: () => {}, log: () => {},
errorLog: () => {}, errorLog: () => {},
fail: (() => { fail: (() => {

View File

@@ -27,7 +27,14 @@
* unit-testable without spawning Claude Code or touching a real config dir. * 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 { homedir } from 'node:os';
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
import { import {
@@ -278,6 +285,77 @@ export function buildClaudexEnv(
return env; 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<string, unknown> {
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<string, unknown> = {};
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) ───────────────────────────────────────── // ─── EXPERIMENTAL classification (P4) ─────────────────────────────────────────
/** Console banner shown at launch. Contains no token material by construction. */ /** Console banner shown at launch. Contains no token material by construction. */
@@ -396,8 +474,8 @@ export interface ClaudexHarnessAdapter {
harnessPreflight: () => void; harnessPreflight: () => void;
/** Compose the full Claude runtime contract (== `composeContract('claude')`). */ /** Compose the full Claude runtime contract (== `composeContract('claude')`). */
composePrompt: () => string; composePrompt: () => string;
/** Replace the current process with `claude` using the composed env. */ /** Register the Claude parent with the broker, then exec with the same PID. */
exec: (cmd: string, args: string[], env: NodeJS.ProcessEnv) => void; execLeaseGated: (args: string[], env: NodeJS.ProcessEnv) => void;
} }
export interface LaunchClaudexDeps { export interface LaunchClaudexDeps {
@@ -406,6 +484,7 @@ export interface LaunchClaudexDeps {
resolveConfigDir?: () => string; resolveConfigDir?: () => string;
models?: () => ClaudexModels; models?: () => ClaudexModels;
buildEnv?: (base: NodeJS.ProcessEnv, opts: BuildClaudexEnvOptions) => NodeJS.ProcessEnv; buildEnv?: (base: NodeJS.ProcessEnv, opts: BuildClaudexEnvOptions) => NodeJS.ProcessEnv;
prepareConfig?: (configDir: string) => void;
log?: (message: string) => void; log?: (message: string) => void;
errorLog?: (message: string) => void; errorLog?: (message: string) => void;
fail?: (code: number) => never; fail?: (code: number) => never;
@@ -431,6 +510,7 @@ export async function launchClaudex(
const resolveConfigDir = deps.resolveConfigDir ?? (() => resolveClaudexConfigDir(baseEnv)); const resolveConfigDir = deps.resolveConfigDir ?? (() => resolveClaudexConfigDir(baseEnv));
const models = deps.models ?? (() => resolveClaudexModels(baseEnv)); const models = deps.models ?? (() => resolveClaudexModels(baseEnv));
const buildEnv = deps.buildEnv ?? buildClaudexEnv; const buildEnv = deps.buildEnv ?? buildClaudexEnv;
const prepareConfig = deps.prepareConfig ?? ensureClaudexMutatorGateSettings;
try { try {
// Harness readiness first (claude on PATH, mosaic home, sequential-thinking). // 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). // Compose the isolated launch env (guard throws → caught below, fail closed).
const resolvedModels = models(); const resolvedModels = models();
const configDir = resolveConfigDir(); const configDir = resolveConfigDir();
prepareConfig(configDir);
const env = buildEnv(baseEnv, { configDir, models: resolvedModels }); const env = buildEnv(baseEnv, { configDir, models: resolvedModels });
const prompt = `${adapter.composePrompt()}\n\n${buildClaudexContractNote(resolvedModels)}`; const prompt = `${adapter.composePrompt()}\n\n${buildClaudexContractNote(resolvedModels)}`;
@@ -454,7 +535,7 @@ export async function launchClaudex(
const cliArgs = yolo ? ['--dangerously-skip-permissions'] : []; const cliArgs = yolo ? ['--dangerously-skip-permissions'] : [];
cliArgs.push('--append-system-prompt', prompt, ...args); cliArgs.push('--append-system-prompt', prompt, ...args);
adapter.exec('claude', cliArgs, env); adapter.execLeaseGated(cliArgs, env);
} catch (err) { } catch (err) {
errorLog( errorLog(
`[mosaic] claudex launch aborted: ${err instanceof Error ? err.message : String(err)}`, `[mosaic] claudex launch aborted: ${err instanceof Error ? err.message : String(err)}`,

View File

@@ -814,12 +814,16 @@ function defaultLeaseBrokerSocket(env: NodeJS.ProcessEnv = process.env): string
return join('/run/user', String(uid), 'mosaic-lease', 'broker.sock'); 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'); const launcher = resolveTool('lease-broker', 'launch-runtime.py');
execRuntime('python3', [launcher, '--runtime', runtime, '--', runtime, ...args], { execRuntime('python3', [launcher, '--runtime', runtime, '--', runtime, ...args], {
...process.env, ...baseEnv,
MOSAIC_LEASE_BROKER_SOCKET: defaultLeaseBrokerSocket(), MOSAIC_LEASE_BROKER_SOCKET: defaultLeaseBrokerSocket(baseEnv),
MOSAIC_RUNTIME_GENERATION: process.env['MOSAIC_RUNTIME_GENERATION'] ?? '1', MOSAIC_RUNTIME_GENERATION: baseEnv['MOSAIC_RUNTIME_GENERATION'] ?? '1',
}); });
} }
@@ -856,7 +860,7 @@ function launchClaudexProduction(args: string[], yolo: boolean): void {
checkSequentialThinking('claude'); checkSequentialThinking('claude');
}, },
composePrompt: () => buildRuntimePrompt('claude'), composePrompt: () => buildRuntimePrompt('claude'),
exec: (cmd, cmdArgs, env) => execRuntime(cmd, cmdArgs, env), execLeaseGated: (cmdArgs, env) => execLeaseGatedRuntime('claude', cmdArgs, env),
}; };
void launchClaudex(args, yolo, adapter); void launchClaudex(args, yolo, adapter);
} }

View File

@@ -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 () => { test('observer revocation and monotonic TTL expiry deny the next mutator', async () => {
const { socket } = await startBroker(); const { socket } = await startBroker();
const sessionId = await register(socket); 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 = { const adapter = {
harnessPreflight: () => {}, harnessPreflight: () => {},
composePrompt: () => '# composed Claude contract', composePrompt: () => '# composed Claude contract',
// Pre-remediation Claudex uses this direct boundary and therefore bypasses registration. // Claudex exposes only the shared register-before-exec boundary.
exec: run,
// The remediated orchestration must select this shared register-before-exec boundary.
execLeaseGated: (args: string[], env: NodeJS.ProcessEnv) => execLeaseGated: (args: string[], env: NodeJS.ProcessEnv) =>
run('python3', [launcherPath, '--runtime', 'claude', '--', 'claude', ...args], env), run('python3', [launcherPath, '--runtime', 'claude', '--', 'claude', ...args], env),
} as unknown as ClaudexHarnessAdapter; } 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).toBeDefined();
expect(execution!.status, execution!.stderr).toBe(0); expect(execution!.status, String(execution!.stderr)).toBe(0);
expect(JSON.parse(execution!.stdout)).toEqual({ expect(JSON.parse(String(execution!.stdout))).toEqual({
session_id: expect.stringMatching(/^[a-f0-9]{64}$/), session_id: expect.stringMatching(/^[a-f0-9]{64}$/),
hook_present: true, hook_present: true,
denied: true, denied: true,