diff --git a/docs/architecture/mutator-class-gate.md b/docs/architecture/mutator-class-gate.md index 2a64d6f..3624f19 100644 --- a/docs/architecture/mutator-class-gate.md +++ b/docs/architecture/mutator-class-gate.md @@ -39,9 +39,17 @@ The executable submits the runtime's actual tool name to `authorize_tool`. Missi Every repository-owned Claude/Pi launch entry converges on `launch-runtime.py`, either directly or through `mosaic` → `execLeaseGatedRuntime`. PRDY init/update and QA remediation invoke the wrapper directly so their existing prompts, dangerous-permission behavior, working directory, and environment survive without skipping broker registration. The raw Claude `--dangerously-skip-permissions` primitive is owned only by `launch-runtime.py`; callers request semantic `--dangerous` mode, and the wrapper validates Claude before injecting the primitive. `@mosaicstack/coord` rewrites direct Claude commands to `mosaic claude` and rejects unknown custom Claude launchers. -`check-runtime-launches.py` is the permanent completeness guard. It scans production shell, TypeScript/JavaScript, Python, and data launch definitions under `packages/`, `apps/`, `plugins/`, and `tools/`; direct literal, absolute-path, process-API, dynamic, command-substitution, `eval`, and variable-execution runtime launches fail. Shell comments are stripped with quote awareness, wrapper prefixes are tokenized with `shlex`, and only an invocation in command position with `--runtime` before the command separator is gated. A direct command always wins over an inert marker on the same line. Independently, the raw dangerous primitive anywhere outside the choke-point is RED. +`check-runtime-launches.py` is the permanent completeness guard. It scans production shell, TypeScript/JavaScript, Python, and data launch definitions under `packages/`, `apps/`, `plugins/`, and `tools/`; direct literal, absolute-path, process-API, dynamic, command-substitution, `eval`, and variable-execution runtime launches fail. Shell comments are stripped with quote awareness, wrapper prefixes are tokenized with `shlex`, and only an invocation in command position with `--runtime` before the command separator is gated. Literal and tracked-variable command tokens use one terminal resolver after any nesting of `exec`, `command`, `nohup`, or `env` plus assignments. A direct command always wins over an inert marker on the same line. Independently, the raw dangerous primitive anywhere outside the choke-point is RED. -Both checks are load-bearing: command-position analysis covers consequential launches that do not use dangerous mode, while primitive ownership cannot be fooled by a comment or marker string for dangerous mode. The guard is mandatory in `@mosaicstack/mosaic`'s test script, so root CI fails on a future bypass. Permanent regressions cover the original add-ungated effectiveness probe, comments, strings, assignments, heredocs, continuations, command chains, command substitution, `eval`, and variable execution; real-socket tests prove PRDY init/update and QA receive broker sessions and deny an unverified mutator. +The command parser is a best-effort CI defense, not a complete shell interpreter. Alias/function redefinition, sourced commands, generated scripts, and encoded pipelines are intentionally residual rather than an invitation to chase an unbounded shell language. Two runtime controls backstop that residual surface: primitive ownership rejects a dangerous launch even when command identity is alias-indirected, and Claude's global `.*` `PreToolUse` hook invokes the broker gate for non-dangerous launches. Without `MOSAIC_LEASE_SESSION_ID`, representative read, mutator, and custom/MCP tools all fail closed with `GATE_UNAVAILABLE`. Hook absence or replacement remains in the documented T-C boundary. + +### Parser stopping criterion + +- **A — realistic parser matrix:** comments, inert strings/assignments, heredocs, continuations, chained commands, command substitution, `eval`, bare tracked variables, and quoted/unquoted tracked variables behind `exec`, `command`, `nohup`, or `env` are permanent RED regressions. Prefix-variable forms are covered in both multiline and same-line assignment shapes. +- **B — residual backstops:** a dangerous alias-indirected launch is RED solely through primitive anchoring; a parser-missed non-dangerous alias launch is paired with an acceptance test proving the global all-tools hook denies every representative tool class as `GATE_UNAVAILABLE` without a lease. +- **C — independent fresh review:** the parser class is considered complete only when reviewers find no new non-overlapping realistic evasion on the exact head. A and B are repository evidence; C is supplied by the fresh review round. + +All three layers are load-bearing and complementary. The guard is mandatory in `@mosaicstack/mosaic`'s test script, so root CI fails on a future realistic bypass. Real-socket tests separately prove PRDY init/update and QA receive broker sessions and deny an unverified mutator. The live inventory is emitted by: diff --git a/docs/scratchpads/829-mutator-gate.md b/docs/scratchpads/829-mutator-gate.md index 5bcbbdc..2c9c1f3 100644 --- a/docs/scratchpads/829-mutator-gate.md +++ b/docs/scratchpads/829-mutator-gate.md @@ -121,3 +121,10 @@ Carried authority chain also verified/read for the locked T-B gate contract: SPE - Both independent gates converged on one guard-only completeness gap at round-4 head `1eb77c17f3147d4fa9944f77f1826243135b9cc0`; all round-4 primitive anchoring, command-position parsing, 14/14 inventory, broker ordering, and coverage remain locked-good. - Reproduced exactly: a temporary production source containing `launcher=claude` followed by `exec "$launcher" -p x` exits 0 with `0 gated / 0 total`. The literal command resolver skips prefixes but cannot resolve a tracked variable; the variable resolver handles only bare/eval references and cannot skip prefixes. - Round-5 plan: add 10 permanent RED forms (quoted/unquoted `exec`, `command`, `nohup`, and `env` with assignment, each multiline and same-line), then unify shell command-position resolution so literal and tracked-variable terminal tokens traverse the same prefix parser. Retain an independent variable-reference backstop, the 14/14 inventory, and every round-4 regression. +- Mos stopping-criterion augment: command parsing is explicitly best-effort rather than a complete shell interpreter. Add B1 proving a parser-exotic alias launch with the raw dangerous flag is still RED by primitive anchoring, and B2 proving a parser-missed non-dangerous alias launch reaches the global `.*` hook and fails closed with `GATE_UNAVAILABLE` when no lease session exists. Document A (realistic parser matrix) + B (robust residual backstops); fresh reviewers supply criterion C (no new non-overlapping finding). +- RED commit `91a4a983`: all 10 prefix×variable cases failed as expected before the fix—quoted/unquoted `exec`, `command`, `nohup`, and `env A=1`, each in multiline and same-line assignment shapes. +- GREEN structural resolution: `shell_command_tokens()` now owns command-position prefix skipping for both literal and variable callers, including nested `exec`/`command`/`nohup`/`env` ordering. `runtime_variables` is threaded into `is_shell_direct_invocation()` and the same terminal-token resolver backs `executes_runtime_variable()`; exact `$v` and `${v}` references are resolved after `shlex` removes quoting. Same-line runtime assignment delimiters include shell operators. +- Residual backstops: B1 proves alias-indirected dangerous mode is classified `dangerous-primitive` even though the parser does not resolve the alias. B2 proves a non-dangerous alias residual remains parser-missed, then verifies the shipped global `.*` Claude hook and status-2 `GATE_UNAVAILABLE` denial for representative read, mutator, and custom/MCP tools without a lease session. +- Stopping-criterion evidence A+B is committed in tests and architecture docs; C remains the fresh exact-head terra/Opus determination. Repository inventory remains exactly **14 gated / 14 total**. +- Fresh round-5 coverage: permanent guard remains **97%** branch-aware aggregate (251 statements, 112 branches). Locked-good launcher and mutator-gate executables remain unchanged at their round-4 **100% / 100%** evidence. +- Fresh round-5 gates: real-socket acceptance **50/50**; persistence **10/10**; launcher/gate **14/14**; guard **12/12**; coord **19/19**; Mosaic **1385/1385**; root **43/43**; typecheck **42/42**; lint **23/23**; format and diff checks green. diff --git a/packages/mosaic/framework/tools/lease-broker/check-runtime-launches.py b/packages/mosaic/framework/tools/lease-broker/check-runtime-launches.py index 7888e21..68392c9 100644 --- a/packages/mosaic/framework/tools/lease-broker/check-runtime-launches.py +++ b/packages/mosaic/framework/tools/lease-broker/check-runtime-launches.py @@ -79,7 +79,7 @@ DIRECT_PATTERNS: Final = ( re.compile(r"\beval\s+[\"'](?:claude|pi)(?:\s|[\"'])"), ) RUNTIME_ASSIGNMENT: Final = re.compile( - r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*[\"']?(?:claude|pi)(?:\s|[\"']|$)" + r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*[\"']?(?:claude|pi)(?:\s|[\"']|[;&|]|$)" ) TYPESCRIPT_WRAPPER_INVOCATION: Final = re.compile( r"(?m)^\s*execRuntime\s*\(\s*[\"']python3[\"']\s*,\s*" @@ -226,44 +226,71 @@ def is_gated_line(path: Path, line: str) -> bool: ) -def is_shell_direct_invocation(path: Path, line: str) -> bool: +def shell_command_tokens(path: Path, line: str) -> list[str]: if path.suffix.lower() not in SHELL_SUFFIXES: - return False + return [] assignment = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=") case_arm = re.match(r"^\s*[^;&()]+\)\s*", line) command_source = line[case_arm.end() :] if case_arm is not None else line + resolved: list[str] = [] for command in shell_commands(command_source): index = 0 while index < len(command) and assignment.match(command[index]): index += 1 - while index < len(command) and command[index] in {"command", "exec", "nohup"}: - index += 1 - if index < len(command) and command[index] == "env": - index += 1 - while index < len(command) and ( - command[index].startswith("-") or assignment.match(command[index]) - ): + # Resolve command position once for every literal and variable caller. + # Prefixes may be nested in either order (for example `exec env A=1`). + while index < len(command): + if command[index] in {"command", "exec", "nohup"}: index += 1 - while index < len(command) and command[index] in {"command", "exec", "nohup"}: + continue + if command[index] == "env": index += 1 - if index < len(command) and Path(command[index]).name in {"claude", "pi"}: - return True - return False + while index < len(command) and ( + command[index].startswith("-") or assignment.match(command[index]) + ): + index += 1 + continue + break + if index < len(command): + resolved.append(command[index]) + return resolved -def is_direct_line(path: Path, line: str) -> bool: - return is_shell_direct_invocation(path, line) or any( +def runtime_variable_reference(token: str, runtime_variables: set[str]) -> bool: + match = re.fullmatch(r"\$(?:([A-Za-z_][A-Za-z0-9_]*)|\{([A-Za-z_][A-Za-z0-9_]*)\})", token) + if match is None: + return False + return (match.group(1) or match.group(2)) in runtime_variables + + +def is_shell_direct_invocation( + path: Path, line: str, runtime_variables: set[str] +) -> bool: + return any( + Path(token).name in {"claude", "pi"} + or runtime_variable_reference(token, runtime_variables) + for token in shell_command_tokens(path, line) + ) + + +def is_direct_line(path: Path, line: str, runtime_variables: set[str]) -> bool: + return is_shell_direct_invocation(path, line, runtime_variables) or any( pattern.search(line) for pattern in DIRECT_PATTERNS ) -def executes_runtime_variable(line: str, runtime_variables: set[str]) -> bool: +def executes_runtime_variable( + path: Path, line: str, runtime_variables: set[str] +) -> bool: + if any( + runtime_variable_reference(token, runtime_variables) + for token in shell_command_tokens(path, line) + ): + return True for variable in runtime_variables: reference = rf"\$(?:{re.escape(variable)}|\{{{re.escape(variable)}\}})" if re.search(rf"\beval\s+[\"']?{reference}", line): return True - if re.match(rf"^\s*[\"']?{reference}[\"']?(?:\s|$)", line): - return True return False @@ -293,8 +320,8 @@ def classify_text(path: Path, source: str) -> list[LaunchSite]: if assignment is not None: runtime_variables.add(assignment.group(1)) primitive_violation = DANGEROUS_PRIMITIVE in line and not is_choke_point(path) - line_is_direct = is_direct_line(path, line) or executes_runtime_variable( - line, runtime_variables + line_is_direct = is_direct_line(path, line, runtime_variables) or executes_runtime_variable( + path, line, runtime_variables ) line_is_gated = line_number in validated_typescript_wrappers or is_gated_line(path, line) 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 3419cbd..932eab1 100644 --- a/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts +++ b/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts @@ -24,6 +24,7 @@ interface BrokerPaths { const frameworkRoot = new URL('../../framework/', import.meta.url).pathname; const daemonPath = join(frameworkRoot, 'tools/lease-broker/daemon.py'); const gatePath = join(frameworkRoot, 'tools/lease-broker/mutator-gate.py'); +const launchGuardPath = join(frameworkRoot, 'tools/lease-broker/check-runtime-launches.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'); @@ -282,6 +283,39 @@ describe('whole mutator-class lease gate', () => { }); }); + test('non-dangerous parser residual is denied by the global all-tools hook without a lease', async () => { + const root = await mkdtemp(join(tmpdir(), 'mosaic-parser-residual-')); + temporaryRoots.push(root); + const source = join(root, 'packages/probe/launch.sh'); + await mkdir(join(root, 'packages/probe'), { recursive: true }); + await writeFile(source, 'alias hidden_runtime=claude\nhidden_runtime -p x\n'); + + const parserResult = spawnSync('python3', [launchGuardPath, '--root', root, '--json'], { + encoding: 'utf8', + }); + expect(parserResult.status).toBe(0); + expect(JSON.parse(parserResult.stdout)).toMatchObject({ gated: 0, total: 0 }); + + const settings = JSON.parse(await readFile(claudeSettingsPath, 'utf8')) as { + hooks: { PreToolUse: Array<{ matcher: string; hooks: Array<{ command: string }> }> }; + }; + const allToolsHook = settings.hooks.PreToolUse.find((hook) => hook.matcher === '.*'); + expect(allToolsHook?.hooks[0]?.command).toContain('mutator-gate.py --runtime claude'); + + const environment = { ...process.env }; + delete environment['MOSAIC_LEASE_SESSION_ID']; + delete environment['MOSAIC_LEASE_BROKER_SOCKET']; + for (const toolName of ['Bash', 'Read', 'mcp__provider__custom']) { + const gateResult = spawnSync('python3', [gatePath, '--runtime', 'claude'], { + input: `${JSON.stringify({ tool_name: toolName })}\n`, + encoding: 'utf8', + env: environment, + }); + expect(gateResult.status, toolName).toBe(2); + expect(gateResult.stderr, toolName).toContain('GATE_UNAVAILABLE'); + } + }); + test('T-B raw and custom mutator tools are default-denied without shell parsing', async () => { const { socket } = await startBroker(); const sessionId = await register(socket); diff --git a/packages/mosaic/src/mutator-gate/runtime_launch_guard_unittest.py b/packages/mosaic/src/mutator-gate/runtime_launch_guard_unittest.py index 4e8a372..f84c81b 100644 --- a/packages/mosaic/src/mutator-gate/runtime_launch_guard_unittest.py +++ b/packages/mosaic/src/mutator-gate/runtime_launch_guard_unittest.py @@ -114,6 +114,15 @@ class RuntimeLaunchGuardTest(unittest.TestCase): with self.subTest(filename=filename): self.assertEqual(GUARD.classify_text(Path(filename), source), []) + def test_dangerous_primitive_backstops_parser_exotic_alias_indirection(self) -> None: + source = ( + "alias hidden_runtime=claude\n" + "hidden_runtime --dangerously-skip-permissions -p x\n" + ) + sites = GUARD.scan_text(Path("alias-launch.sh"), source) + self.assertEqual(len(sites), 1) + self.assertEqual(sites[0].classification, "dangerous-primitive") + def test_dangerous_primitive_is_owned_only_by_the_choke_point(self) -> None: primitive = "--dangerously-skip-permissions" self.assertNotEqual(GUARD.scan_text(Path("caller.ts"), f"args = ['{primitive}'];\n"), [])