rshr_201_step_gate.py (14939B)
1 #!/usr/bin/env python3 2 """Emit the source-bound RSHR-201 gate result for HarvestCircle Steps 289/290.""" 3 4 from __future__ import annotations 5 6 import argparse 7 import hashlib 8 import json 9 import os 10 import re 11 import signal 12 import stat 13 import subprocess 14 import sys 15 import tempfile 16 from pathlib import Path 17 18 19 ROOT = Path(__file__).resolve().parent.parent 20 AUTHORITY_PATH = ROOT / "contracts/rshr-201-step-gates.v1.json" 21 ORIGIN = "ssh://git@github.com/radrootslabs/harvestcircle.git" 22 BRANCH = "rshr/rcld-201" 23 MAX_SOURCE_BYTES = 64 * 1024 * 1024 24 MAX_STREAM_BYTES = 64 * 1024 * 1024 25 TIMEOUT_SECONDS = 3600 26 GATE_DEFINITIONS = { 27 289: "focused Kotlin and source-lock mutations", 28 290: "focused Gradle and unsigned-package negatives", 29 } 30 31 32 class GateError(RuntimeError): 33 """A fail-closed gate error.""" 34 35 36 def canonical(value: object) -> bytes: 37 return json.dumps( 38 value, 39 sort_keys=True, 40 separators=(",", ":"), 41 ensure_ascii=False, 42 allow_nan=False, 43 ).encode("utf-8") 44 45 46 def sha256_bytes(contents: bytes) -> str: 47 return hashlib.sha256(contents).hexdigest() 48 49 50 def read_regular(path: Path, maximum: int = MAX_SOURCE_BYTES) -> bytes: 51 relative = path.relative_to(ROOT) 52 if path.is_symlink(): 53 raise GateError(f"source path is a symbolic link: {relative}") 54 descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) 55 try: 56 metadata = os.fstat(descriptor) 57 if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > maximum: 58 raise GateError(f"source path is not a bounded regular file: {relative}") 59 chunks: list[bytes] = [] 60 remaining = maximum + 1 61 while remaining: 62 chunk = os.read(descriptor, min(1024 * 1024, remaining)) 63 if not chunk: 64 break 65 chunks.append(chunk) 66 remaining -= len(chunk) 67 contents = b"".join(chunks) 68 if len(contents) > maximum: 69 raise GateError(f"source path exceeds its byte bound: {relative}") 70 after = os.fstat(descriptor) 71 if (metadata.st_dev, metadata.st_ino, metadata.st_size) != ( 72 after.st_dev, 73 after.st_ino, 74 after.st_size, 75 ): 76 raise GateError(f"source path changed during read: {relative}") 77 return contents 78 finally: 79 os.close(descriptor) 80 81 82 def run(arguments: list[str], environment: dict[str, str] | None = None) -> bytes: 83 with tempfile.TemporaryFile() as stdout, tempfile.TemporaryFile() as stderr: 84 process = subprocess.Popen( 85 arguments, 86 cwd=ROOT, 87 env=environment, 88 stdin=subprocess.DEVNULL, 89 stdout=stdout, 90 stderr=stderr, 91 start_new_session=True, 92 ) 93 try: 94 return_code = process.wait(timeout=TIMEOUT_SECONDS) 95 except subprocess.TimeoutExpired as error: 96 os.killpg(process.pid, signal.SIGTERM) 97 try: 98 process.wait(timeout=1) 99 except subprocess.TimeoutExpired: 100 os.killpg(process.pid, signal.SIGKILL) 101 process.wait(timeout=1) 102 raise GateError(f"command exceeded {TIMEOUT_SECONDS}s: {arguments!r}") from error 103 stdout.seek(0, os.SEEK_END) 104 stderr.seek(0, os.SEEK_END) 105 stdout_size = stdout.tell() 106 stderr_size = stderr.tell() 107 if stdout_size > MAX_STREAM_BYTES or stderr_size > MAX_STREAM_BYTES: 108 raise GateError(f"command output exceeded its bound: {arguments!r}") 109 stdout.seek(0) 110 stderr.seek(0) 111 captured_stdout = stdout.read() 112 captured_stderr = stderr.read() 113 if return_code: 114 detail = (captured_stdout + captured_stderr)[-8192:].decode("utf-8", "replace") 115 raise GateError(f"command failed ({return_code}): {arguments!r}\n{detail}") 116 return captured_stdout 117 118 119 def git(*arguments: str) -> str: 120 return run(["git", *arguments]).decode("utf-8", "strict").strip() 121 122 123 def require_source_state(source_revision: str, source_tree: str) -> None: 124 if ( 125 git("rev-parse", "HEAD") != source_revision 126 or git("rev-parse", "HEAD^{tree}") != source_tree 127 or git("symbolic-ref", "--short", "HEAD") != BRANCH 128 or git("remote", "get-url", "origin") != ORIGIN 129 or git("rev-parse", f"origin/{BRANCH}") != source_revision 130 or run( 131 [ 132 "git", 133 "status", 134 "--porcelain=v1", 135 "-z", 136 "--untracked-files=all", 137 ] 138 ) 139 ): 140 raise GateError("HarvestCircle source is not clean and tracking-exact") 141 forbidden = git("ls-files", ".github", ".github/**") 142 if forbidden or (ROOT / ".github").exists(): 143 raise GateError("forbidden .github surface is present") 144 145 146 def source_lock_revision() -> str: 147 source_lock = read_regular(ROOT / "radroots.lib.source-lock.v1.toml", 16 * 1024) 148 try: 149 text = source_lock.decode("utf-8", "strict") 150 except UnicodeError as error: 151 raise GateError("Lib source lock is not UTF-8") from error 152 values = dict( 153 re.findall(r'^([a-z0-9_]+) = "([^"\r\n]+)"$', text, flags=re.MULTILINE) 154 ) 155 if values.get("schema") != "radroots.lib.source-lock.v1": 156 raise GateError("Lib source-lock schema differs") 157 if values.get("lockfile") != "core/Cargo.lock": 158 raise GateError("Lib source-lock path differs") 159 lockfile = read_regular(ROOT / values["lockfile"]) 160 if values.get("lockfile_sha256") != sha256_bytes(lockfile): 161 raise GateError("Lib source-lock digest differs from actual bounded bytes") 162 revision = values.get("revision", "") 163 if re.fullmatch(r"[0-9a-f]{40}", revision) is None: 164 raise GateError("Lib source-lock revision is not canonical") 165 return revision 166 167 168 def gate_environment(source_revision: str) -> dict[str, str]: 169 allowed = { 170 "CARGO_HOME", 171 "EXT_BUILD_CONFIG", 172 "EXT_BUILD_MACHINE_CONFIG", 173 "EXT_BUILD_ROOT", 174 "GRADLE_USER_HOME", 175 "HOME", 176 "JAVA_HOME", 177 "LANG", 178 "LC_ALL", 179 "PATH", 180 "RUSTUP_HOME", 181 "RUSTUP_TOOLCHAIN", 182 "SDKROOT", 183 "TMPDIR", 184 "XCODE_DEVELOPER_DIR", 185 "XCODE_DERIVED_DATA", 186 "XCODE_PACKAGE_CACHE", 187 "XCODE_SOURCE_PACKAGES", 188 } 189 environment = {name: value for name, value in os.environ.items() if name in allowed} 190 environment.update( 191 { 192 "HARVESTCIRCLE_BUILD_SOURCE_COMMIT": source_revision, 193 "HARVESTCIRCLE_BUILD_SOURCE_DIRTY": "false", 194 "HARVESTCIRCLE_BUILD_RADROOTS_REVISION": source_lock_revision(), 195 "HARVESTCIRCLE_BUILD_RUST_TOOLCHAIN": "1.97.1", 196 "SOURCE_DATE_EPOCH": git("show", "-s", "--format=%ct", source_revision), 197 } 198 ) 199 return environment 200 201 202 def run_step(step: int, source_revision: str) -> None: 203 environment = gate_environment(source_revision) 204 if step == 289: 205 run( 206 [ 207 "./gradlew", 208 "--offline", 209 "--no-daemon", 210 "-p", 211 "build-logic", 212 ":contracts:test", 213 "--tests", 214 "org.harvestcircle.buildlogic.contracts.BuildContractsTest", 215 ], 216 environment, 217 ) 218 run( 219 [ 220 "cargo", 221 "+1.97.1", 222 "test", 223 "--offline", 224 "--manifest-path", 225 "tools/xtask/Cargo.toml", 226 "--locked", 227 "source_lock", 228 ], 229 environment, 230 ) 231 elif step == 290: 232 run(["tools/test-build-modes.sh"], environment) 233 run( 234 [ 235 "./gradlew", 236 "--offline", 237 "--no-daemon", 238 "-p", 239 "build-logic", 240 ":contracts:test", 241 ":plugins:test", 242 ":plugins:functionalTest", 243 ], 244 environment, 245 ) 246 run( 247 [ 248 "./gradlew", 249 "--offline", 250 "--no-daemon", 251 "--no-parallel", 252 "--no-configuration-cache", 253 ":app:desktop:unsignedReleaseReadiness", 254 ], 255 environment, 256 ) 257 else: 258 raise GateError(f"unsupported step: {step}") 259 if run( 260 ["git", "status", "--porcelain=v1", "-z", "--untracked-files=all"] 261 ): 262 raise GateError("verification changed the tracked or untracked source state") 263 264 265 def parse_arguments() -> argparse.Namespace: 266 parser = argparse.ArgumentParser(allow_abbrev=False) 267 parser.add_argument("--emit-platform-result", action="store_true") 268 parser.add_argument("--step", type=int, required=True) 269 parser.add_argument("--check-id") 270 parser.add_argument("--source-revision", required=True) 271 parser.add_argument("--source-tree", required=True) 272 parser.add_argument("--candidate-digest") 273 parser.add_argument("--platform", required=True) 274 parser.add_argument("--execution-request-sha256", required=True) 275 return parser.parse_args() 276 277 278 def emit_platform_result(arguments: argparse.Namespace) -> int: 279 if arguments.check_id is not None or arguments.candidate_digest is not None: 280 raise GateError("platform probe received gate-only arguments") 281 require_source_state(arguments.source_revision, arguments.source_tree) 282 identity = os.uname() 283 if ( 284 arguments.platform != "macos_aarch64" 285 or identity.sysname != "Darwin" 286 or identity.machine not in {"arm64", "aarch64"} 287 ): 288 raise GateError("platform probe is not running on macOS aarch64") 289 xcode_identity = { 290 "xcodebuild": run(["/usr/bin/xcodebuild", "-version"]).decode( 291 "utf-8", "strict" 292 ), 293 "sdk": { 294 sdk: run( 295 ["/usr/bin/xcrun", "--sdk", sdk, "--show-sdk-version"] 296 ).decode("utf-8", "strict") 297 for sdk in ("iphoneos", "iphonesimulator", "macosx") 298 }, 299 } 300 verifier_path = Path(__file__).resolve() 301 result = { 302 "schema": "radroots.services-hardening.rshr-200-platform-result.v1", 303 "platform": "macos_aarch64", 304 "system": "aarch64-darwin", 305 "os_family": "macos", 306 "architecture": "aarch64", 307 "kernel_name": "Darwin", 308 "kernel_release": identity.release, 309 "os_build_sha256": sha256_bytes( 310 canonical( 311 { 312 "kernel_name": identity.sysname, 313 "kernel_release": identity.release, 314 "kernel_version": identity.version, 315 } 316 ) 317 ), 318 "runner_kind": "host", 319 "runner_image_sha256": "none", 320 "apple_toolchain_identity_sha256": sha256_bytes(canonical(xcode_identity)), 321 "probe_source_path": verifier_path.relative_to(ROOT).as_posix(), 322 "probe_source_sha256": sha256_bytes(read_regular(verifier_path)), 323 "execution_request_sha256": arguments.execution_request_sha256, 324 "assertion": [ 325 {"id": identifier, "result": "pass"} 326 for identifier in ( 327 "os_family", 328 "architecture", 329 "kernel_identity", 330 "runner_identity", 331 "apple_identity", 332 ) 333 ], 334 "result": "available", 335 } 336 sys.stdout.buffer.write(canonical(result)) 337 return 0 338 339 340 def main() -> int: 341 arguments = parse_arguments() 342 if arguments.step not in GATE_DEFINITIONS: 343 raise GateError("step is outside the HarvestCircle gate authority") 344 for value, label in ( 345 (arguments.source_revision, "source revision"), 346 (arguments.source_tree, "source tree"), 347 (arguments.execution_request_sha256, "execution request"), 348 ): 349 expected_length = 40 if label in {"source revision", "source tree"} else 64 350 if re.fullmatch(rf"[0-9a-f]{{{expected_length}}}", value) is None: 351 raise GateError(f"{label} is not canonical") 352 if arguments.emit_platform_result: 353 return emit_platform_result(arguments) 354 if arguments.check_id is None or re.fullmatch( 355 r"gate-01-[0-9a-f]{64}", arguments.check_id 356 ) is None: 357 raise GateError("check digest is not canonical") 358 if arguments.candidate_digest != "none" or arguments.platform != "macos_aarch64": 359 raise GateError("candidate or platform scope differs") 360 authority_bytes = read_regular(AUTHORITY_PATH, 256 * 1024) 361 authority = json.loads(authority_bytes) 362 if canonical(authority) + b"\n" != authority_bytes: 363 raise GateError("gate authority is not canonical JSON") 364 selected = [ 365 row 366 for row in authority.get("gate_command_contract", []) 367 if row.get("step") == arguments.step 368 ] 369 if len(selected) != 1: 370 raise GateError("gate command authority is absent or duplicated") 371 contract = selected[0] 372 gate_digest = sha256_bytes(GATE_DEFINITIONS[arguments.step].encode("utf-8")) 373 verifier_digest = sha256_bytes(read_regular(Path(__file__).resolve())) 374 assertion_id = f"step_{arguments.step:03d}_gate_01_{gate_digest}" 375 if ( 376 contract.get("check_id") != arguments.check_id 377 or contract.get("gate_definition_sha256") != gate_digest 378 or contract.get("verifier_sha256") != verifier_digest 379 or contract.get("assertion_id") != [assertion_id] 380 ): 381 raise GateError("gate command authority differs from source bytes") 382 require_source_state(arguments.source_revision, arguments.source_tree) 383 run_step(arguments.step, arguments.source_revision) 384 assertions = [{"id": assertion_id, "result": "pass"}] 385 result = { 386 "schema": "radroots.services-hardening.rshr-200-step-check-result.v1", 387 "step": arguments.step, 388 "check_id": arguments.check_id, 389 "gate_definition_sha256": gate_digest, 390 "source_revision": arguments.source_revision, 391 "source_tree": arguments.source_tree, 392 "candidate_generation": 0, 393 "candidate_digest": "none", 394 "command_contract_sha256": sha256_bytes(canonical(contract)), 395 "verifier_sha256": verifier_digest, 396 "execution_request": [ 397 { 398 "platform": arguments.platform, 399 "sha256": arguments.execution_request_sha256, 400 } 401 ], 402 "assertion_inventory_sha256": sha256_bytes(canonical(assertions)), 403 "assertion": assertions, 404 "result": "pass", 405 } 406 sys.stdout.buffer.write(canonical(result) + b"\n") 407 return 0 408 409 410 if __name__ == "__main__": 411 try: 412 raise SystemExit(main()) 413 except (GateError, OSError, ValueError, subprocess.SubprocessError) as error: 414 print(f"HarvestCircle RSHR-201 gate failed: {error}", file=sys.stderr) 415 raise SystemExit(1)