fix(#829): harden runtime launch guard against marker evasions
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful

This commit is contained in:
ms-lead-reviewer
2026-07-18 00:17:50 -05:00
parent 64efdb4460
commit 1eb77c17f3
11 changed files with 316 additions and 62 deletions

View File

@@ -37,9 +37,11 @@ The executable submits the runtime's actual tool name to `authorize_tool`. Missi
## Runtime-launch choke-point and permanent guard ## 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: The live inventory is emitted by:

View File

@@ -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. - 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. - 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. - 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.

View File

@@ -6,6 +6,7 @@ from __future__ import annotations
import argparse import argparse
import json import json
import re import re
import shlex
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Final, NamedTuple, Sequence from typing import Final, NamedTuple, Sequence
@@ -33,15 +34,22 @@ SKIPPED_DIRECTORIES: Final = {
"dist", "dist",
"node_modules", "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 # A launch line is gated only when it CALLS the common wrapper in command
# adapter that invokes that wrapper, or the `mosaic` runtime command that reaches # position, invokes the TypeScript adapter, or constructs/runs a `mosaic`
# the adapter. Merely mentioning a session ID or hook is not sufficient. # runtime command. A marker in a comment, string argument, echo, or unrelated
# variable can never satisfy these invocation-shaped patterns.
GATED_PATTERNS: Final = ( GATED_PATTERNS: Final = (
re.compile(r"\bexecLeaseGatedRuntime\s*\("), re.compile(r"(?:^\s*|=>\s*)execLeaseGatedRuntime\s*\("),
re.compile(r"\blaunch-runtime\.(?:sh|py)\b"),
re.compile( 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" 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)[\"'])"), 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*[\"']?(?:claude|pi)\b"),
re.compile(r"\b(?:command|launchCommand|LAUNCH_COMMAND)\s*=\s*(?:\(|\[)\s*[\"']?\$(?:\{?runtime\}?|MOSAIC_AGENT_RUNTIME)\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"\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: def strip_comments(path: Path, line: str, in_block_comment: bool = False) -> tuple[str, bool]:
return any(pattern.search(line) for pattern in GATED_PATTERNS) 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: def is_choke_point(path: Path) -> bool:
return any(pattern.search(line) for pattern in DIRECT_PATTERNS) 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]: def scan_text(path: Path, source: str) -> list[LaunchSite]:
violations: list[LaunchSite] = [] return [site for site in classify_text(path, source) if site.classification != "gated"]
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
def source_files(root: Path): def source_files(root: Path):
@@ -143,27 +350,12 @@ def inventory_repository(root: Path) -> list[LaunchSite]:
inventory: list[LaunchSite] = [] inventory: list[LaunchSite] = []
for path in source_files(root): for path in source_files(root):
try: try:
lines = path.read_text(encoding="utf-8").splitlines() source = path.read_text(encoding="utf-8")
except UnicodeDecodeError: except UnicodeDecodeError:
inventory.append(LaunchSite(path.relative_to(root), 0, "non-UTF-8 source", "unscannable")) inventory.append(LaunchSite(path.relative_to(root), 0, "non-UTF-8 source", "unscannable"))
continue continue
gated_continuation = False relative_path = path.relative_to(root)
for line_number, line in enumerate(lines, start=1): inventory.extend(classify_text(relative_path, source))
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("\\")
return inventory return inventory

View File

@@ -14,6 +14,7 @@ from typing import Final
MAX_FRAME: Final = 64 * 1024 MAX_FRAME: Final = 64 * 1024
BROKER_TIMEOUT_SECONDS: Final = 1.5 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]: def broker_request(socket_path: Path, request: dict[str, object]) -> dict[str, object]:
@@ -46,6 +47,7 @@ def main(
) -> int: ) -> int:
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--runtime", required=True, choices=("claude", "pi")) parser.add_argument("--runtime", required=True, choices=("claude", "pi"))
parser.add_argument("--dangerous", action="store_true")
parser.add_argument("command", nargs=argparse.REMAINDER) parser.add_argument("command", nargs=argparse.REMAINDER)
arguments = parser.parse_args(argv) arguments = parser.parse_args(argv)
command = arguments.command command = arguments.command
@@ -54,6 +56,11 @@ def main(
if not command: if not command:
print("lease-gated runtime command is required", file=sys.stderr) print("lease-gated runtime command is required", file=sys.stderr)
return 64 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 source_environment = os.environ if environ is None else environ
try: try:

View File

@@ -86,8 +86,8 @@ echo ""
cd "$PROJECT" cd "$PROJECT"
if [[ "$RUNTIME_CMD" == "claude" ]]; then if [[ "$RUNTIME_CMD" == "claude" ]]; then
exec python3 "$SCRIPT_DIR/../lease-broker/launch-runtime.py" --runtime claude -- \ exec python3 "$SCRIPT_DIR/../lease-broker/launch-runtime.py" --dangerous --runtime claude -- \
claude --dangerously-skip-permissions --append-system-prompt "$SYSTEM_PROMPT" "$KICKOFF" claude --append-system-prompt "$SYSTEM_PROMPT" "$KICKOFF"
fi fi
if [[ "$RUNTIME_CMD" == "codex" ]]; then if [[ "$RUNTIME_CMD" == "codex" ]]; then

View File

@@ -74,8 +74,8 @@ echo ""
cd "$PROJECT" cd "$PROJECT"
if [[ "$RUNTIME_CMD" == "claude" ]]; then if [[ "$RUNTIME_CMD" == "claude" ]]; then
exec python3 "$SCRIPT_DIR/../lease-broker/launch-runtime.py" --runtime claude -- \ exec python3 "$SCRIPT_DIR/../lease-broker/launch-runtime.py" --dangerous --runtime claude -- \
claude --dangerously-skip-permissions --append-system-prompt "$SYSTEM_PROMPT" "$KICKOFF" claude --append-system-prompt "$SYSTEM_PROMPT" "$KICKOFF"
fi fi
if [[ "$RUNTIME_CMD" == "codex" ]]; then if [[ "$RUNTIME_CMD" == "codex" ]]; then

View File

@@ -54,7 +54,10 @@ function okAdapter(overrides: TestAdapterOverrides = {}): ClaudexHarnessAdapter
return { return {
harnessPreflight: () => {}, harnessPreflight: () => {},
composePrompt: () => '# Composed Claude contract', 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, ...adapterOverrides,
}; };
} }

View File

@@ -475,7 +475,7 @@ export interface ClaudexHarnessAdapter {
/** Compose the full Claude runtime contract (== `composeContract('claude')`). */ /** Compose the full Claude runtime contract (== `composeContract('claude')`). */
composePrompt: () => string; composePrompt: () => string;
/** Register the Claude parent with the broker, then exec with the same PID. */ /** 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 { export interface LaunchClaudexDeps {
@@ -533,9 +533,8 @@ export async function launchClaudex(
log(buildClaudexBanner(resolvedModels)); log(buildClaudexBanner(resolvedModels));
const cliArgs = yolo ? ['--dangerously-skip-permissions'] : []; const cliArgs = ['--append-system-prompt', prompt, ...args];
cliArgs.push('--append-system-prompt', prompt, ...args); adapter.execLeaseGated(cliArgs, env, yolo);
adapter.execLeaseGated(cliArgs, env);
} catch (err) { } catch (err) {
errorLog( errorLog(
`[mosaic] claudex launch aborted: ${err instanceof Error ? err.message : String(err)}`, `[mosaic] claudex launch aborted: ${err instanceof Error ? err.message : String(err)}`,

View File

@@ -755,7 +755,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
printSettingsWarnings(settingsAudit); printSettingsWarnings(settingsAudit);
const prompt = buildRuntimePrompt('claude'); const prompt = buildRuntimePrompt('claude');
const cliArgs = yolo ? ['--dangerously-skip-permissions'] : []; const cliArgs: string[] = [];
cliArgs.push('--append-system-prompt', prompt); cliArgs.push('--append-system-prompt', prompt);
if (hasMissionNoArgs) { if (hasMissionNoArgs) {
cliArgs.push(missionPrompt); cliArgs.push(missionPrompt);
@@ -763,7 +763,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
cliArgs.push(...args); cliArgs.push(...args);
} }
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`); console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
execLeaseGatedRuntime('claude', cliArgs); execLeaseGatedRuntime('claude', cliArgs, process.env, yolo);
break; break;
} }
@@ -818,13 +818,19 @@ function execLeaseGatedRuntime(
runtime: 'claude' | 'pi', runtime: 'claude' | 'pi',
args: string[], args: string[],
baseEnv: NodeJS.ProcessEnv = process.env, baseEnv: NodeJS.ProcessEnv = process.env,
dangerous = false,
): void { ): void {
const launcher = resolveTool('lease-broker', 'launch-runtime.py'); const launcher = resolveTool('lease-broker', 'launch-runtime.py');
execRuntime('python3', [launcher, '--runtime', runtime, '--', runtime, ...args], { const dangerousArgs = dangerous ? ['--dangerous'] : [];
execRuntime(
'python3',
[launcher, ...dangerousArgs, '--runtime', runtime, '--', runtime, ...args],
{
...baseEnv, ...baseEnv,
MOSAIC_LEASE_BROKER_SOCKET: defaultLeaseBrokerSocket(baseEnv), MOSAIC_LEASE_BROKER_SOCKET: defaultLeaseBrokerSocket(baseEnv),
MOSAIC_RUNTIME_GENERATION: baseEnv['MOSAIC_RUNTIME_GENERATION'] ?? '1', MOSAIC_RUNTIME_GENERATION: baseEnv['MOSAIC_RUNTIME_GENERATION'] ?? '1',
}); },
);
} }
/** exec into the runtime, replacing the current process. */ /** exec into the runtime, replacing the current process. */
@@ -860,7 +866,8 @@ function launchClaudexProduction(args: string[], yolo: boolean): void {
checkSequentialThinking('claude'); checkSequentialThinking('claude');
}, },
composePrompt: () => buildRuntimePrompt('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); void launchClaudex(args, yolo, adapter);
} }

View File

@@ -546,8 +546,20 @@ raise SystemExit(0 if len(session_id) == 64 and hook_present and denied else 1)
harnessPreflight: () => {}, harnessPreflight: () => {},
composePrompt: () => '# composed Claude contract', composePrompt: () => '# composed Claude contract',
// Claudex exposes only the shared register-before-exec boundary. // Claudex exposes only the shared register-before-exec boundary.
execLeaseGated: (args: string[], env: NodeJS.ProcessEnv) => execLeaseGated: (args: string[], env: NodeJS.ProcessEnv, dangerous: boolean) =>
run('python3', [launcherPath, '--runtime', 'claude', '--', 'claude', ...args], env), run(
'python3',
[
launcherPath,
...(dangerous ? ['--dangerous'] : []),
'--runtime',
'claude',
'--',
'claude',
...args,
],
env,
),
} as unknown as ClaudexHarnessAdapter; } as unknown as ClaudexHarnessAdapter;
await launchClaudex([], yolo, adapter, { await launchClaudex([], yolo, adapter, {

View File

@@ -56,6 +56,9 @@ class RuntimeLaunchGuardTest(unittest.TestCase):
"command-substitution.sh": "output=$(claude -p prompt)\n", "command-substitution.sh": "output=$(claude -p prompt)\n",
"eval.sh": "launcher='claude -p prompt'\neval \"$launcher\"\n", "eval.sh": "launcher='claude -p prompt'\neval \"$launcher\"\n",
"variable-exec.sh": "launcher=claude\n\"$launcher\" -p prompt\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(): for filename, source in cases.items():
with self.subTest(filename=filename): with self.subTest(filename=filename):
@@ -73,6 +76,28 @@ class RuntimeLaunchGuardTest(unittest.TestCase):
with self.subTest(filename=filename): with self.subTest(filename=filename):
self.assertEqual(GUARD.scan_text(Path(filename), source), []) 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: def test_dangerous_primitive_is_owned_only_by_the_choke_point(self) -> None:
primitive = "--dangerously-skip-permissions" primitive = "--dangerously-skip-permissions"
self.assertNotEqual(GUARD.scan_text(Path("caller.ts"), f"args = ['{primitive}'];\n"), []) self.assertNotEqual(GUARD.scan_text(Path("caller.ts"), f"args = ['{primitive}'];\n"), [])
@@ -122,7 +147,9 @@ class RuntimeLaunchGuardTest(unittest.TestCase):
root = Path(directory) root = Path(directory)
source = root / "packages/example/launch.sh" source = root / "packages/example/launch.sh"
source.parent.mkdir(parents=True) 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() stdout = io.StringIO()
stderr = io.StringIO() stderr = io.StringIO()
with redirect_stdout(stdout), redirect_stderr(stderr): with redirect_stdout(stdout), redirect_stderr(stderr):