lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

rshr_201_step_gate.py (21649B)


      1 #!/usr/bin/env python3
      2 """Emit the source-bound RSHR-201 gate result for Lib Step 292."""
      3 
      4 from __future__ import annotations
      5 
      6 import argparse
      7 import hashlib
      8 import json
      9 import os
     10 import re
     11 import selectors
     12 import signal
     13 import stat
     14 import subprocess
     15 import sys
     16 import time
     17 from pathlib import Path
     18 
     19 
     20 ROOT = Path(__file__).resolve().parent.parent
     21 AUTHORITY_PATH = ROOT / "contracts/rshr-201-step-gates.v1.json"
     22 ORIGIN = "ssh://git@github.com/radrootslabs/lib.git"
     23 BRANCH = "rshr/rcld-201"
     24 STEP = 292
     25 GATE_DEFINITION = (
     26     "timeout, orphan, output-cap, inherited-build-environment, "
     27     "loader-injection, and redaction vectors"
     28 )
     29 GATE_DIGEST = "f65be73a73a7ab8b0c8e02f0695ee5e910c73e0017572b82861c0c7f0d4fa454"
     30 CHECK_ID = f"gate-01-{GATE_DIGEST}"
     31 ASSERTION_ID = f"step_{STEP:03d}_gate_01_{GATE_DIGEST}"
     32 MAX_SOURCE_BYTES = 64 * 1024 * 1024
     33 MAX_STDOUT_BYTES = 16 * 1024 * 1024
     34 MAX_STDERR_BYTES = 16 * 1024 * 1024
     35 COMMAND_TIMEOUT_SECONDS = 3600.0
     36 STREAM_CHUNK_BYTES = 64 * 1024
     37 SELECT_INTERVAL_SECONDS = 0.1
     38 TERM_GRACE_SECONDS = 0.25
     39 KILL_GRACE_SECONDS = 0.5
     40 EXPECTED_ENVIRONMENT_AUTHORITY = {
     41     "cache_policy_id": "rshr-200-step-287-cache-policy.v1",
     42     "cache_policy_sha256": (
     43         "3e81d178bce97b6c349dfbb00c68fd6f620ac00b1a1c8d37b12e9998f3c9eaaa"
     44     ),
     45     "cadence_policy_id": "rshr-200-step-287-cadence-policy.v1",
     46     "cadence_policy_sha256": (
     47         "d24903df8659ee3772297c84994911efe7d21cb8b988320ddc6ddce0431892a1"
     48     ),
     49     "isolation": "extbuild_host_constrained",
     50     "network": "disabled",
     51     "network_policy_id": "none",
     52     "network_policy_sha256": "none",
     53     "resource_policy_id": "rshr-200-step-287-resource-policy.v1",
     54     "resource_policy_sha256": (
     55         "05d3c7a89185d3c55678d97955193fce2ed92b1eee5af99083d77ea64c98d14e"
     56     ),
     57 }
     58 EXPECTED_ENVIRONMENT_NAMES = [
     59     "EXT_BUILD_CONFIG",
     60     "EXT_BUILD_MACHINE_CONFIG",
     61     "EXT_BUILD_ROOT",
     62     "HOME",
     63     "PATH",
     64     "RUSTUP_TOOLCHAIN",
     65     "TMPDIR",
     66 ]
     67 EXPECTED_ARGV_TEMPLATE = [
     68     "cargo",
     69     "extbuild",
     70     "run",
     71     "--",
     72     "uv",
     73     "run",
     74     "--offline",
     75     "--no-project",
     76     "python3",
     77     "-B",
     78     "tools/rshr_201_step_gate.py",
     79     "--step={step}",
     80     "--check-id={check_id}",
     81     "--source-revision={source_revision}",
     82     "--source-tree={source_tree}",
     83     "--candidate-digest={candidate_digest}",
     84     "--platform=macos_aarch64",
     85     "--execution-request-sha256={execution_request_sha256}",
     86 ]
     87 
     88 
     89 class GateError(RuntimeError):
     90     """A fail-closed gate error whose message contains no protected data."""
     91 
     92 
     93 class RedactedArgumentParser(argparse.ArgumentParser):
     94     """Reject malformed input without reflecting argument values."""
     95 
     96     def error(self, _message: str) -> None:
     97         raise GateError("arguments are invalid")
     98 
     99 
    100 def canonical(value: object) -> bytes:
    101     return json.dumps(
    102         value,
    103         sort_keys=True,
    104         separators=(",", ":"),
    105         ensure_ascii=False,
    106         allow_nan=False,
    107     ).encode("utf-8")
    108 
    109 
    110 def sha256_bytes(contents: bytes) -> str:
    111     return hashlib.sha256(contents).hexdigest()
    112 
    113 
    114 def read_regular(path: Path, maximum: int = MAX_SOURCE_BYTES) -> bytes:
    115     try:
    116         relative = path.relative_to(ROOT)
    117     except ValueError as error:
    118         raise GateError("source path is outside the repository") from error
    119     if maximum < 0 or path.is_symlink():
    120         raise GateError(f"source path is not a bounded regular file: {relative}")
    121     try:
    122         descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
    123     except OSError as error:
    124         raise GateError(f"source path cannot be opened safely: {relative}") from error
    125     try:
    126         metadata = os.fstat(descriptor)
    127         if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > maximum:
    128             raise GateError(f"source path is not a bounded regular file: {relative}")
    129         contents = bytearray()
    130         while len(contents) <= maximum:
    131             chunk = os.read(
    132                 descriptor,
    133                 min(STREAM_CHUNK_BYTES, maximum - len(contents) + 1),
    134             )
    135             if not chunk:
    136                 break
    137             contents.extend(chunk)
    138         if len(contents) > maximum:
    139             raise GateError(f"source path exceeds its byte bound: {relative}")
    140         after = os.fstat(descriptor)
    141         if (
    142             metadata.st_dev,
    143             metadata.st_ino,
    144             metadata.st_size,
    145             metadata.st_mtime_ns,
    146         ) != (
    147             after.st_dev,
    148             after.st_ino,
    149             after.st_size,
    150             after.st_mtime_ns,
    151         ):
    152             raise GateError(f"source path changed during its bounded read: {relative}")
    153         return bytes(contents)
    154     except OSError as error:
    155         raise GateError(f"source path cannot be read safely: {relative}") from error
    156     finally:
    157         os.close(descriptor)
    158 
    159 
    160 def _process_group_exists(process_group: int) -> bool:
    161     try:
    162         os.killpg(process_group, 0)
    163     except ProcessLookupError:
    164         return False
    165     except PermissionError:
    166         return True
    167     return True
    168 
    169 
    170 def _signal_process_group(
    171     process: subprocess.Popen[bytes], process_signal: signal.Signals
    172 ) -> None:
    173     try:
    174         os.killpg(process.pid, process_signal)
    175     except ProcessLookupError:
    176         return
    177     except OSError:
    178         try:
    179             process.send_signal(process_signal)
    180         except OSError:
    181             return
    182 
    183 
    184 def _bounded_reap(process: subprocess.Popen[bytes], timeout: float) -> None:
    185     if process.poll() is not None:
    186         return
    187     try:
    188         process.wait(timeout=timeout)
    189     except (OSError, subprocess.TimeoutExpired):
    190         return
    191 
    192 
    193 def _terminate_process_group(process: subprocess.Popen[bytes]) -> None:
    194     """Apply bounded TERM/KILL cleanup to the isolated process group."""
    195 
    196     _signal_process_group(process, signal.SIGTERM)
    197     term_deadline = time.monotonic() + TERM_GRACE_SECONDS
    198     while _process_group_exists(process.pid) and time.monotonic() < term_deadline:
    199         _bounded_reap(
    200             process,
    201             min(SELECT_INTERVAL_SECONDS, max(0.001, term_deadline - time.monotonic())),
    202         )
    203         if process.poll() is not None:
    204             time.sleep(min(0.01, max(0.0, term_deadline - time.monotonic())))
    205     if _process_group_exists(process.pid):
    206         _signal_process_group(process, signal.SIGKILL)
    207     kill_deadline = time.monotonic() + KILL_GRACE_SECONDS
    208     while _process_group_exists(process.pid) and time.monotonic() < kill_deadline:
    209         _bounded_reap(
    210             process,
    211             min(SELECT_INTERVAL_SECONDS, max(0.001, kill_deadline - time.monotonic())),
    212         )
    213         if process.poll() is not None:
    214             time.sleep(min(0.01, max(0.0, kill_deadline - time.monotonic())))
    215     _bounded_reap(process, max(0.001, kill_deadline - time.monotonic()))
    216     if process.poll() is None or _process_group_exists(process.pid):
    217         raise GateError("bounded command cleanup did not reach a terminal state")
    218 
    219 
    220 def run(
    221     arguments: list[str],
    222     environment: dict[str, str],
    223     *,
    224     label: str,
    225     maximum_stdout: int = MAX_STDOUT_BYTES,
    226     maximum_stderr: int = MAX_STDERR_BYTES,
    227     timeout_seconds: float = COMMAND_TIMEOUT_SECONDS,
    228 ) -> bytes:
    229     """Run one isolated command with live dual-stream caps and redacted errors."""
    230 
    231     if (
    232         not arguments
    233         or not label
    234         or maximum_stdout < 0
    235         or maximum_stderr < 0
    236         or timeout_seconds <= 0
    237         or any(
    238             not isinstance(argument, str)
    239             or not argument
    240             or "\x00" in argument
    241             or "\r" in argument
    242             or "\n" in argument
    243             for argument in arguments
    244         )
    245         or any(
    246             not isinstance(name, str)
    247             or not name
    248             or "=" in name
    249             or "\x00" in name
    250             or not isinstance(value, str)
    251             or "\x00" in value
    252             for name, value in environment.items()
    253         )
    254     ):
    255         raise GateError(f"{label} request is invalid")
    256 
    257     process: subprocess.Popen[bytes] | None = None
    258     selector = selectors.DefaultSelector()
    259     streams: list[object] = []
    260     try:
    261         try:
    262             process = subprocess.Popen(
    263                 arguments,
    264                 cwd=ROOT,
    265                 env=dict(environment),
    266                 stdin=subprocess.DEVNULL,
    267                 stdout=subprocess.PIPE,
    268                 stderr=subprocess.PIPE,
    269                 close_fds=True,
    270                 start_new_session=True,
    271             )
    272         except OSError as error:
    273             raise GateError(f"{label} could not start") from error
    274 
    275         assert process.stdout is not None
    276         assert process.stderr is not None
    277         streams.extend((process.stdout, process.stderr))
    278         destinations = {
    279             process.stdout: ("stdout", maximum_stdout, bytearray()),
    280             process.stderr: ("stderr", maximum_stderr, bytearray()),
    281         }
    282         for stream in destinations:
    283             os.set_blocking(stream.fileno(), False)
    284             selector.register(stream, selectors.EVENT_READ)
    285 
    286         deadline = time.monotonic() + timeout_seconds
    287         while selector.get_map():
    288             remaining = deadline - time.monotonic()
    289             if remaining <= 0:
    290                 raise GateError(f"{label} exceeded its deadline")
    291             if process.poll() is not None and _process_group_exists(process.pid):
    292                 raise GateError(f"{label} left a running process group")
    293             events = selector.select(min(SELECT_INTERVAL_SECONDS, remaining))
    294             if not events:
    295                 if process.poll() is not None and _process_group_exists(process.pid):
    296                     raise GateError(f"{label} left a running process group")
    297                 continue
    298             for key, _mask in events:
    299                 stream = key.fileobj
    300                 stream_name, maximum, destination = destinations[stream]
    301                 read_size = min(
    302                     STREAM_CHUNK_BYTES,
    303                     max(1, maximum - len(destination) + 1),
    304                 )
    305                 try:
    306                     chunk = os.read(stream.fileno(), read_size)
    307                 except BlockingIOError:
    308                     continue
    309                 except OSError as error:
    310                     raise GateError(f"{label} stream read failed") from error
    311                 if not chunk:
    312                     selector.unregister(stream)
    313                     continue
    314                 destination.extend(chunk)
    315                 if len(destination) > maximum:
    316                     raise GateError(f"{label} {stream_name} exceeded its byte bound")
    317 
    318         remaining = deadline - time.monotonic()
    319         if remaining <= 0:
    320             raise GateError(f"{label} exceeded its deadline")
    321         try:
    322             return_code = process.wait(timeout=remaining)
    323         except subprocess.TimeoutExpired as error:
    324             raise GateError(f"{label} exceeded its deadline") from error
    325 
    326         if _process_group_exists(process.pid):
    327             raise GateError(f"{label} left a running process group")
    328         if return_code != 0:
    329             raise GateError(f"{label} failed with exit status {return_code}")
    330         return bytes(destinations[process.stdout][2])
    331     except GateError:
    332         if process is not None:
    333             _terminate_process_group(process)
    334         raise
    335     except (OSError, subprocess.SubprocessError) as error:
    336         if process is not None:
    337             _terminate_process_group(process)
    338         raise GateError(f"{label} failed safely") from error
    339     except BaseException:
    340         if process is not None:
    341             _terminate_process_group(process)
    342         raise
    343     finally:
    344         selector.close()
    345         for stream in streams:
    346             try:
    347                 stream.close()  # type: ignore[attr-defined]
    348             except OSError:
    349                 pass
    350 
    351 
    352 def git_environment() -> dict[str, str]:
    353     return {
    354         "GIT_ATTR_NOSYSTEM": "1",
    355         "GIT_CONFIG_GLOBAL": os.devnull,
    356         "GIT_CONFIG_NOSYSTEM": "1",
    357         "GIT_NO_LAZY_FETCH": "1",
    358         "GIT_NO_REPLACE_OBJECTS": "1",
    359         "GIT_OPTIONAL_LOCKS": "0",
    360         "GIT_PAGER": "cat",
    361         "GIT_TERMINAL_PROMPT": "0",
    362         "HOME": os.environ.get("HOME", os.devnull),
    363         "LANG": "C",
    364         "LC_ALL": "C",
    365         "PATH": os.environ.get("PATH", os.defpath),
    366     }
    367 
    368 
    369 def git_bytes(*arguments: str, maximum: int = MAX_SOURCE_BYTES) -> bytes:
    370     return run(
    371         ["git", "--no-pager", "--literal-pathspecs", *arguments],
    372         git_environment(),
    373         label="source identity inspection",
    374         maximum_stdout=maximum,
    375         maximum_stderr=0,
    376         timeout_seconds=60.0,
    377     )
    378 
    379 
    380 def git(*arguments: str) -> str:
    381     try:
    382         return git_bytes(*arguments).decode("utf-8", "strict").strip()
    383     except UnicodeError as error:
    384         raise GateError("source identity is not UTF-8") from error
    385 
    386 
    387 def require_source_state(source_revision: str, source_tree: str) -> None:
    388     if (
    389         git("rev-parse", "HEAD") != source_revision
    390         or git("rev-parse", "HEAD^{tree}") != source_tree
    391         or git("symbolic-ref", "--short", "HEAD") != BRANCH
    392         or git("remote", "get-url", "origin") != ORIGIN
    393         or git("rev-parse", f"refs/remotes/origin/{BRANCH}") != source_revision
    394         or git_bytes("status", "--porcelain=v1", "-z", "--untracked-files=all")
    395     ):
    396         raise GateError("Lib source is not clean and tracking-exact")
    397 
    398     tracked = git_bytes("ls-files", "-z").split(b"\0")
    399     if any(path == b".github" or path.startswith(b".github/") for path in tracked):
    400         raise GateError("forbidden .github surface is tracked")
    401     if os.path.lexists(ROOT / ".github"):
    402         raise GateError("forbidden .github surface is present")
    403 
    404 
    405 def gate_environment() -> dict[str, str]:
    406     allowed = {
    407         "CARGO_HOME",
    408         "CARGO_PROFILE_DEV_DEBUG",
    409         "CARGO_TARGET_DIR",
    410         "EXT_BUILD_CONFIG",
    411         "EXT_BUILD_CARGO_TIMINGS_DIR",
    412         "EXT_BUILD_MACHINE_CONFIG",
    413         "EXT_BUILD_ROOT",
    414         "HOME",
    415         "LANG",
    416         "LC_ALL",
    417         "PATH",
    418         "RUSTUP_HOME",
    419         "RUSTUP_TOOLCHAIN",
    420         "SCCACHE_DIR",
    421         "SDKROOT",
    422         "TMPDIR",
    423     }
    424     environment = {name: value for name, value in os.environ.items() if name in allowed}
    425     environment.update(
    426         {
    427             "CARGO_NET_OFFLINE": "true",
    428             "CARGO_TERM_COLOR": "never",
    429             "LANG": "C",
    430             "LC_ALL": "C",
    431         }
    432     )
    433     if not environment.get("PATH"):
    434         environment["PATH"] = os.defpath
    435     extbuild_root_value = environment.get("EXT_BUILD_ROOT")
    436     routed_path_names = (
    437         "CARGO_TARGET_DIR",
    438         "EXT_BUILD_CARGO_TIMINGS_DIR",
    439         "SCCACHE_DIR",
    440     )
    441     routed_values = [environment[name] for name in routed_path_names if name in environment]
    442     if routed_values:
    443         if not extbuild_root_value:
    444             raise GateError("extbuild routing environment is incomplete")
    445         extbuild_root_input = Path(extbuild_root_value)
    446         if not extbuild_root_input.is_absolute():
    447             raise GateError("extbuild routing environment is invalid")
    448         extbuild_root = Path(os.path.abspath(extbuild_root_value))
    449         for value in routed_values:
    450             destination_input = Path(value)
    451             if not destination_input.is_absolute():
    452                 raise GateError("extbuild routing environment is invalid")
    453             destination = Path(os.path.abspath(value))
    454             if (
    455                 destination != extbuild_root and extbuild_root not in destination.parents
    456             ):
    457                 raise GateError("extbuild routing environment is invalid")
    458     return environment
    459 
    460 
    461 def run_step() -> None:
    462     environment = gate_environment()
    463     run(
    464         [
    465             "cargo",
    466             "+1.97.1",
    467             "test",
    468             "--offline",
    469             "--manifest-path",
    470             "tools/xtask/Cargo.toml",
    471             "--locked",
    472             "--bin",
    473             "xtask",
    474             "bounded_process",
    475             "--",
    476             "--test-threads=1",
    477         ],
    478         environment,
    479         label="bounded-process vector tests",
    480     )
    481     run(
    482         [
    483             "cargo",
    484             "+1.97.1",
    485             "run",
    486             "--offline",
    487             "--manifest-path",
    488             "tools/xtask/Cargo.toml",
    489             "--locked",
    490             "--",
    491             "bounded-process-self-test",
    492         ],
    493         environment,
    494         label="bounded-process self-test",
    495     )
    496     run(
    497         [
    498             "cargo",
    499             "+1.97.1",
    500             "test",
    501             "--offline",
    502             "--manifest-path",
    503             "tools/xtask/Cargo.toml",
    504             "--locked",
    505             "--test",
    506             "services_hardening_bounded_process_decision",
    507             "--",
    508             "--test-threads=1",
    509         ],
    510         environment,
    511         label="bounded-process decision contract",
    512     )
    513     if git_bytes("status", "--porcelain=v1", "-z", "--untracked-files=all"):
    514         raise GateError("verification changed the tracked or untracked source state")
    515 
    516 
    517 def parse_arguments() -> argparse.Namespace:
    518     parser = RedactedArgumentParser(allow_abbrev=False)
    519     parser.add_argument("--step", type=int, required=True)
    520     parser.add_argument("--check-id")
    521     parser.add_argument("--source-revision", required=True)
    522     parser.add_argument("--source-tree", required=True)
    523     parser.add_argument("--candidate-digest")
    524     parser.add_argument("--platform", required=True)
    525     parser.add_argument("--execution-request-sha256", required=True)
    526     return parser.parse_args()
    527 
    528 
    529 def validate_digest(value: str, label: str, length: int) -> None:
    530     if re.fullmatch(rf"[0-9a-f]{{{length}}}", value) is None:
    531         raise GateError(f"{label} is not canonical")
    532 
    533 
    534 def expected_contract(verifier_digest: str) -> dict[str, object]:
    535     return {
    536         "argv_template": EXPECTED_ARGV_TEMPLATE,
    537         "assertion_id": [ASSERTION_ID],
    538         "check_id": CHECK_ID,
    539         "environment_authority": EXPECTED_ENVIRONMENT_AUTHORITY,
    540         "environment_names": EXPECTED_ENVIRONMENT_NAMES,
    541         "gate_definition_sha256": GATE_DIGEST,
    542         "required_platforms": ["macos_aarch64"],
    543         "required_tools": ["uv", "python3", "git"],
    544         "result_schema": "radroots.services-hardening.rshr-200-step-check-result.v1",
    545         "schema": "radroots.services-hardening.rshr-200-step-check-command.v1",
    546         "step": STEP,
    547         "verifier_path": "tools/rshr_201_step_gate.py",
    548         "verifier_sha256": verifier_digest,
    549     }
    550 
    551 
    552 def main() -> int:
    553     arguments = parse_arguments()
    554     if arguments.step != STEP:
    555         raise GateError("step is outside the Lib gate authority")
    556     validate_digest(arguments.source_revision, "source revision", 40)
    557     validate_digest(arguments.source_tree, "source tree", 40)
    558     validate_digest(arguments.execution_request_sha256, "execution request", 64)
    559     if sha256_bytes(GATE_DEFINITION.encode("utf-8")) != GATE_DIGEST:
    560         raise GateError("compiled gate definition digest differs")
    561     if arguments.check_id != CHECK_ID:
    562         raise GateError("check identity differs")
    563     if arguments.candidate_digest != "none" or arguments.platform != "macos_aarch64":
    564         raise GateError("candidate or platform scope differs")
    565 
    566     authority_bytes = read_regular(AUTHORITY_PATH, 256 * 1024)
    567     try:
    568         authority = json.loads(authority_bytes)
    569     except (UnicodeError, json.JSONDecodeError) as error:
    570         raise GateError("gate authority is not canonical JSON") from error
    571     if canonical(authority) + b"\n" != authority_bytes:
    572         raise GateError("gate authority is not canonical JSON")
    573     if (
    574         not isinstance(authority, dict)
    575         or set(authority) != {"schema", "step", "gate_command_contract"}
    576         or authority.get("schema") != "radroots.lib.rshr-201-step-gates.v1"
    577         or authority.get("step") != [STEP]
    578     ):
    579         raise GateError("gate authority step inventory differs")
    580     contracts = authority.get("gate_command_contract")
    581     if not isinstance(contracts, list) or len(contracts) != 1:
    582         raise GateError("gate command authority is absent or duplicated")
    583 
    584     verifier_digest = sha256_bytes(read_regular(Path(__file__).resolve()))
    585     contract = contracts[0]
    586     if contract != expected_contract(verifier_digest):
    587         raise GateError("gate command authority differs from source bytes")
    588 
    589     require_source_state(arguments.source_revision, arguments.source_tree)
    590     run_step()
    591     assertions = [{"id": ASSERTION_ID, "result": "pass"}]
    592     result = {
    593         "schema": "radroots.services-hardening.rshr-200-step-check-result.v1",
    594         "step": STEP,
    595         "check_id": CHECK_ID,
    596         "gate_definition_sha256": GATE_DIGEST,
    597         "source_revision": arguments.source_revision,
    598         "source_tree": arguments.source_tree,
    599         "candidate_generation": 0,
    600         "candidate_digest": "none",
    601         "command_contract_sha256": sha256_bytes(canonical(contract)),
    602         "verifier_sha256": verifier_digest,
    603         "execution_request": [
    604             {
    605                 "platform": arguments.platform,
    606                 "sha256": arguments.execution_request_sha256,
    607             }
    608         ],
    609         "assertion_inventory_sha256": sha256_bytes(canonical(assertions)),
    610         "assertion": assertions,
    611         "result": "pass",
    612     }
    613     sys.stdout.buffer.write(canonical(result) + b"\n")
    614     return 0
    615 
    616 
    617 if __name__ == "__main__":
    618     try:
    619         raise SystemExit(main())
    620     except GateError as error:
    621         print(f"Lib RSHR-201 gate failed: {error}", file=sys.stderr)
    622         raise SystemExit(1)
    623     except Exception:
    624         print("Lib RSHR-201 gate failed safely", file=sys.stderr)
    625         raise SystemExit(1)