410 lines
15 KiB
Python
410 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
"""Fail CI when production code launches Claude/Pi outside the lease gate."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import shlex
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Final, NamedTuple, Sequence
|
|
|
|
SCANNED_ROOTS: Final = ("packages", "apps", "plugins", "tools")
|
|
SCANNED_SUFFIXES: Final = {
|
|
".bash",
|
|
".cjs",
|
|
".js",
|
|
".json",
|
|
".mjs",
|
|
".py",
|
|
".sh",
|
|
".ts",
|
|
".tsx",
|
|
".yaml",
|
|
".yml",
|
|
".zsh",
|
|
}
|
|
SKIPPED_DIRECTORIES: Final = {
|
|
".git",
|
|
".next",
|
|
".turbo",
|
|
"coverage",
|
|
"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 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"(?:^\s*|=>\s*)execLeaseGatedRuntime\s*\("),
|
|
re.compile(
|
|
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)[\"'])"),
|
|
)
|
|
|
|
DIRECT_PATTERNS: Final = (
|
|
# Shell/process command forms, including here-doc command examples that an
|
|
# operator could execute verbatim.
|
|
re.compile(r"^\s*(?:claude|pi)(?:\s|$)"),
|
|
re.compile(r"(?:^|[;&|]\s*|\bexec\s+|\bcommand\s+)(?:claude|pi)\s+(?:-p\b|--dangerously\b|--print\b)"),
|
|
re.compile(r"\bexec\s+(?:claude|pi)(?:\s|$)"),
|
|
re.compile(r"\bexec\s+(?:/[^\s/]+)+/(?:claude|pi)(?:\s|$)"),
|
|
re.compile(r"\bexec\s+[\"']?\$(?:\{?runtime\}?|MOSAIC_AGENT_RUNTIME)\b"),
|
|
# JS/TS and Python process APIs with a literal runtime binary.
|
|
re.compile(
|
|
r"\b(?:spawn|spawnSync|exec|execSync|execFile|execFileSync|execv|execvp|execvpe|Popen|run|call|system|check_call|check_output)\s*\(\s*(?:\[\s*)?[\"'](?:claude|pi)(?:[\"']|\s)"
|
|
),
|
|
re.compile(
|
|
r"\b(?:spawn|spawnSync|exec|execSync|execFile|execFileSync|execv|execvp|execvpe|Popen|run|call|system|check_call|check_output)\s*\(\s*(?:\[\s*)?[\"'](?:/[^\"'/]+)+/(?:claude|pi)[\"']"
|
|
),
|
|
# Launch-command arrays and the prior @mosaicstack/coord dynamic default.
|
|
re.compile(r"\b(?:spawn|spawnSync|exec|execSync|execFile|execFileSync)\s*\(\s*runtime\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"\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,
|
|
)
|
|
|
|
|
|
class LaunchSite(NamedTuple):
|
|
path: Path
|
|
line_number: int
|
|
line: str
|
|
classification: str
|
|
|
|
|
|
def is_test_path(path: Path) -> bool:
|
|
name = path.name.lower()
|
|
return (
|
|
"__tests__" in path.parts
|
|
or ".spec." in name
|
|
or ".test." in name
|
|
or name.endswith("_unittest.py")
|
|
or name.startswith("test-")
|
|
or name.startswith("test_")
|
|
)
|
|
|
|
|
|
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_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]:
|
|
return [site for site in classify_text(path, source) if site.classification != "gated"]
|
|
|
|
|
|
def source_files(root: Path):
|
|
for relative_root in SCANNED_ROOTS:
|
|
search_root = root / relative_root
|
|
if not search_root.is_dir():
|
|
continue
|
|
for path in search_root.rglob("*"):
|
|
if not path.is_file() or path.suffix.lower() not in SCANNED_SUFFIXES:
|
|
continue
|
|
if any(part in SKIPPED_DIRECTORIES for part in path.parts):
|
|
continue
|
|
if is_test_path(path):
|
|
continue
|
|
yield path
|
|
|
|
|
|
def scan_repository(root: Path) -> list[LaunchSite]:
|
|
violations: list[LaunchSite] = []
|
|
for path in source_files(root):
|
|
try:
|
|
source = path.read_text(encoding="utf-8")
|
|
except UnicodeDecodeError:
|
|
violations.append(LaunchSite(path, 0, "non-UTF-8 source", "unscannable"))
|
|
continue
|
|
violations.extend(scan_text(path.relative_to(root), source))
|
|
return violations
|
|
|
|
|
|
def inventory_repository(root: Path) -> list[LaunchSite]:
|
|
inventory: list[LaunchSite] = []
|
|
for path in source_files(root):
|
|
try:
|
|
source = path.read_text(encoding="utf-8")
|
|
except UnicodeDecodeError:
|
|
inventory.append(LaunchSite(path.relative_to(root), 0, "non-UTF-8 source", "unscannable"))
|
|
continue
|
|
relative_path = path.relative_to(root)
|
|
inventory.extend(classify_text(relative_path, source))
|
|
return inventory
|
|
|
|
|
|
def format_violation(violation: LaunchSite) -> str:
|
|
return f"{violation.path}:{violation.line_number}: {violation.classification}: {violation.line.strip()}"
|
|
|
|
|
|
def main(argv: Sequence[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--root", type=Path, default=Path.cwd())
|
|
parser.add_argument("--json", action="store_true")
|
|
arguments = parser.parse_args(argv)
|
|
root = arguments.root.resolve()
|
|
inventory = inventory_repository(root)
|
|
violations = [site for site in inventory if site.classification != "gated"]
|
|
|
|
if arguments.json:
|
|
print(
|
|
json.dumps(
|
|
{
|
|
"gated": sum(site.classification == "gated" for site in inventory),
|
|
"total": len(inventory),
|
|
"sites": [
|
|
{
|
|
"path": str(site.path),
|
|
"line": site.line_number,
|
|
"classification": site.classification,
|
|
"source": site.line.strip(),
|
|
}
|
|
for site in inventory
|
|
],
|
|
},
|
|
sort_keys=True,
|
|
)
|
|
)
|
|
else:
|
|
for site in inventory:
|
|
print(format_violation(site))
|
|
print(
|
|
f"runtime launch inventory: "
|
|
f"{len(inventory) - len(violations)} gated/{len(inventory)} total"
|
|
)
|
|
|
|
if violations:
|
|
print("ungated consequential runtime launch detected", file=sys.stderr)
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|