feat(mosaic): WI-2 mutator-class guard for directive-freshness (#837)
WI-2: mutator-class guard for the compaction directive-freshness mechanism — command-position parser (prefix x var-indirection unified) + primitive-anchored invariant backstop + all-tools-hook fail-close. Parser-complete on principle: realistic evasion matrix RED-regression-covered, residual exotic evasions proven backstop-caught (B-tests), lens-convergence reached. closes #829
This commit was merged in pull request #837.
This commit is contained in:
@@ -0,0 +1,436 @@
|
||||
#!/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 shell_command_tokens(path: Path, line: str) -> list[str]:
|
||||
if path.suffix.lower() not in SHELL_SUFFIXES:
|
||||
return []
|
||||
assignment = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
|
||||
case_arm = re.match(r"^\s*[^;&()]+\)\s*", line)
|
||||
command_source = line[case_arm.end() :] if case_arm is not None else line
|
||||
resolved: list[str] = []
|
||||
for command in shell_commands(command_source):
|
||||
index = 0
|
||||
while index < len(command) and assignment.match(command[index]):
|
||||
index += 1
|
||||
# Resolve command position once for every literal and variable caller.
|
||||
# Prefixes may be nested in either order (for example `exec env A=1`).
|
||||
while index < len(command):
|
||||
if command[index] in {"command", "exec", "nohup"}:
|
||||
index += 1
|
||||
continue
|
||||
if command[index] == "env":
|
||||
index += 1
|
||||
while index < len(command) and (
|
||||
command[index].startswith("-") or assignment.match(command[index])
|
||||
):
|
||||
index += 1
|
||||
continue
|
||||
break
|
||||
if index < len(command):
|
||||
resolved.append(command[index])
|
||||
return resolved
|
||||
|
||||
|
||||
def runtime_variable_reference(token: str, runtime_variables: set[str]) -> bool:
|
||||
match = re.fullmatch(r"\$(?:([A-Za-z_][A-Za-z0-9_]*)|\{([A-Za-z_][A-Za-z0-9_]*)\})", token)
|
||||
if match is None:
|
||||
return False
|
||||
return (match.group(1) or match.group(2)) in runtime_variables
|
||||
|
||||
|
||||
def is_shell_direct_invocation(
|
||||
path: Path, line: str, runtime_variables: set[str]
|
||||
) -> bool:
|
||||
return any(
|
||||
Path(token).name in {"claude", "pi"}
|
||||
or runtime_variable_reference(token, runtime_variables)
|
||||
for token in shell_command_tokens(path, line)
|
||||
)
|
||||
|
||||
|
||||
def is_direct_line(path: Path, line: str, runtime_variables: set[str]) -> bool:
|
||||
return is_shell_direct_invocation(path, line, runtime_variables) or any(
|
||||
pattern.search(line) for pattern in DIRECT_PATTERNS
|
||||
)
|
||||
|
||||
|
||||
def executes_runtime_variable(
|
||||
path: Path, line: str, runtime_variables: set[str]
|
||||
) -> bool:
|
||||
if any(
|
||||
runtime_variable_reference(token, runtime_variables)
|
||||
for token in shell_command_tokens(path, line)
|
||||
):
|
||||
return True
|
||||
for variable in runtime_variables:
|
||||
reference = rf"\$(?:{re.escape(variable)}|\{{{re.escape(variable)}\}})"
|
||||
if re.search(rf"\beval\s+[\"']?{reference}", line):
|
||||
return True
|
||||
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, runtime_variables) or executes_runtime_variable(
|
||||
path, line, runtime_variables
|
||||
)
|
||||
line_is_gated = line_number in validated_typescript_wrappers or is_gated_line(path, line)
|
||||
|
||||
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())
|
||||
@@ -24,9 +24,19 @@ MAX_FRAME: Final = 64 * 1024
|
||||
MAX_STATE: Final = 4 * 1024 * 1024
|
||||
MAX_PENDING_TOKENS: Final = 256
|
||||
MAX_IN_FLIGHT_CONNECTIONS: Final = 16
|
||||
MAX_LEASE_TTL_SECONDS: Final = 300
|
||||
STATE_VERSION: Final = 1
|
||||
CONNECTION_DEADLINE_SECONDS: Final = 1.0
|
||||
HEX_256_LENGTH: Final = 64
|
||||
LEASE_UNVERIFIED: Final = "UNVERIFIED"
|
||||
LEASE_PENDING: Final = "PENDING_VERIFICATION"
|
||||
LEASE_PENDING_PROMOTION: Final = "PENDING_PROMOTION"
|
||||
LEASE_VERIFIED: Final = "VERIFIED"
|
||||
READ_ONLY_TOOLS: Final = {
|
||||
"claude": frozenset({"Read", "Grep", "Glob", "Ls", "Find"}),
|
||||
"pi": frozenset({"read", "grep", "find", "ls"}),
|
||||
}
|
||||
RECOVERY_TOOL: Final = "mosaic_context_recover"
|
||||
|
||||
|
||||
class BrokerFailure(Exception):
|
||||
@@ -265,6 +275,9 @@ class StateStore:
|
||||
class Broker:
|
||||
def __init__(self, store: StateStore) -> None:
|
||||
self.store = store
|
||||
# VERIFIED authority is deliberately volatile: broker restart revokes all
|
||||
# leases while preserving WI-1 identity and pending-token integrity.
|
||||
self.leases: dict[str, dict[str, object]] = {}
|
||||
|
||||
def authenticate(self, peer_pid: int, request: dict[str, object]) -> tuple[str, dict[str, object]]:
|
||||
session_id = request.get("session_id")
|
||||
@@ -289,7 +302,7 @@ class Broker:
|
||||
raise BrokerFailure("STALE_GENERATION")
|
||||
if generation > current_generation:
|
||||
session["runtime_generation"] = generation
|
||||
self.revoke_session_tokens(session_id)
|
||||
self.revoke_session_authority(session_id)
|
||||
return session_id, session
|
||||
|
||||
def session_for_anchor(
|
||||
@@ -310,19 +323,59 @@ class Broker:
|
||||
]:
|
||||
del tokens[token_value]
|
||||
|
||||
def revoke_session_authority(self, session_id: str) -> None:
|
||||
self.revoke_session_tokens(session_id)
|
||||
session = self.store.sessions().get(session_id)
|
||||
generation = session.get("runtime_generation") if isinstance(session, dict) else None
|
||||
self.leases[session_id] = {
|
||||
"state": LEASE_UNVERIFIED,
|
||||
"runtime_generation": generation,
|
||||
}
|
||||
|
||||
def mint_token(
|
||||
self,
|
||||
session_id: str,
|
||||
generation: int,
|
||||
binding: dict[str, object],
|
||||
) -> str:
|
||||
if len(self.store.tokens()) >= MAX_PENDING_TOKENS:
|
||||
raise BrokerFailure("TOKEN_CAPACITY")
|
||||
token = secrets.token_hex(32)
|
||||
self.store.tokens()[token] = {
|
||||
"session_id": session_id,
|
||||
"runtime_generation": generation,
|
||||
"binding": copy.deepcopy(binding),
|
||||
"consumed": False,
|
||||
}
|
||||
return token
|
||||
|
||||
def finish_promotion(self, session_id: str) -> None:
|
||||
lease = self.leases.get(session_id)
|
||||
if not isinstance(lease, dict) or lease.get("state") != LEASE_PENDING_PROMOTION:
|
||||
raise BrokerFailure("STATE_INTEGRITY")
|
||||
lease["state"] = LEASE_VERIFIED
|
||||
|
||||
def handle(self, peer: tuple[int, int, int], request: dict[str, object]) -> dict[str, object]:
|
||||
if self.store.poisoned:
|
||||
raise StateCommitUncertain()
|
||||
previous = copy.deepcopy(self.store.value)
|
||||
previous_leases = copy.deepcopy(self.leases)
|
||||
try:
|
||||
response = self._handle(peer, request)
|
||||
if self.store.value != previous:
|
||||
self.store.commit()
|
||||
if request.get("action") == "promote_lease":
|
||||
session_id = request.get("session_id")
|
||||
if not isinstance(session_id, str):
|
||||
raise BrokerFailure("INVALID_IDENTITY")
|
||||
self.finish_promotion(session_id)
|
||||
response["state"] = LEASE_VERIFIED
|
||||
return response
|
||||
except StateCommitUncertain:
|
||||
raise
|
||||
except Exception:
|
||||
self.store.value = previous
|
||||
self.leases = previous_leases
|
||||
raise
|
||||
|
||||
def _handle(self, peer: tuple[int, int, int], request: dict[str, object]) -> dict[str, object]:
|
||||
@@ -351,7 +404,7 @@ class Broker:
|
||||
raise BrokerFailure("STALE_GENERATION")
|
||||
if generation > current_generation:
|
||||
session["runtime_generation"] = generation
|
||||
self.revoke_session_tokens(session_id)
|
||||
self.revoke_session_authority(session_id)
|
||||
return {"ok": True, "session_id": session_id, "peer": {"pid": peer_pid, "uid": peer_uid, "gid": peer_gid, "starttime": anchor["starttime"]}}
|
||||
if action == "authenticate":
|
||||
self.authenticate(peer_pid, request)
|
||||
@@ -361,15 +414,7 @@ class Broker:
|
||||
binding = request.get("binding")
|
||||
if not valid_binding(binding):
|
||||
raise BrokerFailure("INVALID_BINDING")
|
||||
if len(self.store.tokens()) >= MAX_PENDING_TOKENS:
|
||||
raise BrokerFailure("TOKEN_CAPACITY")
|
||||
token = secrets.token_hex(32)
|
||||
self.store.tokens()[token] = {
|
||||
"session_id": session_id,
|
||||
"runtime_generation": request["runtime_generation"],
|
||||
"binding": binding,
|
||||
"consumed": False,
|
||||
}
|
||||
token = self.mint_token(session_id, request["runtime_generation"], binding)
|
||||
return {"ok": True, "token": token}
|
||||
if action == "consume_token":
|
||||
session_id, _ = self.authenticate(peer_pid, request)
|
||||
@@ -379,6 +424,112 @@ class Broker:
|
||||
raise BrokerFailure("TOKEN_REPLAY")
|
||||
del self.store.tokens()[token_value]
|
||||
return {"ok": True}
|
||||
if action == "begin_verification":
|
||||
session_id, _ = self.authenticate(peer_pid, request)
|
||||
runtime = request.get("runtime")
|
||||
binding = request.get("binding")
|
||||
ttl_seconds = request.get("ttl_seconds", MAX_LEASE_TTL_SECONDS)
|
||||
if runtime not in READ_ONLY_TOOLS:
|
||||
raise BrokerFailure("INVALID_RUNTIME")
|
||||
if not valid_binding(binding):
|
||||
raise BrokerFailure("INVALID_BINDING")
|
||||
if (
|
||||
type(ttl_seconds) is not int
|
||||
or ttl_seconds <= 0
|
||||
or ttl_seconds > MAX_LEASE_TTL_SECONDS
|
||||
):
|
||||
raise BrokerFailure("INVALID_LEASE_TTL")
|
||||
# Revoke-first is a broker operation, not advisory adapter order.
|
||||
self.revoke_session_authority(session_id)
|
||||
token = self.mint_token(session_id, request["runtime_generation"], binding)
|
||||
self.leases[session_id] = {
|
||||
"state": LEASE_PENDING,
|
||||
"runtime": runtime,
|
||||
"runtime_generation": request["runtime_generation"],
|
||||
"binding": copy.deepcopy(binding),
|
||||
"promotion_token": token,
|
||||
"ttl_seconds": ttl_seconds,
|
||||
}
|
||||
return {
|
||||
"ok": True,
|
||||
"state": LEASE_PENDING,
|
||||
"promotion_token": token,
|
||||
}
|
||||
if action == "promote_lease":
|
||||
session_id, _ = self.authenticate(peer_pid, request)
|
||||
promotion_token = request.get("promotion_token")
|
||||
lease = self.leases.get(session_id)
|
||||
if not isinstance(lease, dict) or lease.get("state") != LEASE_PENDING:
|
||||
raise BrokerFailure("INVALID_LEASE_TRANSITION")
|
||||
expected_token = lease.get("promotion_token")
|
||||
if (
|
||||
not isinstance(promotion_token, str)
|
||||
or not isinstance(expected_token, str)
|
||||
or not secrets.compare_digest(promotion_token, expected_token)
|
||||
):
|
||||
raise BrokerFailure("PROMOTION_TOKEN_MISMATCH")
|
||||
token = self.store.tokens().get(promotion_token)
|
||||
if (
|
||||
not isinstance(token, dict)
|
||||
or token.get("session_id") != session_id
|
||||
or token.get("runtime_generation") != request.get("runtime_generation")
|
||||
or token.get("binding") != lease.get("binding")
|
||||
or token.get("consumed") is not False
|
||||
):
|
||||
raise BrokerFailure("PROMOTION_TOKEN_INVALID")
|
||||
del self.store.tokens()[promotion_token]
|
||||
lease["state"] = LEASE_PENDING_PROMOTION
|
||||
lease["expires_at"] = time.monotonic() + int(lease["ttl_seconds"])
|
||||
# handle() commits token consumption before finish_promotion() makes
|
||||
# VERIFIED externally visible: promote-last by construction.
|
||||
return {"ok": True, "state": LEASE_PENDING_PROMOTION}
|
||||
if action == "revoke_lease":
|
||||
session_id, _ = self.authenticate(peer_pid, request)
|
||||
self.revoke_session_authority(session_id)
|
||||
return {"ok": True, "state": LEASE_UNVERIFIED}
|
||||
if action == "authorize_tool":
|
||||
session_id, _ = self.authenticate(peer_pid, request)
|
||||
runtime = request.get("runtime")
|
||||
tool_name = request.get("tool_name")
|
||||
if runtime not in READ_ONLY_TOOLS:
|
||||
raise BrokerFailure("INVALID_RUNTIME")
|
||||
if not isinstance(tool_name, str) or not tool_name or len(tool_name) > 256:
|
||||
raise BrokerFailure("INVALID_TOOL")
|
||||
lease = self.leases.get(session_id)
|
||||
state = lease.get("state") if isinstance(lease, dict) else LEASE_UNVERIFIED
|
||||
if tool_name in READ_ONLY_TOOLS[runtime] or tool_name == RECOVERY_TOOL:
|
||||
return {"ok": True, "decision": "allow", "state": state}
|
||||
if not isinstance(lease, dict) or lease.get("state") != LEASE_VERIFIED:
|
||||
return {
|
||||
"ok": False,
|
||||
"code": "MUTATOR_UNVERIFIED",
|
||||
"decision": "deny",
|
||||
"state": LEASE_UNVERIFIED,
|
||||
}
|
||||
if (
|
||||
lease.get("runtime") != runtime
|
||||
or lease.get("runtime_generation") != request.get("runtime_generation")
|
||||
):
|
||||
return {
|
||||
"ok": False,
|
||||
"code": "MUTATOR_UNVERIFIED",
|
||||
"decision": "deny",
|
||||
"state": LEASE_UNVERIFIED,
|
||||
}
|
||||
expires_at = lease.get("expires_at")
|
||||
if not isinstance(expires_at, (int, float)) or time.monotonic() >= expires_at:
|
||||
self.revoke_session_authority(session_id)
|
||||
return {
|
||||
"ok": False,
|
||||
"code": "LEASE_EXPIRED",
|
||||
"decision": "deny",
|
||||
"state": LEASE_UNVERIFIED,
|
||||
}
|
||||
return {
|
||||
"ok": True,
|
||||
"decision": "allow",
|
||||
"state": LEASE_VERIFIED,
|
||||
}
|
||||
raise BrokerFailure("UNKNOWN_ACTION")
|
||||
|
||||
|
||||
|
||||
103
packages/mosaic/framework/tools/lease-broker/launch-runtime.py
Normal file
103
packages/mosaic/framework/tools/lease-broker/launch-runtime.py
Normal file
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Register a runtime parent with the lease broker, then exec without changing PID."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from pathlib import Path
|
||||
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]:
|
||||
payload = (json.dumps(request, separators=(",", ":")) + "\n").encode()
|
||||
response = bytearray()
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
||||
connection.settimeout(BROKER_TIMEOUT_SECONDS)
|
||||
connection.connect(str(socket_path))
|
||||
connection.sendall(payload)
|
||||
connection.shutdown(socket.SHUT_WR)
|
||||
while len(response) <= MAX_FRAME:
|
||||
chunk = connection.recv(min(4096, MAX_FRAME + 1 - len(response)))
|
||||
if not chunk:
|
||||
break
|
||||
response.extend(chunk)
|
||||
if len(response) > MAX_FRAME or not response.endswith(b"\n"):
|
||||
raise ValueError("invalid broker reply")
|
||||
value = json.loads(response)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("invalid broker reply")
|
||||
return value
|
||||
|
||||
|
||||
def main(
|
||||
argv: Sequence[str] | None = None,
|
||||
*,
|
||||
environ: Mapping[str, str] | None = None,
|
||||
request: Callable[[Path, dict[str, object]], dict[str, object]] = broker_request,
|
||||
execute: Callable[[str, list[str], dict[str, str]], object] = os.execvpe,
|
||||
) -> 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
|
||||
if command and command[0] == "--":
|
||||
command = command[1:]
|
||||
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:
|
||||
socket_path = Path(source_environment["MOSAIC_LEASE_BROKER_SOCKET"])
|
||||
generation = int(source_environment.get("MOSAIC_RUNTIME_GENERATION", "1"))
|
||||
if generation < 0:
|
||||
raise ValueError("invalid generation")
|
||||
reply = request(
|
||||
socket_path,
|
||||
{
|
||||
"action": "register_anchor",
|
||||
"runtime_generation": generation,
|
||||
},
|
||||
)
|
||||
session_id = reply.get("session_id")
|
||||
if (
|
||||
reply.get("ok") is not True
|
||||
or not isinstance(session_id, str)
|
||||
or len(session_id) != 64
|
||||
or any(character not in "0123456789abcdef" for character in session_id)
|
||||
):
|
||||
raise ValueError("registration refused")
|
||||
except (KeyError, ValueError, OSError, json.JSONDecodeError):
|
||||
print("Mosaic lease broker registration failed; runtime launch denied.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
environment = dict(source_environment)
|
||||
environment["MOSAIC_LEASE_SESSION_ID"] = session_id
|
||||
environment["MOSAIC_RUNTIME_GENERATION"] = str(generation)
|
||||
environment["MOSAIC_LEASE_RUNTIME"] = arguments.runtime
|
||||
try:
|
||||
execute(command[0], command, environment)
|
||||
except OSError:
|
||||
print("Mosaic lease-gated runtime exec failed.", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
100
packages/mosaic/framework/tools/lease-broker/mutator-gate.py
Normal file
100
packages/mosaic/framework/tools/lease-broker/mutator-gate.py
Normal file
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Runtime-neutral whole mutator-class gate backed by the Mosaic lease broker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from pathlib import Path
|
||||
from typing import BinaryIO, Final
|
||||
|
||||
MAX_FRAME: Final = 64 * 1024
|
||||
BROKER_TIMEOUT_SECONDS: Final = 1.5
|
||||
|
||||
|
||||
def deny(code: str) -> int:
|
||||
print(f"BLOCKED: Mosaic mutator gate denied this tool ({code}).", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
def read_tool_name(stream: BinaryIO | None = None) -> str:
|
||||
source = sys.stdin.buffer if stream is None else stream
|
||||
raw = source.read(MAX_FRAME + 1)
|
||||
if len(raw) > MAX_FRAME:
|
||||
raise ValueError("INVALID_GATE_INPUT")
|
||||
value = json.loads(raw)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("INVALID_GATE_INPUT")
|
||||
tool_name = value.get("tool_name")
|
||||
if not isinstance(tool_name, str) or not tool_name or len(tool_name) > 256:
|
||||
raise ValueError("INVALID_GATE_INPUT")
|
||||
return tool_name
|
||||
|
||||
|
||||
def broker_request(socket_path: Path, request: dict[str, object]) -> dict[str, object]:
|
||||
payload = (json.dumps(request, separators=(",", ":")) + "\n").encode()
|
||||
if len(payload) > MAX_FRAME:
|
||||
raise ValueError("INVALID_GATE_INPUT")
|
||||
response = bytearray()
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
||||
connection.settimeout(BROKER_TIMEOUT_SECONDS)
|
||||
connection.connect(str(socket_path))
|
||||
connection.sendall(payload)
|
||||
connection.shutdown(socket.SHUT_WR)
|
||||
while len(response) <= MAX_FRAME:
|
||||
chunk = connection.recv(min(4096, MAX_FRAME + 1 - len(response)))
|
||||
if not chunk:
|
||||
break
|
||||
response.extend(chunk)
|
||||
if len(response) > MAX_FRAME or not response.endswith(b"\n"):
|
||||
raise ValueError("INVALID_BROKER_REPLY")
|
||||
value = json.loads(response)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("INVALID_BROKER_REPLY")
|
||||
return value
|
||||
|
||||
|
||||
def main(
|
||||
argv: Sequence[str] | None = None,
|
||||
*,
|
||||
environ: Mapping[str, str] | None = None,
|
||||
stream: BinaryIO | None = None,
|
||||
request: Callable[[Path, dict[str, object]], dict[str, object]] = broker_request,
|
||||
) -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--runtime", required=True, choices=("claude", "pi"))
|
||||
arguments = parser.parse_args(argv)
|
||||
source_environment = os.environ if environ is None else environ
|
||||
|
||||
try:
|
||||
tool_name = read_tool_name(stream)
|
||||
socket_value = source_environment["MOSAIC_LEASE_BROKER_SOCKET"]
|
||||
session_id = source_environment["MOSAIC_LEASE_SESSION_ID"]
|
||||
generation = int(source_environment["MOSAIC_RUNTIME_GENERATION"])
|
||||
if generation < 0:
|
||||
raise ValueError("INVALID_GENERATION")
|
||||
reply = request(
|
||||
Path(socket_value),
|
||||
{
|
||||
"action": "authorize_tool",
|
||||
"session_id": session_id,
|
||||
"runtime_generation": generation,
|
||||
"runtime": arguments.runtime,
|
||||
"tool_name": tool_name,
|
||||
},
|
||||
)
|
||||
except (KeyError, ValueError, OSError, json.JSONDecodeError):
|
||||
return deny("GATE_UNAVAILABLE")
|
||||
|
||||
if reply.get("ok") is True and reply.get("decision") == "allow":
|
||||
return 0
|
||||
code = reply.get("code")
|
||||
return deny(code if isinstance(code, str) else "MUTATOR_UNVERIFIED")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user