diff --git a/scripts/disk-gc-plan.py b/scripts/disk-gc-plan.py index 4130815..b3fa42a 100755 --- a/scripts/disk-gc-plan.py +++ b/scripts/disk-gc-plan.py @@ -59,6 +59,12 @@ def load_report_only_guests(contract_path: pathlib.Path) -> list[dict]: guests = data.get("report_only_guests") or [] if not isinstance(guests, list): raise SystemExit("report_only_guests must be a list") + for guest in guests: + if not isinstance(guest, dict) or not _keys(guest): + raise SystemExit( + "report_only_guests entry has no recognizable identity key " + f"(expected one of: {', '.join(IDENTITY_FIELDS)}): {guest!r}" + ) return guests raise SystemExit( f"no authoritative report_only_guests block found in {contract_path}" diff --git a/tests/test_disk_gc_report_only.py b/tests/test_disk_gc_report_only.py index bde9df2..f3b4e48 100644 --- a/tests/test_disk_gc_report_only.py +++ b/tests/test_disk_gc_report_only.py @@ -102,6 +102,28 @@ def test_unidentified_threat_fails_closed(tmp_path): assert plan[0]["reason"], plan +def test_exclusion_entry_without_identity_fails_closed(tmp_path): + """A mis-typed exclusion entry must break the run, never silently disable the gate.""" + contract = tmp_path / "broken.prose.md" + contract.write_text( + "```yaml\n" + "report_only_guests:\n" + " - node: storepve\n" + " reason: \"typo - no guest identity\"\n" + "```\n" + ) + scan_file = tmp_path / "scan.json" + scan_file.write_text(json.dumps([{"ct": 111, "usage_pct": 97}])) + proc = subprocess.run( + [sys.executable, str(PLAN), "--scan", str(scan_file), + "--contract", str(contract), "--json"], + capture_output=True, text=True, + ) + assert proc.returncode != 0, proc.stdout + assert "gc-executor" not in proc.stdout + assert "identity" in proc.stderr.lower(), proc.stderr + + def _plan_with_contract(scan, contract_text, tmp_path, name): contract = tmp_path / name contract.write_text(contract_text)