test(#829): regress claudex bypass and per-tool coverage gaps
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { chmod, mkdtemp, readFile } from 'node:fs/promises';
|
||||
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { createConnection } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
@@ -6,6 +6,8 @@ import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
|
||||
|
||||
import { afterEach, describe, expect, test } from 'vitest';
|
||||
|
||||
import { launchClaudex, type ClaudexHarnessAdapter } from '../commands/claudex.js';
|
||||
|
||||
interface BrokerReply {
|
||||
ok: boolean;
|
||||
code?: string;
|
||||
@@ -26,6 +28,7 @@ const launcherPath = join(frameworkRoot, 'tools/lease-broker/launch-runtime.py')
|
||||
const claudeSettingsPath = join(frameworkRoot, 'runtime/claude/settings.json');
|
||||
const piExtensionPath = join(frameworkRoot, 'runtime/pi/mosaic-extension.ts');
|
||||
const children: ChildProcess[] = [];
|
||||
const temporaryRoots: string[] = [];
|
||||
|
||||
const binding = (compaction_epoch = 1) => ({
|
||||
compaction_epoch,
|
||||
@@ -148,8 +151,11 @@ function runRuntimeGate(
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
for (const child of children.splice(0)) child.kill('SIGTERM');
|
||||
await Promise.all(
|
||||
temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
describe('whole mutator-class lease gate', () => {
|
||||
@@ -334,6 +340,114 @@ describe('whole mutator-class lease gate', () => {
|
||||
expect(unavailable.stdout).not.toContain('EXECUTED');
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ command: 'mosaic claudex', yolo: false },
|
||||
{ command: 'mosaic yolo claudex', yolo: true },
|
||||
])(
|
||||
'$command registers a broker anchor, installs the all-tools hook, and denies an unverified mutator',
|
||||
async ({ yolo }) => {
|
||||
const { socket } = await startBroker();
|
||||
const root = await mkdtemp(join(tmpdir(), 'mosaic-claudex-gate-'));
|
||||
temporaryRoots.push(root);
|
||||
const configDir = join(root, 'isolated-claude');
|
||||
const binDir = join(root, 'bin');
|
||||
await mkdir(configDir, { recursive: true });
|
||||
await mkdir(binDir, { recursive: true });
|
||||
|
||||
const fakeClaude = join(binDir, 'claude');
|
||||
const probe = `#!/usr/bin/env python3
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
session_id = os.environ.get("MOSAIC_LEASE_SESSION_ID", "")
|
||||
settings_path = Path(os.environ["CLAUDE_CONFIG_DIR"]) / "settings.json"
|
||||
try:
|
||||
settings = json.loads(settings_path.read_text())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
settings = {}
|
||||
pre_tool = settings.get("hooks", {}).get("PreToolUse", [])
|
||||
hook_present = any(
|
||||
item.get("matcher") == ".*" and any("mutator-gate.py" in hook.get("command", "") for hook in item.get("hooks", []))
|
||||
for item in pre_tool
|
||||
)
|
||||
denied = subprocess.run(
|
||||
["python3", ${JSON.stringify(gatePath)}, "--runtime", "claude"],
|
||||
input=json.dumps({"tool_name": "Bash"}) + "\\n",
|
||||
text=True,
|
||||
capture_output=True,
|
||||
env=os.environ,
|
||||
).returncode == 2
|
||||
is_yolo = "--dangerously-skip-permissions" in sys.argv[1:]
|
||||
result = {
|
||||
"session_id": session_id,
|
||||
"hook_present": hook_present,
|
||||
"denied": denied,
|
||||
"is_yolo": is_yolo,
|
||||
}
|
||||
print(json.dumps(result))
|
||||
raise SystemExit(0 if len(session_id) == 64 and hook_present and denied else 1)
|
||||
`;
|
||||
await writeFile(fakeClaude, probe, { mode: 0o700 });
|
||||
await chmod(fakeClaude, 0o700);
|
||||
|
||||
let execution: ReturnType<typeof spawnSync> | undefined;
|
||||
const run = (cmd: string, args: string[], env: NodeJS.ProcessEnv) => {
|
||||
execution = spawnSync(cmd, args, { encoding: 'utf8', env });
|
||||
};
|
||||
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.
|
||||
execLeaseGated: (args: string[], env: NodeJS.ProcessEnv) =>
|
||||
run('python3', [launcherPath, '--runtime', 'claude', '--', 'claude', ...args], env),
|
||||
} as unknown as ClaudexHarnessAdapter;
|
||||
|
||||
await launchClaudex([], yolo, adapter, {
|
||||
baseEnv: {
|
||||
...process.env,
|
||||
PATH: `${binDir}:${process.env.PATH ?? ''}`,
|
||||
MOSAIC_LEASE_BROKER_SOCKET: socket,
|
||||
MOSAIC_RUNTIME_GENERATION: '1',
|
||||
},
|
||||
proxyGate: () =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
report: {
|
||||
binaryPresent: true,
|
||||
binaryPath: '/test/claude-code-proxy',
|
||||
auth: { state: 'valid' },
|
||||
live: true,
|
||||
listenerVerdict: 'ok',
|
||||
needsReauth: false,
|
||||
ok: true,
|
||||
problems: [],
|
||||
},
|
||||
problems: [],
|
||||
}),
|
||||
resolveConfigDir: () => configDir,
|
||||
log: () => {},
|
||||
errorLog: () => {},
|
||||
fail: ((code: number) => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as (code: number) => never,
|
||||
});
|
||||
|
||||
expect(execution).toBeDefined();
|
||||
expect(execution!.status, execution!.stderr).toBe(0);
|
||||
expect(JSON.parse(execution!.stdout)).toEqual({
|
||||
session_id: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
hook_present: true,
|
||||
denied: true,
|
||||
is_yolo: yolo,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test('Claude and Pi runtime adapters consult the broker for every tool class', async () => {
|
||||
const { socket } = await startBroker();
|
||||
const sessionId = await register(socket);
|
||||
|
||||
Reference in New Issue
Block a user