feat(mosaic): WI-2 mutator-class guard for directive-freshness (#837)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish Pipeline was successful

WI-2: mutator-class guard for the compaction directive-freshness mechanism — command-position parser (prefix x var-indirection unified) + primitive-anchored invariant backstop + all-tools-hook fail-close. Parser-complete on principle: realistic evasion matrix RED-regression-covered, residual exotic evasions proven backstop-caught (B-tests), lens-convergence reached.

closes #829
This commit was merged in pull request #837.
This commit is contained in:
2026-07-18 05:51:58 +00:00
parent 8ec67a1126
commit abd2791f59
27 changed files with 2579 additions and 46 deletions

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, dangerous: boolean) => 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,14 +527,14 @@ 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)}`;
log(buildClaudexBanner(resolvedModels));
const cliArgs = yolo ? ['--dangerously-skip-permissions'] : [];
cliArgs.push('--append-system-prompt', prompt, ...args);
adapter.exec('claude', cliArgs, env);
const cliArgs = ['--append-system-prompt', prompt, ...args];
adapter.execLeaseGated(cliArgs, env, yolo);
} catch (err) {
errorLog(
`[mosaic] claudex launch aborted: ${err instanceof Error ? err.message : String(err)}`,