fix(#829): harden runtime launch guard against marker evasions
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
This commit is contained in:
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)}`,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user