diff --git a/docs/scratchpads/829-mutator-gate.md b/docs/scratchpads/829-mutator-gate.md index 19b7279..0dfede8 100644 --- a/docs/scratchpads/829-mutator-gate.md +++ b/docs/scratchpads/829-mutator-gate.md @@ -82,3 +82,10 @@ Carried authority chain also verified/read for the locked T-B gate contract: SPE - Fresh full repository suite: `43/43` Turbo tasks; `@mosaicstack/mosaic` `72/72` files and `1,381/1,381` tests. - Fresh root gates: typecheck `42/42`; lint `23/23`; format and `git diff --check` GREEN. - PR #837 remains open and unmerged. Terra CODE and Opus SECREV must both rerun from zero on the exact remediated head before coordinator-owned merge authorization. + +## Remediation round 3 — terra CODE comment 18099 + binding upgrade + +- Locked-good surfaces: Claudex gating and B2 per-executable coverage are verified; do not regress them. Broker state-transition/lock ordering remains untouched. +- Mechanical repository sweep found direct executing Claude entries in PRDY init, PRDY update, QA remediation, and `@mosaicstack/coord` task launch. It also found a direct Claude command rendered into the QA report template and documentation examples. Existing Mosaic CLI Claude/Pi/Claudex, orchestrator session-run, and fleet starts already reach the gated boundary. +- Elevated hard requirements: ship a permanent suite/CI guard that scans production source and fails on any direct Claude/Pi launch; route every executing entry through one common gated wrapper; add real-broker RED/GREEN tests for PRDY init/update and QA; preserve each environment and denial behavior; independently measure all new executable coverage at ≥85%. +- Round-3 plan: first commit RED behavioral and scanner-contract tests; then add one framework `launch-runtime.sh` choke-point over `launch-runtime.py`, make Mosaic CLI and shell launchers use it, make coord route through `mosaic`, and wire the permanent guard into package tests. Update all discovered operator-facing direct-launch examples so the scanner inventory remains complete. diff --git a/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts b/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts index 78ca520..903900f 100644 --- a/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts +++ b/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts @@ -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 { return { socket }; } +interface RuntimeLaunchEntry { + name: string; + script: string; + prepare(root: string): Promise; +} + +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 { 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 }, diff --git a/packages/mosaic/src/mutator-gate/runtime_launch_guard_unittest.py b/packages/mosaic/src/mutator-gate/runtime_launch_guard_unittest.py new file mode 100644 index 0000000..7be7389 --- /dev/null +++ b/packages/mosaic/src/mutator-gate/runtime_launch_guard_unittest.py @@ -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()