From 1eb77c17f3147d4fa9944f77f1826243135b9cc0 Mon Sep 17 00:00:00 2001 From: ms-lead-reviewer Date: Sat, 18 Jul 2026 00:17:50 -0500 Subject: [PATCH] fix(#829): harden runtime launch guard against marker evasions --- docs/architecture/mutator-class-gate.md | 6 +- docs/scratchpads/829-mutator-gate.md | 5 + .../lease-broker/check-runtime-launches.py | 272 +++++++++++++++--- .../tools/lease-broker/launch-runtime.py | 7 + .../mosaic/framework/tools/prdy/prdy-init.sh | 4 +- .../framework/tools/prdy/prdy-update.sh | 4 +- packages/mosaic/src/commands/claudex.spec.ts | 5 +- packages/mosaic/src/commands/claudex.ts | 7 +- packages/mosaic/src/commands/launch.ts | 23 +- .../mutator-gate.acceptance.spec.ts | 16 +- .../runtime_launch_guard_unittest.py | 29 +- 11 files changed, 316 insertions(+), 62 deletions(-) diff --git a/docs/architecture/mutator-class-gate.md b/docs/architecture/mutator-class-gate.md index 2ef63f2..2a64d6f 100644 --- a/docs/architecture/mutator-class-gate.md +++ b/docs/architecture/mutator-class-gate.md @@ -37,9 +37,11 @@ The executable submits the runtime's actual tool name to `authorize_tool`. Missi ## Runtime-launch choke-point and permanent guard -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 flags, working directory, and environment survive without skipping broker registration. `@mosaicstack/coord` rewrites direct Claude commands to `mosaic claude` and rejects unknown custom Claude launchers. +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, and dynamic runtime launches fail. It is mandatory in `@mosaicstack/mosaic`'s test script, so root CI fails when a future entry does not name the wrapper, `execLeaseGatedRuntime`, or a gated `mosaic` launch. Synthetic scanner tests prove representative bypass forms are rejected, while real-socket tests prove PRDY init/update and QA receive broker sessions and deny an unverified mutator. +`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. + +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 live inventory is emitted by: diff --git a/docs/scratchpads/829-mutator-gate.md b/docs/scratchpads/829-mutator-gate.md index b5af096..858705d 100644 --- a/docs/scratchpads/829-mutator-gate.md +++ b/docs/scratchpads/829-mutator-gate.md @@ -110,3 +110,8 @@ Carried authority chain also verified/read for the locked T-B gate contract: SPE - Round-4 plan: add permanent RED cases for the exact comment evasion plus string-argument, echo, and unrelated-variable marker evasions; tokenize/strip comments by launcher syntax; recognize only command-position wrapper invocations with `--runtime` and the gated command separator; retain 14/14 real inventory; rerun guard coverage and all gates fresh. - Mos Rider A/B decision: adopt **both** defenses. Command-position parsing remains necessary because a normal `claude -p` launch is consequential even without the dangerous flag. The primitive-location invariant is more mechanically robust for dangerous mode because it does not need to recognize a wrapper marker at all. Move the sole raw `--dangerously-skip-permissions` literal into `launch-runtime.py`; any occurrence in another production file is independently RED. - Rider-A RED matrix adds heredoc body, backslash continuation, non-first `;`/`&&`/pipe commands, command substitution, `eval`, and variable-execution indirection in addition to the six marker/comment evasions. Before the augmented implementation, primitive ownership, command substitution, `eval`, variable execution, and the preserved 14-site inventory all fail. +- Round-4 GREEN uses both defenses. Quote-aware comment stripping removes shell/Python `#` and JS/TS line/block comments; shell command prefixes are segmented with `shlex`; validated wrappers require `launch-runtime.py` in command position, `--runtime`, and the `--` command separator; multiline TypeScript wrapper calls are validated as complete invocations. Direct command syntax wins over markers, while tracked runtime assignments plus `eval`/variable execution, command substitution, chained commands, heredocs, continuations, and `env`/`command`/`nohup` prefixes are rejected. +- Primitive ownership is independently load-bearing: `launch-runtime.py` is the sole production owner of the raw Claude dangerous flag. Mosaic, Claudex, and PRDY request semantic `--dangerous`; the wrapper validates Claude and injects the primitive immediately before register/exec. This preserves actual YOLO argv behavior while making any raw primitive elsewhere fail without relying on wrapper-name recognition. +- Permanent guard suite now has 10 tests and 31 direct-launch forms, including 18 new round-4 marker/comment/indirection/prefix evasions plus harmless-marker and multiline-wrapper controls. Terra's exact add-ungated source is exercised through the CLI effectiveness test. Repository inventory remains exactly **14 gated / 14 total**. +- Fresh round-4 coverage: guard **97%** branch-aware aggregate (241 statements, 110 branches); `launch-runtime.py` **100%**; `mutator-gate.py` **100%**. The daemon is byte-unchanged from the round-3 head whose WI-2 delta is **87.5%**. +- Fresh round-4 gates: real-socket acceptance **49/49**; persistence **10/10**; launcher/gate **14/14**; guard **10/10**; coord **19/19**; Mosaic **1384/1384**; 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 e7f4248..7888e21 100644 --- a/packages/mosaic/framework/tools/lease-broker/check-runtime-launches.py +++ b/packages/mosaic/framework/tools/lease-broker/check-runtime-launches.py @@ -6,6 +6,7 @@ from __future__ import annotations import argparse import json import re +import shlex import sys from pathlib import Path from typing import Final, NamedTuple, Sequence @@ -33,15 +34,22 @@ SKIPPED_DIRECTORIES: Final = { "dist", "node_modules", } +DANGEROUS_PRIMITIVE: Final = "--dangerously-" + "skip-permissions" +CHOKE_POINT_SUFFIX: Final = "framework/tools/lease-broker/launch-runtime.py" +SHELL_SUFFIXES: Final = {".bash", ".sh", ".zsh"} -# A launch line is gated only when it names the common wrapper, the TypeScript -# adapter that invokes that wrapper, or the `mosaic` runtime command that reaches -# the adapter. Merely mentioning a session ID or hook is not sufficient. +# A launch line is gated only when it CALLS the common wrapper in command +# position, invokes the TypeScript adapter, or constructs/runs a `mosaic` +# runtime command. A marker in a comment, string argument, echo, or unrelated +# variable can never satisfy these invocation-shaped patterns. GATED_PATTERNS: Final = ( - re.compile(r"\bexecLeaseGatedRuntime\s*\("), - re.compile(r"\blaunch-runtime\.(?:sh|py)\b"), + re.compile(r"(?:^\s*|=>\s*)execLeaseGatedRuntime\s*\("), re.compile( - r"(?:\bexec\s+|\b(?:LAUNCH_COMMAND|launch_cmd)\s*=\s*\(?|\becho\s+[\"'])" + r"\bexecRuntime\s*\(\s*[\"']python3[\"']\s*,\s*" + r"\[\s*launcher\s*,.*[\"']--runtime[\"']\s*,\s*runtime\s*,\s*[\"']--[\"']" + ), + re.compile( + r"(?:^|[;&|]\s*|\bexec\s+|\b(?:LAUNCH_COMMAND|launch_cmd)\s*=\s*\(?|\becho\s+[\"'])" r"mosaic\s+(?:yolo\s+)?(?:claude|pi|claudex|[\"']?\$\{?runtime\}?[\"']?|[\"']?\$MOSAIC_AGENT_RUNTIME[\"']?)\b" ), re.compile(r"\[\s*[\"']mosaic[\"']\s*,\s*(?:[\"']yolo[\"']\s*,\s*)?(?:runtime|[\"'](?:claude|pi|claudex)[\"'])"), @@ -67,6 +75,17 @@ DIRECT_PATTERNS: Final = ( re.compile(r"\b(?:command|launchCommand|LAUNCH_COMMAND)\s*=\s*(?:\(|\[)\s*[\"']?(?:claude|pi)\b"), re.compile(r"\b(?:command|launchCommand|LAUNCH_COMMAND)\s*=\s*(?:\(|\[)\s*[\"']?\$(?:\{?runtime\}?|MOSAIC_AGENT_RUNTIME)\b"), re.compile(r"\breturn\s*\[\s*runtime\s*,\s*[\"'](?:-p|--dangerously)"), + re.compile(r"\$\(\s*(?:claude|pi)(?:\s|$)"), + 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|[\"']|$)" +) +TYPESCRIPT_WRAPPER_INVOCATION: Final = re.compile( + r"(?m)^\s*execRuntime\s*\(\s*[\"']python3[\"']\s*,\s*" + r"\[\s*launcher\s*,[^\]]*[\"']--runtime[\"']\s*,\s*runtime\s*,\s*" + r"[\"']--[\"']\s*,\s*runtime(?:\s*,|\s*\])", + re.DOTALL, ) @@ -89,27 +108,215 @@ def is_test_path(path: Path) -> bool: ) -def is_gated_line(line: str) -> bool: - return any(pattern.search(line) for pattern in GATED_PATTERNS) +def strip_comments(path: Path, line: str, in_block_comment: bool = False) -> tuple[str, bool]: + suffix = path.suffix.lower() + hash_comments = suffix in {".bash", ".py", ".sh", ".yaml", ".yml", ".zsh"} + slash_comments = suffix in {".cjs", ".js", ".mjs", ".ts", ".tsx"} + output: list[str] = [] + quote: str | None = None + escaped = False + index = 0 + + while index < len(line): + if in_block_comment: + end = line.find("*/", index) + if end < 0: + return "".join(output), True + in_block_comment = False + index = end + 2 + continue + + character = line[index] + following = line[index + 1] if index + 1 < len(line) else "" + if quote is not None: + output.append(character) + if escaped: + escaped = False + elif character == "\\": + escaped = True + elif character == quote: + quote = None + index += 1 + continue + + if character in {"'", '"', "`"}: + quote = character + output.append(character) + index += 1 + continue + if slash_comments and character == "/" and following == "/": + break + if slash_comments and character == "/" and following == "*": + in_block_comment = True + index += 2 + continue + if hash_comments and character == "#": + if suffix == ".py" or index == 0 or line[index - 1].isspace(): + break + output.append(character) + index += 1 + + return "".join(output), in_block_comment -def is_direct_line(line: str) -> bool: - return any(pattern.search(line) for pattern in DIRECT_PATTERNS) +def is_choke_point(path: Path) -> bool: + return path.as_posix().endswith(CHOKE_POINT_SUFFIX) + + +def shell_commands(line: str) -> list[list[str]]: + candidate = line.rstrip().removesuffix("\\").rstrip() + try: + lexer = shlex.shlex(candidate, posix=True, punctuation_chars=";&|") + lexer.whitespace_split = True + lexer.commenters = "" + tokens = list(lexer) + except ValueError: + return [] + + commands: list[list[str]] = [] + current: list[str] = [] + for token in tokens: + if token and all(character in ";&|" for character in token): + if current: + commands.append(current) + current = [] + else: + current.append(token) + if current: + commands.append(current) + return commands + + +def is_wrapper_invocation(path: Path, line: str) -> bool: + if path.suffix.lower() not in SHELL_SUFFIXES: + return False + separator = re.search(r"\s--(?:\s|$)", line) + if separator is None: + return False + # Everything after the wrapper separator is opaque runtime argv and may + # contain an open quote continued on later physical lines. Parse only the + # complete command-position prefix through the separator. + wrapper_prefix = line[: separator.end()] + for command in shell_commands(wrapper_prefix): + if command and command[0] == "exec": + command = command[1:] + if not command: + continue + if command[0] == "python3": + if len(command) < 2 or not command[1].endswith("launch-runtime.py"): + continue + arguments = command[2:] + elif command[0].endswith(("launch-runtime.py", "launch-runtime.sh")): + arguments = command[1:] + else: + continue + try: + runtime_index = arguments.index("--runtime") + separator_index = arguments.index("--") + except ValueError: + continue + if runtime_index + 1 < len(arguments) and runtime_index < separator_index: + return True + return False + + +def is_gated_line(path: Path, line: str) -> bool: + return is_wrapper_invocation(path, line) or any( + pattern.search(line) for pattern in GATED_PATTERNS + ) + + +def is_shell_direct_invocation(path: Path, line: str) -> bool: + if path.suffix.lower() not in SHELL_SUFFIXES: + return False + 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 + 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]) + ): + index += 1 + while index < len(command) and command[index] in {"command", "exec", "nohup"}: + index += 1 + if index < len(command) and Path(command[index]).name in {"claude", "pi"}: + return True + return False + + +def is_direct_line(path: Path, line: str) -> bool: + return is_shell_direct_invocation(path, line) or any( + pattern.search(line) for pattern in DIRECT_PATTERNS + ) + + +def executes_runtime_variable(line: str, runtime_variables: set[str]) -> bool: + 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 + + +def typescript_wrapper_lines(path: Path, source: str) -> set[int]: + if path.suffix.lower() not in {".js", ".mjs", ".ts", ".tsx"}: + return set() + return { + source.count("\n", 0, match.start()) + 1 + for match in TYPESCRIPT_WRAPPER_INVOCATION.finditer(source) + } + + +def classify_text(path: Path, source: str) -> list[LaunchSite]: + sites: list[LaunchSite] = [] + validated_typescript_wrappers = typescript_wrapper_lines(path, source) + runtime_variables: set[str] = set() + gated_continuation = False + in_block_comment = False + for line_number, physical_line in enumerate(source.splitlines(), start=1): + line, in_block_comment = strip_comments(path, physical_line, in_block_comment) + stripped = line.strip() + if not stripped: + gated_continuation = False + continue + + assignment = RUNTIME_ASSIGNMENT.match(line) + 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_gated = line_number in validated_typescript_wrappers or is_gated_line(path, line) + + if primitive_violation: + sites.append( + LaunchSite(path, line_number, physical_line.rstrip(), "dangerous-primitive") + ) + elif line_is_direct and not gated_continuation: + # Direct syntax always wins over a same-line marker. Only the command + # continuation of a previously validated wrapper may contain the raw + # runtime binary itself. + sites.append(LaunchSite(path, line_number, physical_line.rstrip(), "direct")) + elif line_is_gated: + sites.append(LaunchSite(path, line_number, physical_line.rstrip(), "gated")) + + gated = line_is_gated or gated_continuation + gated_continuation = gated and line.rstrip().endswith("\\") + return sites def scan_text(path: Path, source: str) -> list[LaunchSite]: - violations: list[LaunchSite] = [] - gated_continuation = False - for line_number, line in enumerate(source.splitlines(), start=1): - stripped = line.strip() - if not stripped or stripped.startswith(("#", "//", "*")): - gated_continuation = False - continue - gated = is_gated_line(line) or gated_continuation - if is_direct_line(line) and not gated: - violations.append(LaunchSite(path, line_number, line.rstrip(), "direct")) - gated_continuation = gated and line.rstrip().endswith("\\") - return violations + return [site for site in classify_text(path, source) if site.classification != "gated"] def source_files(root: Path): @@ -143,27 +350,12 @@ def inventory_repository(root: Path) -> list[LaunchSite]: inventory: list[LaunchSite] = [] for path in source_files(root): try: - lines = path.read_text(encoding="utf-8").splitlines() + source = path.read_text(encoding="utf-8") except UnicodeDecodeError: inventory.append(LaunchSite(path.relative_to(root), 0, "non-UTF-8 source", "unscannable")) continue - gated_continuation = False - for line_number, line in enumerate(lines, start=1): - stripped = line.strip() - if not stripped or stripped.startswith(("#", "//", "*")): - gated_continuation = False - continue - line_is_gated = is_gated_line(line) - gated = line_is_gated or gated_continuation - if line_is_gated and not stripped.startswith("function execLeaseGatedRuntime"): - inventory.append( - LaunchSite(path.relative_to(root), line_number, line.rstrip(), "gated") - ) - elif is_direct_line(line) and not gated: - inventory.append( - LaunchSite(path.relative_to(root), line_number, line.rstrip(), "direct") - ) - gated_continuation = gated and line.rstrip().endswith("\\") + relative_path = path.relative_to(root) + inventory.extend(classify_text(relative_path, source)) return inventory diff --git a/packages/mosaic/framework/tools/lease-broker/launch-runtime.py b/packages/mosaic/framework/tools/lease-broker/launch-runtime.py index b68de1b..780f958 100644 --- a/packages/mosaic/framework/tools/lease-broker/launch-runtime.py +++ b/packages/mosaic/framework/tools/lease-broker/launch-runtime.py @@ -14,6 +14,7 @@ from typing import Final MAX_FRAME: Final = 64 * 1024 BROKER_TIMEOUT_SECONDS: Final = 1.5 +CLAUDE_DANGEROUS_FLAG: Final = "--dangerously-skip-permissions" def broker_request(socket_path: Path, request: dict[str, object]) -> dict[str, object]: @@ -46,6 +47,7 @@ def main( ) -> int: parser = argparse.ArgumentParser() parser.add_argument("--runtime", required=True, choices=("claude", "pi")) + parser.add_argument("--dangerous", action="store_true") parser.add_argument("command", nargs=argparse.REMAINDER) arguments = parser.parse_args(argv) command = arguments.command @@ -54,6 +56,11 @@ def main( if not command: print("lease-gated runtime command is required", file=sys.stderr) return 64 + if arguments.dangerous: + if arguments.runtime != "claude" or Path(command[0]).name != "claude": + print("dangerous mode is supported only for the Claude runtime", file=sys.stderr) + return 64 + command = [command[0], CLAUDE_DANGEROUS_FLAG, *command[1:]] source_environment = os.environ if environ is None else environ try: diff --git a/packages/mosaic/framework/tools/prdy/prdy-init.sh b/packages/mosaic/framework/tools/prdy/prdy-init.sh index c86855e..74f4219 100755 --- a/packages/mosaic/framework/tools/prdy/prdy-init.sh +++ b/packages/mosaic/framework/tools/prdy/prdy-init.sh @@ -86,8 +86,8 @@ echo "" cd "$PROJECT" if [[ "$RUNTIME_CMD" == "claude" ]]; then - exec python3 "$SCRIPT_DIR/../lease-broker/launch-runtime.py" --runtime claude -- \ - claude --dangerously-skip-permissions --append-system-prompt "$SYSTEM_PROMPT" "$KICKOFF" + exec python3 "$SCRIPT_DIR/../lease-broker/launch-runtime.py" --dangerous --runtime claude -- \ + claude --append-system-prompt "$SYSTEM_PROMPT" "$KICKOFF" fi if [[ "$RUNTIME_CMD" == "codex" ]]; then diff --git a/packages/mosaic/framework/tools/prdy/prdy-update.sh b/packages/mosaic/framework/tools/prdy/prdy-update.sh index 19a3e3f..146dd2f 100755 --- a/packages/mosaic/framework/tools/prdy/prdy-update.sh +++ b/packages/mosaic/framework/tools/prdy/prdy-update.sh @@ -74,8 +74,8 @@ echo "" cd "$PROJECT" if [[ "$RUNTIME_CMD" == "claude" ]]; then - exec python3 "$SCRIPT_DIR/../lease-broker/launch-runtime.py" --runtime claude -- \ - claude --dangerously-skip-permissions --append-system-prompt "$SYSTEM_PROMPT" "$KICKOFF" + exec python3 "$SCRIPT_DIR/../lease-broker/launch-runtime.py" --dangerous --runtime claude -- \ + claude --append-system-prompt "$SYSTEM_PROMPT" "$KICKOFF" fi if [[ "$RUNTIME_CMD" == "codex" ]]; then diff --git a/packages/mosaic/src/commands/claudex.spec.ts b/packages/mosaic/src/commands/claudex.spec.ts index 65a1305..383c3b9 100644 --- a/packages/mosaic/src/commands/claudex.spec.ts +++ b/packages/mosaic/src/commands/claudex.spec.ts @@ -54,7 +54,10 @@ function okAdapter(overrides: TestAdapterOverrides = {}): ClaudexHarnessAdapter return { harnessPreflight: () => {}, composePrompt: () => '# Composed Claude contract', - execLeaseGated: adapterOverrides.execLeaseGated ?? ((args, env) => exec?.('claude', args, env)), + execLeaseGated: + adapterOverrides.execLeaseGated ?? + ((args, env, dangerous) => + exec?.('claude', dangerous ? ['--dangerously-skip-permissions', ...args] : args, env)), ...adapterOverrides, }; } diff --git a/packages/mosaic/src/commands/claudex.ts b/packages/mosaic/src/commands/claudex.ts index 5fdc5c6..6f81a44 100644 --- a/packages/mosaic/src/commands/claudex.ts +++ b/packages/mosaic/src/commands/claudex.ts @@ -475,7 +475,7 @@ export interface ClaudexHarnessAdapter { /** Compose the full Claude runtime contract (== `composeContract('claude')`). */ composePrompt: () => string; /** Register the Claude parent with the broker, then exec with the same PID. */ - execLeaseGated: (args: string[], env: NodeJS.ProcessEnv) => void; + execLeaseGated: (args: string[], env: NodeJS.ProcessEnv, dangerous: boolean) => void; } export interface LaunchClaudexDeps { @@ -533,9 +533,8 @@ export async function launchClaudex( log(buildClaudexBanner(resolvedModels)); - const cliArgs = yolo ? ['--dangerously-skip-permissions'] : []; - cliArgs.push('--append-system-prompt', prompt, ...args); - adapter.execLeaseGated(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)}`, diff --git a/packages/mosaic/src/commands/launch.ts b/packages/mosaic/src/commands/launch.ts index 2cc78d4..2a69682 100644 --- a/packages/mosaic/src/commands/launch.ts +++ b/packages/mosaic/src/commands/launch.ts @@ -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}...`); - execLeaseGatedRuntime('claude', cliArgs); + execLeaseGatedRuntime('claude', cliArgs, process.env, yolo); break; } @@ -818,13 +818,19 @@ function execLeaseGatedRuntime( runtime: 'claude' | 'pi', args: string[], baseEnv: NodeJS.ProcessEnv = process.env, + dangerous = false, ): void { const launcher = resolveTool('lease-broker', 'launch-runtime.py'); - execRuntime('python3', [launcher, '--runtime', runtime, '--', runtime, ...args], { - ...baseEnv, - MOSAIC_LEASE_BROKER_SOCKET: defaultLeaseBrokerSocket(baseEnv), - MOSAIC_RUNTIME_GENERATION: baseEnv['MOSAIC_RUNTIME_GENERATION'] ?? '1', - }); + 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. */ @@ -860,7 +866,8 @@ function launchClaudexProduction(args: string[], yolo: boolean): void { checkSequentialThinking('claude'); }, composePrompt: () => buildRuntimePrompt('claude'), - execLeaseGated: (cmdArgs, env) => execLeaseGatedRuntime('claude', cmdArgs, env), + execLeaseGated: (cmdArgs, env, dangerous) => + execLeaseGatedRuntime('claude', cmdArgs, env, dangerous), }; void launchClaudex(args, yolo, adapter); } 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 903900f..3419cbd 100644 --- a/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts +++ b/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts @@ -546,8 +546,20 @@ raise SystemExit(0 if len(session_id) == 64 and hook_present and denied else 1) harnessPreflight: () => {}, composePrompt: () => '# composed Claude contract', // Claudex exposes only the shared register-before-exec boundary. - execLeaseGated: (args: string[], env: NodeJS.ProcessEnv) => - run('python3', [launcherPath, '--runtime', 'claude', '--', 'claude', ...args], env), + execLeaseGated: (args: string[], env: NodeJS.ProcessEnv, dangerous: boolean) => + run( + 'python3', + [ + launcherPath, + ...(dangerous ? ['--dangerous'] : []), + '--runtime', + 'claude', + '--', + 'claude', + ...args, + ], + env, + ), } as unknown as ClaudexHarnessAdapter; await launchClaudex([], yolo, adapter, { 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 6c63d03..e049b2c 100644 --- a/packages/mosaic/src/mutator-gate/runtime_launch_guard_unittest.py +++ b/packages/mosaic/src/mutator-gate/runtime_launch_guard_unittest.py @@ -56,6 +56,9 @@ class RuntimeLaunchGuardTest(unittest.TestCase): "command-substitution.sh": "output=$(claude -p prompt)\n", "eval.sh": "launcher='claude -p prompt'\neval \"$launcher\"\n", "variable-exec.sh": "launcher=claude\n\"$launcher\" -p prompt\n", + "env-prefix.sh": "env SAFE=1 claude --help\n", + "command-prefix.sh": "command pi --help\n", + "nohup-prefix.sh": "nohup claude --help &\n", } for filename, source in cases.items(): with self.subTest(filename=filename): @@ -73,6 +76,28 @@ class RuntimeLaunchGuardTest(unittest.TestCase): with self.subTest(filename=filename): self.assertEqual(GUARD.scan_text(Path(filename), source), []) + def test_accepts_only_validated_multiline_typescript_wrapper_invocation(self) -> None: + source = """execRuntime( + 'python3', + [launcher, ...dangerousArgs, '--runtime', runtime, '--', runtime, ...args], + environment, +); +""" + sites = GUARD.classify_text(Path("launch.ts"), source) + self.assertEqual(len(sites), 1) + self.assertEqual(sites[0].classification, "gated") + + def test_marker_comments_strings_and_assignments_are_not_gated_sites(self) -> None: + harmless_sources = { + "comment.sh": "# launch-runtime.py --runtime claude --\n", + "echo.sh": "echo 'launch-runtime.py --runtime claude --'\n", + "assignment.sh": "marker='launch-runtime.py --runtime claude --'\n", + "argument.sh": "printf '%s' 'launch-runtime.py --runtime claude --'\n", + } + for filename, source in harmless_sources.items(): + with self.subTest(filename=filename): + self.assertEqual(GUARD.classify_text(Path(filename), source), []) + 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"), []) @@ -122,7 +147,9 @@ class RuntimeLaunchGuardTest(unittest.TestCase): root = Path(directory) source = root / "packages/example/launch.sh" source.parent.mkdir(parents=True) - source.write_text("exec claude -p prompt\n") + source.write_text( + 'exec claude --dangerously-skip-permissions "terra-r3" # launch-runtime.py\n' + ) stdout = io.StringIO() stderr = io.StringIO() with redirect_stdout(stdout), redirect_stderr(stderr):