test(#829): regress ungated runtime launch entries
This commit is contained in:
@@ -27,6 +27,9 @@ const gatePath = join(frameworkRoot, 'tools/lease-broker/mutator-gate.py');
|
||||
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 prdyInitPath = join(frameworkRoot, 'tools/prdy/prdy-init.sh');
|
||||
const prdyUpdatePath = join(frameworkRoot, 'tools/prdy/prdy-update.sh');
|
||||
const remediationHandlerPath = join(frameworkRoot, 'tools/qa/remediation-hook-handler.sh');
|
||||
const children: ChildProcess[] = [];
|
||||
const temporaryRoots: string[] = [];
|
||||
|
||||
@@ -77,6 +80,82 @@ async function startBroker(): Promise<BrokerPaths> {
|
||||
return { socket };
|
||||
}
|
||||
|
||||
interface RuntimeLaunchEntry {
|
||||
name: string;
|
||||
script: string;
|
||||
prepare(root: string): Promise<string[]>;
|
||||
}
|
||||
|
||||
const runtimeLaunchEntries: RuntimeLaunchEntry[] = [
|
||||
{
|
||||
name: 'prdy-init',
|
||||
script: prdyInitPath,
|
||||
prepare: async (root) => ['--project', root, '--name', 'Gate Test'],
|
||||
},
|
||||
{
|
||||
name: 'prdy-update',
|
||||
script: prdyUpdatePath,
|
||||
prepare: async (root) => {
|
||||
await mkdir(join(root, 'docs'), { recursive: true });
|
||||
await writeFile(join(root, 'docs/PRD.md'), '# Existing PRD\n');
|
||||
return ['--project', root];
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'qa-remediation',
|
||||
script: remediationHandlerPath,
|
||||
prepare: async (root) => {
|
||||
const pending = join(root, 'reports/pending');
|
||||
await mkdir(pending, { recursive: true });
|
||||
const report = join(pending, 'gate_remediation_needed.md');
|
||||
await writeFile(report, '# remediation\n');
|
||||
return [report];
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
async function runRuntimeLaunchEntry(entry: RuntimeLaunchEntry, socket: string) {
|
||||
const root = await mkdtemp(join(tmpdir(), `mosaic-${entry.name}-gate-`));
|
||||
temporaryRoots.push(root);
|
||||
const binDir = join(root, 'bin');
|
||||
await mkdir(binDir, { recursive: true });
|
||||
const fakeClaude = join(binDir, 'claude');
|
||||
await writeFile(
|
||||
fakeClaude,
|
||||
`#!/usr/bin/env python3
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
session_id = os.environ.get("MOSAIC_LEASE_SESSION_ID", "")
|
||||
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
|
||||
print("RUNTIME_PROBE=" + json.dumps({"session_id": session_id, "denied": denied}))
|
||||
raise SystemExit(0 if len(session_id) == 64 and denied else 1)
|
||||
`,
|
||||
{ mode: 0o700 },
|
||||
);
|
||||
await chmod(fakeClaude, 0o700);
|
||||
const args = await entry.prepare(root);
|
||||
return spawnSync('bash', [entry.script, ...args], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${binDir}:${process.env.PATH ?? ''}`,
|
||||
MOSAIC_HOME: frameworkRoot,
|
||||
MOSAIC_PRDY_RUNTIME: 'claude',
|
||||
MOSAIC_LEASE_BROKER_SOCKET: socket,
|
||||
MOSAIC_RUNTIME_GENERATION: '1',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function register(socket: string, runtime_generation = 1): Promise<string> {
|
||||
const reply = await request(socket, { action: 'register_anchor', runtime_generation });
|
||||
expect(reply.ok).toBe(true);
|
||||
@@ -386,6 +465,26 @@ describe('whole mutator-class lease gate', () => {
|
||||
expect(unavailable.stdout).not.toContain('EXECUTED');
|
||||
});
|
||||
|
||||
test.each(runtimeLaunchEntries)(
|
||||
'$name registers before launch, denies an unverified mutator, and fails closed without broker',
|
||||
async (entry) => {
|
||||
const missingSocket = join(tmpdir(), `missing-${entry.name}-${process.pid}.sock`);
|
||||
const unavailable = await runRuntimeLaunchEntry(entry, missingSocket);
|
||||
expect(unavailable.status).not.toBe(0);
|
||||
expect(`${unavailable.stdout}${unavailable.stderr}`).not.toContain('RUNTIME_PROBE=');
|
||||
|
||||
const { socket } = await startBroker();
|
||||
const launched = await runRuntimeLaunchEntry(entry, socket);
|
||||
expect(launched.status, launched.stderr).toBe(0);
|
||||
const match = /RUNTIME_PROBE=(\{[^\n]+\})/.exec(`${launched.stdout}${launched.stderr}`);
|
||||
expect(match).not.toBeNull();
|
||||
expect(JSON.parse(match![1]!)).toEqual({
|
||||
session_id: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
denied: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
{ command: 'mosaic claudex', yolo: false },
|
||||
{ command: 'mosaic yolo claudex', yolo: true },
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Contract tests for the permanent consequential-runtime launch guard."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MOSAIC_ROOT = Path(__file__).parents[2]
|
||||
REPO_ROOT = Path(__file__).parents[4]
|
||||
GUARD_PATH = MOSAIC_ROOT / "framework/tools/lease-broker/check-runtime-launches.py"
|
||||
SPEC = importlib.util.spec_from_file_location("runtime_launch_guard", GUARD_PATH)
|
||||
if SPEC is None or SPEC.loader is None:
|
||||
raise RuntimeError("unable to load runtime launch guard")
|
||||
GUARD = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(GUARD)
|
||||
|
||||
|
||||
class RuntimeLaunchGuardTest(unittest.TestCase):
|
||||
def test_detects_direct_shell_and_process_api_launches(self) -> None:
|
||||
cases = {
|
||||
"shell-exec.sh": 'exec claude --dangerously-skip-permissions "prompt"\n',
|
||||
"shell-print.sh": 'claude -p "prompt" | tee report.log\n',
|
||||
"typescript.ts": "spawn('pi', ['--print', prompt]);\n",
|
||||
"python.py": "subprocess.run(['claude', '-p', prompt])\n",
|
||||
"dynamic.ts": "return [runtime, '-p', prompt];\n",
|
||||
}
|
||||
for filename, source in cases.items():
|
||||
with self.subTest(filename=filename):
|
||||
violations = GUARD.scan_text(Path(filename), source)
|
||||
self.assertNotEqual(violations, [], source)
|
||||
|
||||
def test_allows_only_explicit_gated_boundaries(self) -> None:
|
||||
cases = {
|
||||
"shell-helper.sh": 'exec "$GATED_RUNTIME" claude -- claude -p "prompt"\n',
|
||||
"mosaic.sh": 'exec mosaic yolo "$runtime" "prompt"\n',
|
||||
"launch.ts": "execLeaseGatedRuntime('claude', args);\n",
|
||||
"coord.ts": "return ['mosaic', runtime, '-p', prompt];\n",
|
||||
}
|
||||
for filename, source in cases.items():
|
||||
with self.subTest(filename=filename):
|
||||
self.assertEqual(GUARD.scan_text(Path(filename), source), [])
|
||||
|
||||
def test_repository_has_no_ungated_consequential_runtime_launch(self) -> None:
|
||||
violations = GUARD.scan_repository(REPO_ROOT)
|
||||
self.assertEqual(
|
||||
violations,
|
||||
[],
|
||||
"\n".join(GUARD.format_violation(violation) for violation in violations),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user