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)