Add safety case generator

This commit is contained in:
juvdiaz 2026-06-30 13:15:07 -06:00
parent 14aa2555c1
commit c7956638b3
7 changed files with 170 additions and 1 deletions

View File

@ -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:

View File

@ -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:

View File

@ -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

View File

@ -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
```

View File

@ -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
;;

View File

@ -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
;;
*)

116
scripts/safety-case Executable file
View File

@ -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 <commit> 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())