feat(mosaic): WI-2 mutator-class guard for directive-freshness (#837)
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:
@@ -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)}`,
|
||||
|
||||
Reference in New Issue
Block a user