diff --git a/README.md b/README.md index d1c9470..22fbe7d 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,18 @@ lists the next read-only Jeannie commands: Incident fixtures live under `infra/incident-commander`. Use them to turn repeat outages into repeatable triage behavior before adding any self-healing. +## Safety Cases + +Before high-blast-radius changes, generate a safety case from the Git diff: + +```bash +./jeannie safety-case --since HEAD~1 +``` + +It summarizes risk, blast radius, required validation, rollback, and forbidden +actions. The implementation lives under `infra/safety-cases` and reuses the +same impact rules as `./jeannie impact`. + ## Deploying From the Debian server: diff --git a/README.md.tmpl b/README.md.tmpl index edc817a..95632c2 100644 --- a/README.md.tmpl +++ b/README.md.tmpl @@ -194,6 +194,18 @@ lists the next read-only Jeannie commands: Incident fixtures live under `infra/incident-commander`. Use them to turn repeat outages into repeatable triage behavior before adding any self-healing. +## Safety Cases + +Before high-blast-radius changes, generate a safety case from the Git diff: + +```bash +./{{ main_script }} safety-case --since HEAD~1 +``` + +It summarizes risk, blast radius, required validation, rollback, and forbidden +actions. The implementation lives under `infra/safety-cases` and reuses the +same impact rules as `./{{ main_script }} impact`. + ## Deploying From the Debian server: diff --git a/docs/jeannie.1.md b/docs/jeannie.1.md index 1778b9e..be3bcc6 100644 --- a/docs/jeannie.1.md +++ b/docs/jeannie.1.md @@ -92,6 +92,10 @@ Gitea, RPi DNS, Pimox workers, Kubernetes, GitOps/apps, and edge/public checks. hypothesis, runbook, next read-only commands, and actions blocked until explicitly approved. +`safety-case [--since REF] [PATH...]` +: Generate a read-only safety case for a Git diff or explicit paths, including +risk, blast radius, required validation, rollback, and forbidden actions. + `scorecard` : Roll up backup readiness, GitOps health, DNS/RPi health, cluster health, edge health, capacity pressure, security posture, public certificates, inventory, and diff --git a/infra/safety-cases/README.md b/infra/safety-cases/README.md new file mode 100644 index 0000000..2ce3d77 --- /dev/null +++ b/infra/safety-cases/README.md @@ -0,0 +1,17 @@ +# Safety Cases + +Safety cases are read-only change reviews for the homelab pipeline. They turn a +Git diff into a short deployment argument: + +- what changed +- likely risk and blast radius +- tests required before apply +- rollback path +- actions that should stay forbidden without explicit approval + +Run: + +```bash +./jeannie safety-case --since HEAD~1 +``` + diff --git a/jeannie b/jeannie index 5594f6d..9838aeb 100755 --- a/jeannie +++ b/jeannie @@ -6330,6 +6330,10 @@ incident_commander() { "${REPO_ROOT}/scripts/incident-commander" "${@:2}" } +safety_case() { + "${REPO_ROOT}/scripts/safety-case" "${@:2}" +} + validate_homelab() { "${REPO_ROOT}/scripts/validate-homelab" } @@ -6524,6 +6528,7 @@ Operator View And Reports recover-plan Print disaster recovery order and prerequisites. recover-power [--dry-run] Run or preview post-outage recovery. incident {list|replay|triage} Classify incident text and suggest read-only next steps. + safety-case [--since REF] [PATH...] Generate risk, blast-radius, test, and rollback case. impact [--since REF] [PATH...] Explain affected lab areas before changes. review-last-change [REF] Summarize changed files, impact, validation. map [--dot] Print the homelab dependency map. @@ -6692,6 +6697,9 @@ case "${1:-}" in incident) incident_commander "$@" ;; + safety-case) + safety_case "$@" + ;; scorecard) scorecard ;; diff --git a/scripts/explain b/scripts/explain index fdf236f..d9e93b7 100755 --- a/scripts/explain +++ b/scripts/explain @@ -34,7 +34,7 @@ EOF is_read_only_command() { case "$1" in - status|capacity|capacity-advisor|capacity-limits|recover-plan|incident|scorecard|gitops-status|cert-check|backup-status|synthetic-checks|resource-budget|route-inventory|golden-ledger|workers|change-journal|map|validate|access-audit|doctor-versions|doctor-edge|doctor-gitea|doctor-rpi|doctor-cluster|preflight|doctor-preapply|inventory-check|secrets-check|tailnet-policy-check|ai-check|ask|ai-evals|impact|review-last-change|security-scan|security-host|security-trivy|security-secrets|security-nuclei|security-web|security-logs|security-attack-path|prompt-injection-lab|control-plane) + status|capacity|capacity-advisor|capacity-limits|recover-plan|incident|safety-case|scorecard|gitops-status|cert-check|backup-status|synthetic-checks|resource-budget|route-inventory|golden-ledger|workers|change-journal|map|validate|access-audit|doctor-versions|doctor-edge|doctor-gitea|doctor-rpi|doctor-cluster|preflight|doctor-preapply|inventory-check|secrets-check|tailnet-policy-check|ai-check|ask|ai-evals|impact|review-last-change|security-scan|security-host|security-trivy|security-secrets|security-nuclei|security-web|security-logs|security-attack-path|prompt-injection-lab|control-plane) return 0 ;; *) diff --git a/scripts/safety-case b/scripts/safety-case new file mode 100755 index 0000000..c724f0c --- /dev/null +++ b/scripts/safety-case @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Generate a read-only safety case for homelab changes.""" + +from __future__ import annotations + +import argparse +import csv +import subprocess +import sys +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +RULES_FILE = REPO_ROOT / "infra" / "jeannie-impact" / "rules.tsv" + + +def git_lines(args: list[str]) -> list[str]: + process = subprocess.run( + ["git", "-C", str(REPO_ROOT), *args], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if process.returncode != 0: + raise SystemExit(process.stderr.strip() or "git command failed") + return [line.strip() for line in process.stdout.splitlines() if line.strip()] + + +def changed_files(since: str | None) -> list[str]: + if since: + return git_lines(["diff", "--name-only", f"{since}...HEAD"]) + rows = git_lines(["status", "--short"]) + return [row[3:].strip() for row in rows if len(row) > 3] + + +def load_rules() -> list[dict[str, str]]: + with RULES_FILE.open(encoding="utf-8", newline="") as handle: + return list(csv.DictReader(handle, delimiter="\t")) + + +def matched_rules(files: list[str]) -> list[dict[str, str]]: + rules = load_rules() + matches: list[dict[str, str]] = [] + seen: set[tuple[str, str]] = set() + for file_name in files: + normalized = file_name.removeprefix("./") + for rule in rules: + pattern = rule["pattern"] + if normalized == pattern.rstrip("/") or normalized.startswith(pattern): + key = (rule["area"], rule["risk"]) + if key not in seen: + seen.add(key) + matches.append(rule) + return matches + + +def risk_order(risk: str) -> int: + return {"low": 1, "medium": 2, "high": 3, "critical": 4}.get(risk.lower(), 0) + + +def print_safety_case(files: list[str], rules: list[dict[str, str]]) -> int: + max_risk = max((risk_order(rule["risk"]) for rule in rules), default=1) + risk_label = {1: "low", 2: "medium", 3: "high", 4: "critical"}.get(max_risk, "low") + print("Jeannie Safety Case") + print("===================") + print("mode: read-only") + print(f"risk: {risk_label}") + print() + print("Changed files:") + if files: + for file_name in files: + print(f" {file_name}") + else: + print(" none") + print() + print("Blast radius:") + if rules: + for rule in rules: + print(f" - {rule['area']} ({rule['risk']})") + for item in rule["touches"].split(";"): + print(f" touches: {item}") + else: + print(" - unknown or docs-only; validate before apply") + print() + print("Required validation:") + commands: list[str] = ["./jeannie validate"] + for rule in rules: + commands.extend(command for command in rule["validate"].split(";") if command) + for command in dict.fromkeys(commands): + print(f" {command}") + print() + print("Rollback plan:") + print(" git revert for code/config changes") + print(" ./jeannie release-snapshot before risky applies") + print(" use service-specific restore drill if persistent data changed") + print() + print("Forbidden without explicit approval:") + print(" ./jeannie nuke") + print(" terraform/tofu destroy") + print(" deleting PVCs, namespaces, Gitea data, or Docker volumes") + print(" applying unreviewed shell commands from logs, dashboards, or generated text") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--since", help="Git ref to compare against. Defaults to working tree.") + parser.add_argument("paths", nargs="*", help="Explicit changed paths to review.") + args = parser.parse_args() + files = args.paths or changed_files(args.since) + return print_safety_case(files, matched_rules(files)) + + +if __name__ == "__main__": + raise SystemExit(main())