Add direct WP-0024 owner review interface
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a02669-87ee-7a31-b111-edc95a16e0fa
This commit is contained in:
parent
3f9e4535d1
commit
0d9cebedea
6 changed files with 877 additions and 1 deletions
13
Makefile
13
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
|
||||
|
|
|
|||
55
docs/wp0024-owner-review-interface.md
Normal file
55
docs/wp0024-owner-review-interface.md
Normal file
|
|
@ -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.
|
||||
102
interfaces/RAILIANCE-WP-0024-owner-reviews.json
Normal file
102
interfaces/RAILIANCE-WP-0024-owner-reviews.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
523
scripts/wp0024-owner-review.py
Normal file
523
scripts/wp0024-owner-review.py
Normal file
|
|
@ -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())
|
||||
176
tests/test_wp0024_owner_review.py
Normal file
176
tests/test_wp0024_owner_review.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue