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

@@ -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__":

View File

@@ -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",

View File

@@ -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> = {}): 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 {
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: (() => {

View File

@@ -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<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) ─────────────────────────────────────────
/** 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)}`,

View File

@@ -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);
}

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 () => {
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,