diff --git a/Makefile b/Makefile index d7de669..8d9a4ed 100644 --- a/Makefile +++ b/Makefile @@ -494,5 +494,16 @@ audit-database-lease-recovery-exercise: ## Run the separately approved attended railiance01-reboot-preflight: ## Read-only gate for the coordinated single-node reboot python3 scripts/audit-core-recovery-preflight.py node-reboot $(RAILIANCE01_REBOOT_PREFLIGHT_ARGS) -.PHONY: audit-whitehat-e2-preflight audit-whitehat-e2-project audit-whitehat-e2-cleanup audit-database-lease-recovery-preflight audit-database-lease-approval-check audit-database-lease-recovery-exercise railiance01-reboot-preflight apps-pg-isolation-probe +wp0024-owner-show: ## Show one owner's hash-bound review contract (set OWNER) + @test -n "$(OWNER)" || { echo 'set OWNER=audit-core|rapp-postgres|railiance-cluster|railiance-infra' >&2; exit 2; } + @python3 scripts/wp0024-owner-review.py show --owner "$(OWNER)" + +wp0024-owner-verify: ## Run one owner's named read-only checks (set OWNER) + @test -n "$(OWNER)" || { echo 'set OWNER=audit-core|rapp-postgres|railiance-cluster|railiance-infra' >&2; exit 2; } + @python3 scripts/wp0024-owner-review.py verify --owner "$(OWNER)" + +wp0024-owner-status: ## Collect current hash-bound owner receipts from State Hub + @python3 scripts/wp0024-owner-review.py status + +.PHONY: audit-whitehat-e2-preflight audit-whitehat-e2-project audit-whitehat-e2-cleanup audit-database-lease-recovery-preflight audit-database-lease-approval-check audit-database-lease-recovery-exercise railiance01-reboot-preflight wp0024-owner-show wp0024-owner-verify wp0024-owner-status apps-pg-isolation-probe .PHONY: db-deploy db-status db-shell db-logs forgejo-db-deploy forgejo-db-status forgejo-db-shell apps-pg-deploy apps-pg-status apps-pg-shell apps-pg-logs net-kingdom-pg-inter-hub-networkpolicy-deploy pg-deploy pg-status pg-pgpool-check valkey-deploy valkey-status openbao-repo openbao-dry-run openbao-overlay-apply openbao-verify-login-overlay openbao-deploy openbao-status openbao-verify openbao-verify-post-unseal openbao-configure-initial openbao-configure-ssh openbao-verify-ssh openbao-verify-authenticated openbao-configure-external-secrets-issue-core openbao-configure-external-secrets-activity-core openbao-configure-external-secrets-forgejo openbao-validate-restore-evidence openbao-validate-emergency-evidence credential-grants-validate credential-change-applier-dry-run credential-change-applier-apply-plan credential-change-applier-apply credential-change-runbook credential-change-record-evidence credential-change-lifecycle-plan credential-change-lifecycle-event credential-change-import-inventory agent-high-risk-boundary-check openbao-credential-change-appliers-dry-run openbao-configure-credential-change-appliers openbao-token-grants-dry-run openbao-configure-token-grants openbao-verify-token-grants-dry-run openbao-verify-token-grants openbao-verify-token-grants-smoke credential-helper-dry-run credential-tests credential-exec-ops-warden-smoke argocd-bootstrap-dry-run argocd-bootstrap-deploy argocd-repo-apply argocd-status backup forgejo-backup forgejo-backup-dry-run forgejo-backup-status forgejo-package-prune forgejo-package-prune-dry-run reef-deployables apps-pg-evidence consumption-preflight help diff --git a/docs/wp0024-owner-review-interface.md b/docs/wp0024-owner-review-interface.md new file mode 100644 index 0000000..d083477 --- /dev/null +++ b/docs/wp0024-owner-review-interface.md @@ -0,0 +1,55 @@ +# WP-0024 direct owner-review interface + +This interface lets each procedure owner inspect its exact review assertions, +run the applicable read-only checks, and submit an approval or change request +directly. No coding agent has to copy a to-do or translate a prose reply. + +The canonical contract is +`interfaces/RAILIANCE-WP-0024-owner-reviews.json`. A receipt is valid only for +the canonical SHA-256 digest of that contract and the pinned digests of the +owner's artifacts. Any procedure change therefore makes prior approval stale. + +## Owner flow + +Run these commands from a current `railiance-platform` checkout, replacing the +owner and reviewer with your stable agent or operator identifier: + +```bash +python3 scripts/wp0024-owner-review.py show --owner audit-core +python3 scripts/wp0024-owner-review.py verify --owner audit-core +python3 scripts/wp0024-owner-review.py approve --owner audit-core --reviewer audit-core +``` + +Valid owners are `audit-core`, `rapp-postgres`, `railiance-cluster`, and +`railiance-infra`. `approve` repeats verification and submits the receipt only +when artifact hashes and all named read-only checks pass. The checks may read +resource metadata, but they never read Secret data or OpenBao lease payloads. +They cannot revoke a lease, restart a workload, create a snapshot, or reboot a +node. A successful review is still not a live execution window. + +To request changes instead: + +```bash +python3 scripts/wp0024-owner-review.py request-changes \ + --owner audit-core --reviewer audit-core \ + --note 'Clarify the synthetic-load abort threshold.' +``` + +The note is durable State Hub metadata. Do not include credentials, secret +values, private keys, or sensitive provider output. + +## Coordination status + +Anyone can collect current receipts without interpreting owner messages: + +```bash +python3 scripts/wp0024-owner-review.py status +``` + +The result reports `approve`, `request-changes`, or `missing` per owner and an +`all_approved` aggregate. Receipts for an older contract digest or different +artifact hashes are counted as stale and cannot satisfy the aggregate. + +The interface uses `STATE_HUB_URL` when set and otherwise connects to +`http://127.0.0.1:8000`. The same value can be supplied explicitly with the +top-level `--api-base` option before the subcommand. diff --git a/interfaces/RAILIANCE-WP-0024-owner-reviews.json b/interfaces/RAILIANCE-WP-0024-owner-reviews.json new file mode 100644 index 0000000..1274d85 --- /dev/null +++ b/interfaces/RAILIANCE-WP-0024-owner-reviews.json @@ -0,0 +1,102 @@ +{ + "interface": "railiance.owner-review", + "version": 1, + "workplan_id": "RAILIANCE-WP-0024", + "workplan_uuid": "88c4ef7f-0af8-580e-90dc-a2bae2675a4d", + "base_revision": "3f9e453", + "receipt_destination": "railiance-platform", + "decisions": [ + "approve", + "request-changes" + ], + "owners": { + "audit-core": { + "tasks": [ + "RAILIANCE-WP-0024-T02", + "RAILIANCE-WP-0024-T03" + ], + "artifacts": [ + "docs/audit-core-database-lease-recovery.md", + "docs/railiance01-coordinated-reboot.md", + "scripts/audit-core-recovery-preflight.py", + "scripts/audit-core-database-lease-recovery.py" + ], + "assertions": [ + "The database-lease exercise uses only synthetic load and proves health 200, readiness and requests fail retryably with 503, then recover without changing the audit-core pod UID or restart count.", + "The approved load driver implements the exact start, probe, stop and summary contract and cannot expose credential values.", + "The reboot exercise proves audit-core health, readiness, database integrity and the approved synthetic E2 path after dependencies recover." + ], + "checks": [ + "focused-unit-tests", + "database-lease-preflight", + "node-reboot-preflight" + ] + }, + "rapp-postgres": { + "tasks": [ + "RAILIANCE-WP-0024-T02" + ], + "artifacts": [ + "docs/audit-core-database-lease-recovery.md", + "scripts/audit-core-recovery-preflight.py", + "scripts/audit-core-database-lease-recovery.py", + "docs/audit-core-database-lease-approval.example.json" + ], + "assertions": [ + "Lease selection aborts unless exactly one live handle exists below database/creds/audit-core-runtime and its issue time coheres with the ExternalSecret refresh.", + "The action revokes only that exact lease handle; it never revokes the role prefix, an External Secrets parent lease, or another database consumer.", + "Recovery uses ordinary ExternalSecret refresh and mounted-file reread, with no pod restart, database restore or credential value in evidence." + ], + "checks": [ + "focused-unit-tests", + "database-lease-preflight" + ] + }, + "railiance-cluster": { + "tasks": [ + "RAILIANCE-WP-0024-T03" + ], + "artifacts": [ + "docs/railiance01-coordinated-reboot.md", + "scripts/audit-core-recovery-preflight.py", + "docs/openbao-reboot-snapshot-receipt.example.json" + ], + "assertions": [ + "Recovery is ordered host, node/k3s, CoreDNS and operators before application readiness; application lag is not treated as host failure.", + "The exercise does not reinstall k3s, replace PVCs or alter firewall policy as a recovery shortcut.", + "External Secrets stale-provider recovery is bounded to the documented controller reconciliation path and preserves value-safe evidence." + ], + "checks": [ + "focused-unit-tests", + "node-reboot-preflight" + ] + }, + "railiance-infra": { + "tasks": [ + "RAILIANCE-WP-0024-T03" + ], + "artifacts": [ + "docs/railiance01-coordinated-reboot.md", + "scripts/audit-core-recovery-preflight.py", + "docs/openbao-reboot-snapshot-receipt.example.json" + ], + "assertions": [ + "An attended operator has independently verified provider-console access before the window and performs an ordinary reboot only.", + "Host-return, access-recovery and abort/escalation ownership are explicit; no ad hoc firewall, operating-system or k3s reinstall is authorized.", + "The reboot is gated on a current verified encrypted off-host OpenBao snapshot receipt and named recovery owners." + ], + "checks": [ + "focused-unit-tests", + "node-reboot-preflight" + ] + } + }, + "artifact_sha256": { + "docs/audit-core-database-lease-approval.example.json": "360d49d0bea7ca376405974403036bff080533728f133d237f30f4ee3bb943e0", + "docs/audit-core-database-lease-recovery.md": "921b701efa14b1d59434d175f9f2df57e4e6f462ab4fbdf06d6d17c15a02fb0a", + "docs/openbao-reboot-snapshot-receipt.example.json": "b95903a3e1aacb607eecc3f0440f08e62480d09da47bd7ab96c3eb8814f2eaee", + "docs/railiance01-coordinated-reboot.md": "9969c2f498587710154e8d9a650fb8a13159e9e7b99918291dbdf577b131b7e7", + "scripts/audit-core-database-lease-recovery.py": "c87fb026c527fc17116067c5742d324340707ce4aecf440d0d4e618db9b872a1", + "scripts/audit-core-recovery-preflight.py": "802f73fa4352f64bc0972d813e3dd874f3ec2f428e162b23158c9d8d6fd8e710" + } +} diff --git a/scripts/wp0024-owner-review.py b/scripts/wp0024-owner-review.py new file mode 100644 index 0000000..a660763 --- /dev/null +++ b/scripts/wp0024-owner-review.py @@ -0,0 +1,523 @@ +#!/usr/bin/env python3 +"""Direct, value-safe owner review interface for RAILIANCE-WP-0024. + +The executable surface is intentionally closed: contract files select named +read-only checks, never shell commands. Approvals are bound to the canonical +contract digest and the SHA-256 digests of the owner's reviewed artifacts. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import subprocess +import sys +import urllib.error +import urllib.parse +import urllib.request +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, Callable + + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_CONTRACT = ROOT / "interfaces" / "RAILIANCE-WP-0024-owner-reviews.json" +DEFAULT_API_BASE = os.environ.get("STATE_HUB_URL", "http://127.0.0.1:8000") +RECEIPT_PREFIX = "WP0024-OWNER-RECEIPT" +KNOWN_CHECKS = { + "focused-unit-tests", + "database-lease-preflight", + "node-reboot-preflight", +} + + +class ReviewError(RuntimeError): + pass + + +def canonical_json(value: Any) -> str: + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + + +def sha256_bytes(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for block in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def load_contract(path: Path = DEFAULT_CONTRACT) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ReviewError(f"review contract is unavailable or invalid: {path}") from exc + if not isinstance(value, dict): + raise ReviewError("review contract must be a JSON object") + if value.get("interface") != "railiance.owner-review" or value.get("version") != 1: + raise ReviewError("unsupported owner-review interface/version") + owners = value.get("owners") + hashes = value.get("artifact_sha256") + if not isinstance(owners, dict) or not owners or not isinstance(hashes, dict): + raise ReviewError("review contract requires owners and artifact_sha256 maps") + for owner, review in owners.items(): + if not isinstance(owner, str) or not isinstance(review, dict): + raise ReviewError("invalid owner review entry") + artifacts = review.get("artifacts") + checks = review.get("checks") + assertions = review.get("assertions") + if not all(isinstance(item, str) for item in artifacts or []): + raise ReviewError(f"{owner}: artifacts must be strings") + if not artifacts or any(item not in hashes for item in artifacts): + raise ReviewError(f"{owner}: every artifact requires a pinned digest") + if not checks or any(item not in KNOWN_CHECKS for item in checks): + raise ReviewError(f"{owner}: unknown or missing read-only check") + if not assertions or not all(isinstance(item, str) for item in assertions): + raise ReviewError(f"{owner}: assertions must be non-empty strings") + return value + + +def contract_digest(contract: dict[str, Any]) -> str: + return sha256_bytes(canonical_json(contract).encode("utf-8")) + + +def owner_review(contract: dict[str, Any], owner: str) -> dict[str, Any]: + review = contract["owners"].get(owner) + if not isinstance(review, dict): + choices = ", ".join(sorted(contract["owners"])) + raise ReviewError(f"unknown owner {owner!r}; choose one of: {choices}") + return review + + +def artifact_state( + contract: dict[str, Any], owner: str, *, root: Path = ROOT +) -> dict[str, dict[str, Any]]: + review = owner_review(contract, owner) + result: dict[str, dict[str, Any]] = {} + for relative in review["artifacts"]: + expected = contract["artifact_sha256"][relative] + target = (root / relative).resolve() + try: + target.relative_to(root.resolve()) + except ValueError as exc: + raise ReviewError(f"artifact escapes repository root: {relative}") from exc + try: + observed = file_sha256(target) + except OSError: + observed = None + result[relative] = { + "expected_sha256": expected, + "observed_sha256": observed, + "matches": observed == expected, + } + return result + + +def check_command(check_id: str) -> list[str]: + if check_id == "focused-unit-tests": + return [ + sys.executable, + "-m", + "unittest", + "tests/test_audit_core_recovery_preflight.py", + "tests/test_audit_core_database_lease_recovery.py", + "tests/test_wp0024_owner_review.py", + ] + if check_id == "database-lease-preflight": + return [sys.executable, "scripts/audit-core-recovery-preflight.py", "database-lease"] + if check_id == "node-reboot-preflight": + return [sys.executable, "scripts/audit-core-recovery-preflight.py", "node-reboot"] + raise ReviewError(f"unknown check id: {check_id}") + + +def run_named_check( + check_id: str, + *, + runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run, +) -> dict[str, Any]: + completed = runner( + check_command(check_id), + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + result: dict[str, Any] = { + "id": check_id, + "passed": completed.returncode == 0, + "exit_code": completed.returncode, + "read_only": True, + } + if check_id.endswith("-preflight") and completed.returncode == 0: + try: + payload = json.loads(completed.stdout) + except json.JSONDecodeError: + result["passed"] = False + result["detail"] = "preflight returned invalid JSON" + else: + safe = ( + isinstance(payload, dict) + and payload.get("automated_checks_passed") is True + and payload.get("secret_values_observed") is False + ) + result["passed"] = bool(safe) + result["automated_checks_passed"] = payload.get("automated_checks_passed") + result["ready_for_live_execution"] = payload.get("ready_for_live_execution") + result["secret_values_observed"] = payload.get("secret_values_observed") + if not safe: + result["detail"] = "preflight safety/automated gate did not pass" + elif completed.returncode != 0: + result["detail"] = "check failed; run the named interface verify command locally for diagnosis" + return result + + +def verify( + contract: dict[str, Any], + owner: str, + *, + root: Path = ROOT, + check_runner: Callable[[str], dict[str, Any]] = run_named_check, +) -> dict[str, Any]: + artifacts = artifact_state(contract, owner, root=root) + checks: list[dict[str, Any]] = [] + if all(item["matches"] for item in artifacts.values()): + checks = [check_runner(item) for item in owner_review(contract, owner)["checks"]] + return { + "interface": contract["interface"], + "version": contract["version"], + "workplan_id": contract["workplan_id"], + "owner": owner, + "contract_digest": contract_digest(contract), + "artifacts": artifacts, + "checks": checks, + "passed": bool(checks) + and all(item["matches"] for item in artifacts.values()) + and all(item["passed"] for item in checks), + "secret_values_observed": False, + } + + +def validate_reviewer(value: str) -> str: + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:@/+\-]{1,127}", value): + raise ReviewError("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 ReviewError("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 ReviewError("note appears to contain credential material; do not submit it") + return note + + +def receipt_subject(receipt: dict[str, Any]) -> str: + return "/".join( + ( + RECEIPT_PREFIX, + f"v{receipt['version']}", + receipt["owner"], + receipt["decision"], + receipt["contract_digest"], + ) + ) + + +def build_receipt( + contract: dict[str, Any], + owner: str, + decision: str, + reviewer: str, + *, + verification: dict[str, Any] | None = None, + note: str | None = None, + now: datetime | None = None, +) -> dict[str, Any]: + if decision not in contract["decisions"]: + raise ReviewError(f"unsupported decision: {decision}") + if decision == "approve" and (not verification or verification.get("passed") is not True): + raise ReviewError("approval requires a passing direct verification") + if decision == "request-changes": + note = validate_note(note or "") + review = owner_review(contract, owner) + timestamp = (now or datetime.now(UTC)).astimezone(UTC).isoformat().replace("+00:00", "Z") + receipt: dict[str, Any] = { + "interface": contract["interface"], + "version": contract["version"], + "workplan_id": contract["workplan_id"], + "workplan_uuid": contract["workplan_uuid"], + "owner": owner, + "reviewer": validate_reviewer(reviewer), + "decision": decision, + "created_at": timestamp, + "contract_digest": contract_digest(contract), + "artifact_sha256": { + path: contract["artifact_sha256"][path] for path in review["artifacts"] + }, + "assertion_count": len(review["assertions"]), + "secret_values_observed": False, + } + if verification: + receipt["checks"] = [ + {"id": item["id"], "passed": item["passed"], "read_only": True} + for item in verification["checks"] + ] + if note is not None: + receipt["note"] = note + return receipt + + +def http_json( + method: str, + url: str, + payload: dict[str, Any] | None = None, + *, + opener: Callable[..., Any] = urllib.request.urlopen, +) -> Any: + data = canonical_json(payload).encode("utf-8") if payload is not None else None + request = urllib.request.Request( + url, + data=data, + method=method, + headers={"Content-Type": "application/json"} if data else {}, + ) + try: + with opener(request, timeout=10) as response: + return json.loads(response.read().decode("utf-8")) + except (OSError, urllib.error.URLError, json.JSONDecodeError) as exc: + raise ReviewError(f"State Hub request failed: {method} {url}") from exc + + +def post_receipt(receipt: dict[str, Any], api_base: str) -> dict[str, Any]: + payload = { + "from_agent": receipt["owner"], + "to_agent": "railiance-platform", + "subject": receipt_subject(receipt), + "body": canonical_json(receipt), + } + response = http_json("POST", f"{api_base.rstrip('/')}/messages/", payload) + if not isinstance(response, dict): + raise ReviewError("State Hub returned an invalid receipt response") + return response + + +def parse_receipt_message(message: dict[str, Any]) -> dict[str, Any] | None: + subject = message.get("subject") + body = message.get("body") + if not isinstance(subject, str) or not subject.startswith(RECEIPT_PREFIX + "/"): + return None + if not isinstance(body, str): + return None + try: + receipt = json.loads(body) + except json.JSONDecodeError: + return None + if not isinstance(receipt, dict): + return None + required = { + "interface": str, + "version": int, + "workplan_id": str, + "workplan_uuid": str, + "owner": str, + "reviewer": str, + "decision": str, + "created_at": str, + "contract_digest": str, + "artifact_sha256": dict, + "assertion_count": int, + "secret_values_observed": bool, + } + if any(not isinstance(receipt.get(key), kind) for key, kind in required.items()): + return None + if ( + receipt["interface"] != "railiance.owner-review" + or receipt["version"] != 1 + or receipt["workplan_id"] != "RAILIANCE-WP-0024" + or receipt["decision"] not in {"approve", "request-changes"} + or receipt["secret_values_observed"] is not False + or not re.fullmatch(r"[0-9a-f]{64}", receipt["contract_digest"]) + ): + return None + try: + datetime.fromisoformat(receipt["created_at"].replace("Z", "+00:00")) + expected_subject = receipt_subject(receipt) + except (KeyError, TypeError, ValueError): + return None + if expected_subject != subject: + return None + if message.get("from_agent") != receipt.get("owner"): + return None + return receipt + + +def aggregate_status( + contract: dict[str, Any], messages: list[dict[str, Any]] +) -> dict[str, Any]: + digest = contract_digest(contract) + candidates: dict[str, list[dict[str, Any]]] = { + owner: [] for owner in contract["owners"] + } + stale: dict[str, int] = {owner: 0 for owner in contract["owners"]} + for message in messages: + receipt = parse_receipt_message(message) + if not receipt or receipt.get("owner") not in candidates: + continue + owner = receipt["owner"] + if receipt.get("contract_digest") != digest: + stale[owner] += 1 + continue + expected = { + path: contract["artifact_sha256"][path] + for path in contract["owners"][owner]["artifacts"] + } + review = contract["owners"][owner] + try: + validate_reviewer(receipt["reviewer"]) + except ReviewError: + stale[owner] += 1 + continue + if ( + receipt.get("interface") != contract["interface"] + or receipt.get("version") != contract["version"] + or receipt.get("workplan_id") != contract["workplan_id"] + or receipt.get("workplan_uuid") != contract["workplan_uuid"] + or receipt.get("artifact_sha256") != expected + or receipt.get("assertion_count") != len(review["assertions"]) + ): + stale[owner] += 1 + continue + if receipt["decision"] == "approve": + checks = receipt.get("checks") + if ( + not isinstance(checks, list) + or [item.get("id") for item in checks if isinstance(item, dict)] + != review["checks"] + or any( + not isinstance(item, dict) + or item.get("passed") is not True + or item.get("read_only") is not True + for item in checks + ) + ): + stale[owner] += 1 + continue + candidates[owner].append(receipt) + owners: dict[str, Any] = {} + for owner, receipts in candidates.items(): + latest = max(receipts, key=lambda item: item.get("created_at", ""), default=None) + owners[owner] = { + "decision": latest.get("decision") if latest else "missing", + "reviewer": latest.get("reviewer") if latest else None, + "created_at": latest.get("created_at") if latest else None, + "stale_receipt_count": stale[owner], + } + try: + artifact_current = all( + file_sha256(ROOT / path) == expected + for path, expected in contract["artifact_sha256"].items() + ) + except OSError: + artifact_current = False + return { + "interface": contract["interface"], + "version": contract["version"], + "workplan_id": contract["workplan_id"], + "contract_digest": digest, + "contract_artifacts_current": artifact_current, + "owners": owners, + "all_approved": artifact_current + and all(item["decision"] == "approve" for item in owners.values()), + } + + +def fetch_status(contract: dict[str, Any], api_base: str) -> dict[str, Any]: + query = urllib.parse.urlencode({"to_agent": "railiance-platform", "limit": 500}) + value = http_json("GET", f"{api_base.rstrip('/')}/messages/?{query}") + if not isinstance(value, list): + raise ReviewError("State Hub messages response is not a list") + return aggregate_status(contract, value) + + +def parser() -> argparse.ArgumentParser: + result = argparse.ArgumentParser(description=__doc__) + result.add_argument("--contract", type=Path, default=DEFAULT_CONTRACT) + result.add_argument("--api-base", default=DEFAULT_API_BASE) + subparsers = result.add_subparsers(dest="command", required=True) + for command in ("show", "verify"): + child = subparsers.add_parser(command) + child.add_argument("--owner", required=True) + approve = subparsers.add_parser("approve") + approve.add_argument("--owner", required=True) + approve.add_argument("--reviewer", required=True) + changes = subparsers.add_parser("request-changes") + changes.add_argument("--owner", required=True) + changes.add_argument("--reviewer", required=True) + changes.add_argument("--note", required=True) + subparsers.add_parser("status") + return result + + +def main(argv: list[str] | None = None) -> int: + args = parser().parse_args(argv) + try: + contract = load_contract(args.contract) + if args.command == "show": + review = owner_review(contract, args.owner) + output = { + "interface": contract["interface"], + "version": contract["version"], + "workplan_id": contract["workplan_id"], + "owner": args.owner, + "contract_digest": contract_digest(contract), + **review, + "artifacts_state": artifact_state(contract, args.owner), + "decisions": contract["decisions"], + } + elif args.command == "verify": + output = verify(contract, args.owner) + elif args.command == "approve": + verification = verify(contract, args.owner) + receipt = build_receipt( + contract, args.owner, "approve", args.reviewer, verification=verification + ) + posted = post_receipt(receipt, args.api_base) + output = { + "submitted": True, + "message_id": posted.get("id"), + "receipt": receipt, + } + elif args.command == "request-changes": + receipt = build_receipt( + contract, + args.owner, + "request-changes", + args.reviewer, + note=args.note, + ) + posted = post_receipt(receipt, args.api_base) + output = { + "submitted": True, + "message_id": posted.get("id"), + "receipt": receipt, + } + else: + output = fetch_status(contract, args.api_base) + except ReviewError as exc: + print(canonical_json({"ok": False, "error": str(exc)}), file=sys.stderr) + return 2 + print(json.dumps(output, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_wp0024_owner_review.py b/tests/test_wp0024_owner_review.py new file mode 100644 index 0000000..ca812db --- /dev/null +++ b/tests/test_wp0024_owner_review.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +import importlib.util +import json +import subprocess +import tempfile +import unittest +from datetime import UTC, datetime +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location( + "wp0024_owner_review", ROOT / "scripts" / "wp0024-owner-review.py" +) +assert SPEC and SPEC.loader +module = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(module) + + +class OwnerReviewTests(unittest.TestCase): + def setUp(self) -> None: + self.contract = module.load_contract() + + def test_contract_has_four_owners_and_only_known_checks(self) -> None: + self.assertEqual( + {"audit-core", "rapp-postgres", "railiance-cluster", "railiance-infra"}, + set(self.contract["owners"]), + ) + checks = { + check + for review in self.contract["owners"].values() + for check in review["checks"] + } + self.assertLessEqual(checks, module.KNOWN_CHECKS) + self.assertEqual(64, len(module.contract_digest(self.contract))) + + def test_current_artifacts_match_contract(self) -> None: + for owner in self.contract["owners"]: + state = module.artifact_state(self.contract, owner) + self.assertTrue(all(item["matches"] for item in state.values())) + + def test_artifact_change_prevents_checks_and_approval(self) -> None: + contract = json.loads(json.dumps(self.contract)) + owner = "rapp-postgres" + review = contract["owners"][owner] + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for relative in review["artifacts"]: + target = root / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes((ROOT / relative).read_bytes()) + (root / review["artifacts"][0]).write_text("changed", encoding="utf-8") + result = module.verify( + contract, + owner, + root=root, + check_runner=lambda _: self.fail("checks must not run after hash mismatch"), + ) + self.assertFalse(result["passed"]) + self.assertEqual([], result["checks"]) + with self.assertRaises(module.ReviewError): + module.build_receipt( + contract, owner, "approve", owner, verification=result + ) + + def test_named_preflight_requires_safe_machine_result(self) -> None: + def runner(*args, **kwargs): + return subprocess.CompletedProcess( + args[0], + 0, + stdout=json.dumps( + { + "automated_checks_passed": True, + "ready_for_live_execution": False, + "secret_values_observed": False, + } + ), + stderr="", + ) + + result = module.run_named_check("database-lease-preflight", runner=runner) + self.assertTrue(result["passed"]) + self.assertFalse(result["ready_for_live_execution"]) + self.assertFalse(result["secret_values_observed"]) + + def test_receipt_round_trip_and_stale_digest_invalidation(self) -> None: + owner = "audit-core" + verification = { + "passed": True, + "checks": [ + {"id": check, "passed": True} + for check in self.contract["owners"][owner]["checks"] + ], + } + receipt = module.build_receipt( + self.contract, + owner, + "approve", + owner, + verification=verification, + now=datetime(2026, 8, 22, 12, 0, tzinfo=UTC), + ) + message = { + "from_agent": owner, + "subject": module.receipt_subject(receipt), + "body": module.canonical_json(receipt), + } + self.assertEqual(receipt, module.parse_receipt_message(message)) + current = module.aggregate_status(self.contract, [message]) + self.assertEqual("approve", current["owners"][owner]["decision"]) + self.assertTrue(current["contract_artifacts_current"]) + self.assertFalse(current["all_approved"]) + + stale = json.loads(json.dumps(receipt)) + stale["contract_digest"] = "0" * 64 + stale_message = { + "from_agent": owner, + "subject": module.receipt_subject(stale), + "body": module.canonical_json(stale), + } + status = module.aggregate_status(self.contract, [stale_message]) + self.assertEqual("missing", status["owners"][owner]["decision"]) + self.assertEqual(1, status["owners"][owner]["stale_receipt_count"]) + + def test_incomplete_approval_is_not_counted(self) -> None: + owner = "rapp-postgres" + verification = { + "passed": True, + "checks": [ + {"id": check, "passed": True} + for check in self.contract["owners"][owner]["checks"] + ], + } + receipt = module.build_receipt( + self.contract, owner, "approve", owner, verification=verification + ) + receipt["checks"].pop() + message = { + "from_agent": owner, + "subject": module.receipt_subject(receipt), + "body": module.canonical_json(receipt), + } + status = module.aggregate_status(self.contract, [message]) + self.assertEqual("missing", status["owners"][owner]["decision"]) + self.assertEqual(1, status["owners"][owner]["stale_receipt_count"]) + + def test_malformed_receipt_message_is_ignored(self) -> None: + message = { + "from_agent": "audit-core", + "subject": module.RECEIPT_PREFIX + "/invalid", + "body": "{}", + } + self.assertIsNone(module.parse_receipt_message(message)) + + def test_request_changes_requires_safe_note(self) -> None: + receipt = module.build_receipt( + self.contract, + "railiance-infra", + "request-changes", + "infra-owner", + note="Name the provider-console operator.", + ) + self.assertEqual("request-changes", receipt["decision"]) + with self.assertRaises(module.ReviewError): + module.build_receipt( + self.contract, + "railiance-infra", + "request-changes", + "infra-owner", + note="", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/workplans/RAILIANCE-WP-0024-audit-core-custody-and-recovery-coordination.md b/workplans/RAILIANCE-WP-0024-audit-core-custody-and-recovery-coordination.md index 90a7e94..fe59571 100644 --- a/workplans/RAILIANCE-WP-0024-audit-core-custody-and-recovery-coordination.md +++ b/workplans/RAILIANCE-WP-0024-audit-core-custody-and-recovery-coordination.md @@ -201,6 +201,15 @@ Routed from immutable revision `bd25f7f` to audit-core procedure paths, responsibility boundary, stop conditions, passing automated preflight state, and explicit statement that no live mutation was requested. +**Direct review interface (2026-08-22):** +`interfaces/RAILIANCE-WP-0024-owner-reviews.json` and +`scripts/wp0024-owner-review.py` replace follow-up prose-to-task translation +with owner-executable `show`, `verify`, `approve`, `request-changes`, and +aggregate `status` operations. Approvals are valid only for the canonical +contract digest and the owner's pinned artifact hashes, so a changed procedure +invalidates the old receipt. The interface runs only closed-set, read-only +checks and cannot perform any T02/T03 live mutation. + ## Acceptance - [ ] E2 cannot proceed without enforced sender expiry and exact-path projection.