#!/usr/bin/env python3 """Direct Whitehat owner interface for WP-0025 broker readiness.""" from __future__ import annotations import argparse import json import os import re import subprocess import sys from datetime import UTC, datetime from pathlib import Path from typing import Any, Callable SCRIPT_DIR = Path(__file__).resolve().parent if str(SCRIPT_DIR) not in sys.path: sys.path.insert(0, str(SCRIPT_DIR)) from custody_contract import ( # noqa: E402 BROKER_INTERFACE, BROKER_OWNER, PROJECTION_INTERFACE, WORKPLAN_ID, ContractError, broker_subject, canonical_json, contract_digest, current_broker_receipt, file_digest, http_json, interface_artifacts, load_json, validate_broker_receipt, validate_projection_contract, ) DEFAULT_ADAPTER = Path("src/whitehat_security/platform_custody.py") DEFAULT_TEST = Path("tests/test_platform_custody_adapter.py") class ReadinessError(RuntimeError): pass def validate_reviewer(value: str) -> str: if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:@/+\-]{1,127}", value): raise ReadinessError("reviewer must be a stable 2-128 character identifier") return value def validate_note(value: str) -> str: note = value.strip() if not note or len(note) > 2000: raise ReadinessError("request-changes requires a note of 1-2000 characters") forbidden = ("BEGIN PRIVATE KEY", "BEGIN OPENSSH PRIVATE KEY", "AGE-SECRET-KEY-1", "hvs.") if any(marker in note for marker in forbidden): raise ReadinessError("note appears to contain credential material") return note def safe_child(root: Path, relative: Path) -> Path: if relative.is_absolute(): raise ReadinessError("adapter and test paths must be repository-relative") target = (root / relative).resolve() try: target.relative_to(root.resolve()) except ValueError as exc: raise ReadinessError("consumer artifact escapes repository root") from exc return target def verify_adapter( consumer_root: Path, *, adapter_path: Path = DEFAULT_ADAPTER, test_path: Path = DEFAULT_TEST, runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run, ) -> dict[str, Any]: root = consumer_root.resolve() adapter = safe_child(root, adapter_path) test = safe_child(root, test_path) if not adapter.is_file() or not test.is_file(): return { "passed": False, "adapter_present": adapter.is_file(), "test_present": test.is_file(), "secret_values_observed": False, } revision_result = runner( ["git", "rev-parse", "HEAD"], cwd=root, text=True, capture_output=True, check=False, ) revision = revision_result.stdout.strip() if revision_result.returncode == 0 else "" tests = runner( [sys.executable, "-m", "pytest", "-q", str(test_path)], cwd=root, text=True, capture_output=True, check=False, ) passed = bool(re.fullmatch(r"[0-9a-f]{40}", revision)) and tests.returncode == 0 return { "passed": passed, "adapter_present": True, "test_present": True, "adapter": { "repo": "whitehat-security", "revision": revision, "path": str(adapter_path), "sha256": file_digest(adapter), "tests_passed": tests.returncode == 0, }, "test_exit_code": tests.returncode, "secret_values_observed": False, } def receipt_base( contract: dict[str, Any], reviewer: str, decision: str, *, now: datetime | None = None, ) -> dict[str, Any]: created_at = now or datetime.now(UTC) return { "interface": BROKER_INTERFACE, "version": 1, "workplan_id": WORKPLAN_ID, "owner": BROKER_OWNER, "reviewer": validate_reviewer(reviewer), "decision": decision, "created_at": created_at.replace(microsecond=0).isoformat().replace("+00:00", "Z"), "engagement_id": contract["engagement_id"], "target_id": contract["target"]["id"], "projection_contract_digest": contract_digest(contract), "projection_receipt_interface": PROJECTION_INTERFACE, "interface_artifacts": interface_artifacts(), "required_roles": ["attacker", "owner"], "mount_paths": sorted(item["mount_path"] for item in contract["identities"]), "secret_values_observed": False, } def build_approval( contract: dict[str, Any], reviewer: str, verification: dict[str, Any], *, now: datetime | None = None, ) -> dict[str, Any]: if verification.get("passed") is not True or verification.get("secret_values_observed") is not False: raise ReadinessError("consumer adapter verification did not pass") receipt = { **receipt_base(contract, reviewer, "approve", now=now), "adapter": verification["adapter"], "cleanup_request_supported": True, } validate_broker_receipt(receipt, contract, now=now) return receipt def build_change_request( contract: dict[str, Any], reviewer: str, note: str, *, now: datetime | None = None, ) -> dict[str, Any]: return { **receipt_base(contract, reviewer, "request-changes", now=now), "note": validate_note(note), } def post_receipt(receipt: dict[str, Any], api_base: str) -> dict[str, Any]: response = http_json( "POST", f"{api_base.rstrip('/')}/messages/", { "from_agent": BROKER_OWNER, "to_agent": "railiance-platform", "subject": broker_subject(receipt), "body": canonical_json(receipt), }, ) if not isinstance(response, dict) or not response.get("id"): raise ReadinessError("State Hub returned an invalid message receipt") return response def write_receipt(path: Path, receipt: dict[str, Any]) -> None: """Persist only the canonical broker receipt, never the CLI wrapper.""" path.parent.mkdir(parents=True, exist_ok=True) descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) try: with os.fdopen(descriptor, "w", encoding="utf-8") as handle: handle.write(json.dumps(receipt, indent=2, sort_keys=True) + "\n") finally: os.chmod(path, 0o600) def show(contract: dict[str, Any]) -> dict[str, Any]: return { "interface": BROKER_INTERFACE, "version": 1, "owner": BROKER_OWNER, "engagement_id": contract["engagement_id"], "target_id": contract["target"]["id"], "projection_contract_digest": contract_digest(contract), "projection_receipt_interface": PROJECTION_INTERFACE, "required_roles": ["attacker", "owner"], "mount_paths": sorted(item["mount_path"] for item in contract["identities"]), "adapter_path": str(DEFAULT_ADAPTER), "test_path": str(DEFAULT_TEST), "approve_command": ( "python3 scripts/wp0025-broker-readiness.py approve --contract " "--consumer-root --reviewer " ), "live_mutation_authorized": False, "secret_values_observed": False, } def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("command", choices=["show", "verify", "approve", "request-changes", "status"]) parser.add_argument("--contract", type=Path, required=True) parser.add_argument("--consumer-root", type=Path) parser.add_argument("--reviewer") parser.add_argument("--note") parser.add_argument("--receipt-out", type=Path) parser.add_argument("--state-hub", default="http://127.0.0.1:8000") args = parser.parse_args() try: contract = validate_projection_contract(load_json(args.contract)) receipt_out = None if args.command == "show": result = show(contract) elif args.command == "status": try: receipt = current_broker_receipt(contract, args.state_hub) except ContractError as exc: result = { "ready": False, "engagement_id": contract["engagement_id"], "reason": str(exc), "secret_values_observed": False, } else: receipt_out = receipt result = { "ready": True, "engagement_id": contract["engagement_id"], "receipt": receipt, "secret_values_observed": False, } elif args.command == "verify": if not args.consumer_root: raise ReadinessError("verify requires --consumer-root") result = verify_adapter(args.consumer_root) elif args.command == "approve": if not args.consumer_root or not args.reviewer: raise ReadinessError("approve requires --consumer-root and --reviewer") verification = verify_adapter(args.consumer_root) receipt = build_approval(contract, args.reviewer, verification) posted = post_receipt(receipt, args.state_hub) receipt_out = receipt result = {"submitted": True, "message_id": posted["id"], "receipt": receipt} else: if not args.reviewer or not args.note: raise ReadinessError("request-changes requires --reviewer and --note") receipt = build_change_request(contract, args.reviewer, args.note) posted = post_receipt(receipt, args.state_hub) result = {"submitted": True, "message_id": posted["id"], "receipt": receipt} if args.receipt_out: if receipt_out is None: raise ReadinessError("--receipt-out requires approve or a ready status") write_receipt(args.receipt_out, receipt_out) print(json.dumps(result, indent=2, sort_keys=True)) return 0 except (ContractError, OSError, ReadinessError) as exc: print(f"broker readiness failed: {exc}", file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main())