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:
@@ -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,20 @@ 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, dangerous) =>
|
||||
exec?.('claude', dangerous ? ['--dangerously-skip-permissions', ...args] : args, env)),
|
||||
...adapterOverrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -561,11 +578,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: (() => {
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -115,7 +115,7 @@ function auditClaudeSettings(): SettingsAudit {
|
||||
// Check required hooks
|
||||
const hooks = settings['hooks'] as Record<string, unknown[]> | undefined;
|
||||
|
||||
const requiredPreToolUse = ['prevent-memory-write.sh'];
|
||||
const requiredPreToolUse = ['mutator-gate.py', 'prevent-memory-write.sh'];
|
||||
const requiredPostToolUse = ['qa-hook-stdin.sh', 'typecheck-hook.sh'];
|
||||
|
||||
const preHooks = (hooks?.['PreToolUse'] ?? []) as Array<Record<string, unknown>>;
|
||||
@@ -755,7 +755,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
||||
printSettingsWarnings(settingsAudit);
|
||||
|
||||
const prompt = buildRuntimePrompt('claude');
|
||||
const cliArgs = yolo ? ['--dangerously-skip-permissions'] : [];
|
||||
const cliArgs: string[] = [];
|
||||
cliArgs.push('--append-system-prompt', prompt);
|
||||
if (hasMissionNoArgs) {
|
||||
cliArgs.push(missionPrompt);
|
||||
@@ -763,7 +763,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
||||
cliArgs.push(...args);
|
||||
}
|
||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||
execRuntime('claude', cliArgs);
|
||||
execLeaseGatedRuntime('claude', cliArgs, process.env, yolo);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -798,7 +798,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
||||
cliArgs.push(...args);
|
||||
}
|
||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||
execRuntime('pi', cliArgs);
|
||||
execLeaseGatedRuntime('pi', cliArgs);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -806,6 +806,33 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
||||
process.exit(0); // Unreachable but satisfies never
|
||||
}
|
||||
|
||||
function defaultLeaseBrokerSocket(env: NodeJS.ProcessEnv = process.env): string {
|
||||
if (env['MOSAIC_LEASE_BROKER_SOCKET']) return env['MOSAIC_LEASE_BROKER_SOCKET'];
|
||||
const runtimeDir = env['XDG_RUNTIME_DIR'];
|
||||
if (runtimeDir) return join(runtimeDir, 'mosaic-lease', 'broker.sock');
|
||||
const uid = typeof process.getuid === 'function' ? process.getuid() : 0;
|
||||
return join('/run/user', String(uid), 'mosaic-lease', 'broker.sock');
|
||||
}
|
||||
|
||||
function execLeaseGatedRuntime(
|
||||
runtime: 'claude' | 'pi',
|
||||
args: string[],
|
||||
baseEnv: NodeJS.ProcessEnv = process.env,
|
||||
dangerous = false,
|
||||
): void {
|
||||
const launcher = resolveTool('lease-broker', 'launch-runtime.py');
|
||||
const dangerousArgs = dangerous ? ['--dangerous'] : [];
|
||||
execRuntime(
|
||||
'python3',
|
||||
[launcher, ...dangerousArgs, '--runtime', runtime, '--', runtime, ...args],
|
||||
{
|
||||
...baseEnv,
|
||||
MOSAIC_LEASE_BROKER_SOCKET: defaultLeaseBrokerSocket(baseEnv),
|
||||
MOSAIC_RUNTIME_GENERATION: baseEnv['MOSAIC_RUNTIME_GENERATION'] ?? '1',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** exec into the runtime, replacing the current process. */
|
||||
function execRuntime(cmd: string, args: string[], env: NodeJS.ProcessEnv = process.env): void {
|
||||
try {
|
||||
@@ -839,7 +866,8 @@ function launchClaudexProduction(args: string[], yolo: boolean): void {
|
||||
checkSequentialThinking('claude');
|
||||
},
|
||||
composePrompt: () => buildRuntimePrompt('claude'),
|
||||
exec: (cmd, cmdArgs, env) => execRuntime(cmd, cmdArgs, env),
|
||||
execLeaseGated: (cmdArgs, env, dangerous) =>
|
||||
execLeaseGatedRuntime('claude', cmdArgs, env, dangerous),
|
||||
};
|
||||
void launchClaudex(args, yolo, adapter);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user