#!/usr/bin/env python3 """Build machine-readable owner packets from post-reboot next-gate dispatch. Read-only by design. This script may run the read-only dispatch checklist, but it never sends owner requests, reads secrets, writes credential markers, or modifies host/runtime state. """ from __future__ import annotations import argparse import json import subprocess import sys from datetime import datetime from pathlib import Path from typing import Any ROOT = Path(__file__).resolve().parents[2] DISPATCH_SCRIPT = ROOT / "scripts" / "reboot-recovery" / "post-reboot-next-gate-dispatch.sh" def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Convert post-reboot gate dispatch output into owner packet JSON.", ) parser.add_argument( "--dispatch-file", type=Path, help="Use an existing post-reboot-next-gate-dispatch output file.", ) parser.add_argument( "--summary-file", type=Path, help="Pass an existing readiness summary file to the delegated dispatch checklist.", ) parser.add_argument( "--output", type=Path, help="Write JSON to this path instead of stdout.", ) parser.add_argument( "--no-color", action="store_true", help="Pass --no-color to the delegated dispatch script.", ) return parser.parse_args() def run_dispatch(no_color: bool, summary_file: Path | None) -> str: cmd = [str(DISPATCH_SCRIPT)] if summary_file: cmd.extend(["--summary-file", str(summary_file)]) if no_color: cmd.append("--no-color") completed = subprocess.run( cmd, cwd=ROOT, check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, ) if completed.returncode not in (0,): raise SystemExit( f"dispatch_failed rc={completed.returncode}\n{completed.stdout}" ) return completed.stdout def load_dispatch(args: argparse.Namespace) -> str: if args.dispatch_file: return args.dispatch_file.read_text(encoding="utf-8") return run_dispatch(no_color=args.no_color, summary_file=args.summary_file) def split_csv(value: str) -> list[str]: if not value or value == "none": return [] return [item.strip() for item in value.split(",") if item.strip()] def parse_dispatch(text: str) -> dict[str, Any]: summary: dict[str, str] = {} gates: list[dict[str, Any]] = [] current_gate: dict[str, Any] | None = None for raw_line in text.splitlines(): line = raw_line.strip() if not line or "=" not in line: continue key, value = line.split("=", 1) key = key.strip() value = value.strip() if key == "GATE_ID": if current_gate: gates.append(current_gate) current_gate = {"gate_id": value} continue target = current_gate if current_gate is not None else summary target[key.lower()] = value if current_gate: gates.append(current_gate) for gate in gates: for key in ( "owner_group", "required_items", "required_evidence", "required_decisions", "required_export", "forbidden_payloads", "forbidden_action", "forbidden_actions", "done_criteria", ): if key in gate: gate[key] = split_csv(str(gate[key])) return {"summary": summary, "gates": gates} def build_packet(parsed: dict[str, Any]) -> dict[str, Any]: summary = parsed["summary"] gates = parsed["gates"] next_required = split_csv(summary.get("next_required_gates", "")) return { "schema_version": "awoooi_post_reboot_next_gate_owner_packets_v1", "generated_at": datetime.now().astimezone().isoformat(timespec="seconds"), "source": { "dispatch_script": str(DISPATCH_SCRIPT.relative_to(ROOT)), "summary_file": summary.get("summary_file", "unknown"), "summary_artifact_dir": summary.get("summary_artifact_dir", "unknown"), "overall_declaration": summary.get("overall_declaration", "unknown"), "next_required_gates": next_required, }, "status": { "service_green": summary.get("service_green", "unknown"), "runtime_action_authorized": 0, "dispatch_authorized": 0, "request_sent_count": 0, "owner_response_received_count": 0, "owner_response_accepted_count": 0, "host_write_authorized": 0, "secret_value_collection_allowed": 0, "runtime_gate_count": 0, }, "owner_packets": [ { "packet_id": gate.get("gate_id", "unknown"), "title": gate.get("gate_title", "unknown"), "priority": gate.get("gate_priority", "unknown"), "status": gate.get("gate_status", "unknown"), "current_evidence": gate.get("current_evidence", "unknown"), "owner_group": gate.get("owner_group", []), "required_items": gate.get("required_items", []), "required_evidence": gate.get("required_evidence", []), "required_decisions": gate.get("required_decisions", []), "required_export": gate.get("required_export", []), "allowed_action": gate.get("allowed_action", "unknown"), "forbidden_payloads": gate.get("forbidden_payloads", []), "forbidden_actions": gate.get("forbidden_actions") or gate.get("forbidden_action", []), "done_criteria": gate.get("done_criteria", []), "request_sent": False, "response_received": False, "response_accepted": False, "runtime_action_authorized": False, } for gate in gates ], "counts": { "next_gate_count": len(gates), "p0_gate_count": sum(1 for gate in gates if gate.get("gate_priority") == "P0"), "request_sent_count": 0, "owner_response_received_count": 0, "owner_response_accepted_count": 0, "runtime_action_authorized_count": 0, }, "no_false_green_rules": [ "product_route_green_does_not_equal_product_data_green", "service_green_does_not_equal_dr_complete", "backup_fresh_does_not_equal_credential_escrow_complete", "host_188_service_green_does_not_equal_host_hygiene_green", "wazuh_route_or_transport_does_not_equal_manager_registry_accepted", ], "forbidden_global_actions": [ "send_owner_request_without_review", "write_credential_marker_without_non_secret_evidence", "collect_secret_value_hash_prefix_suffix_or_raw_payload", "pg_resetwal_or_db_restore_without_maintenance_window", "nginx_reload_or_certbot_renew_without_owner_gate", "wazuh_active_response_reenroll_restart_or_secret_patch", "host_write_or_kali_active_scan_without_explicit_approval", ], } def main() -> int: args = parse_args() parsed = parse_dispatch(load_dispatch(args)) packet = build_packet(parsed) payload = json.dumps(packet, ensure_ascii=False, indent=2, sort_keys=True) if args.output: args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(payload + "\n", encoding="utf-8") else: print(payload) return 0 if __name__ == "__main__": sys.exit(main())