SAFETY-60/62: operator tools refuse input they cannot read
checks / unit (pull_request) Successful in 8m31s

predeploy_check.py and rehearse_roster.py now assert a positive property of
their input (a JSON object, most of whose top-level keys are known option or
store keys) and refuse anything else with the shape named. The `ha apps info
--raw-json` wrapper, a truncated or list-wrapped state file and a wrapped
config no longer produce "CLEARED" / "NO FINDINGS" with exit 0.

Also: a clean --no-live-entities rehearsal exits 3, not 0; alloc_sim.py
exits 1 when split-invariance FAILS; decode_bus_capture.py takes several
paths and exits 1 on a file that is mostly not frames.

Graded by the new test_tool_inputs.py; DOCS.md says to pass .data.options.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
glenn schrooyen
2026-09-24 23:27:50 +02:00
co-authored by Claude Opus 5.5
parent a45ed06c05
commit f4f72211a9
7 changed files with 390 additions and 25 deletions
+6 -3
View File
@@ -6,6 +6,8 @@
python3 tools/alloc_sim.py --sweep # AC 3: gain to 1.0 at every split
python3 tools/alloc_sim.py --all
Exit 0 only if every split-invariance run HOLDS; 1 if one FAILS; 2 if no rig.
# DEV-02: the split-invariance finding ALLOC-01's review handed on, and the
# sensing change that closed it. The two rows are the evidence.
python3 tools/alloc_sim.py --splits --gain 0.4 --feedback unit # 250 W apart
@@ -279,13 +281,14 @@ def main():
if a.table or a.all:
table(sitesim, control, watchdog, a.cycles, a.load)
print()
held = True
if a.splits or a.all:
splits(sitesim, control, watchdog, a.cycles, a.load, a.gain,
a.feedback)
held = splits(sitesim, control, watchdog, a.cycles, a.load, a.gain,
a.feedback)
print()
if a.sweep or a.all:
sweep(sitesim, control, watchdog, a.cycles, a.load)
return 0
return 0 if held else 1
if __name__ == "__main__":
+26 -3
View File
@@ -195,14 +195,37 @@ def _hms(t):
return f"{int(t // 3600):02d}:{int(t % 3600 // 60):02d}:{t % 60:06.3f}"
def unreadable(stats):
"""Why a capture decoded to too little to trust, or None: most lines must be frames."""
framed = sum(stats[k] for k in ("requests", "responses", "writes",
"write_echoes"))
if framed > 0 and stats["lines"] - framed <= framed:
return None
return (f"{framed} of {stats['lines']} line(s) decoded as frames "
f"(unparsed {stats['unparsed']}, bad_crc {stats['bad_crc']}, "
f"unknown {stats['unknown']}) - not a capture this decoder can read")
def main(argv):
if len(argv) != 2:
"""Decode each capture named; exit 1 if any is unreadable, 2 on no argument."""
if len(argv) < 2:
print(__doc__.splitlines()[2].strip())
return 2
events, stats = decode(argv[1])
print(f"== {argv[1]}")
rc = 0
for path in argv[1:]:
rc |= _report(path)
return rc
def _report(path):
events, stats = decode(path)
print(f"== {path}")
for k, v in stats.items():
print(f" {k:14} {v}")
bad = unreadable(stats)
if bad:
print(f"REFUSED: {path}: {bad}")
return 1
for kind in ("setpoint_w", "batt_w", "soc_pct"):
rows = series(events, kind)
if not rows:
+48 -3
View File
@@ -39,8 +39,10 @@ and not at merge. The check belongs where the mistake is made.
⚠️ WHAT THIS DOES NOT DO. It does not read the live box. The options blob is
reachable only through the Supervisor API, which needs a supervisor token, so
the operator exports it and passes `--options`. Without that flag the validator
half is SKIPPED and says so - a skipped check must never look like a passed one.
the operator exports it and passes `--options` - the bare options object
(`.data.options` of `ha apps info <slug> --raw-json`); any other shape is
refused. Without that flag the validator half is SKIPPED and says so - a
skipped check must never look like a passed one.
"""
import argparse
import json
@@ -79,6 +81,43 @@ def shipped_version(tree=ADDON):
return m.group(1) if m else None
def option_keys(addon=ADDON):
"""Top-level option names in `addon`'s config.yaml `options:`/`schema:` blocks."""
keys, section = set(), None
with open(os.path.join(addon, "config.yaml"), encoding="utf-8") as fh:
for line in fh:
if not line.strip() or line.startswith("#"):
continue
if not line[0].isspace():
section = line.split(":", 1)[0].strip()
continue
m = re.match(r" ([A-Za-z_][A-Za-z0-9_]*):", line)
if m and section in ("options", "schema"):
keys.add(m.group(1))
return keys
def shape_problem(doc, known, what, allow_empty=False):
"""Why `doc` is not a document of `known` keys, or None (SAFETY-60/62).
Positive property: a JSON object, at least half of whose top-level keys
(`_`-prefixed comments aside) `known(key)` recognises.
"""
if not isinstance(doc, dict):
return f"{what} is a JSON {type(doc).__name__}, not an object"
keys = [k for k in doc if not str(k).startswith("_")]
hits = [k for k in keys if known(k)]
if (keys or allow_empty) and len(hits) * 2 >= len(keys):
return None
msg = (f"{what} is not the expected document: {len(hits)} of {len(keys)} "
f"top-level key(s) recognised {sorted(map(str, keys))[:8]}")
inner = doc.get("data")
if isinstance(inner, dict) and isinstance(inner.get("options"), dict):
msg += ("; this is `ha apps info --raw-json` output - pass its "
"`.data.options` instead")
return msg
def is_runtime(path):
return any(path == p or path.startswith(p) for p in RUNTIME)
@@ -256,6 +295,12 @@ def main():
except (json.JSONDecodeError, OSError, UnicodeDecodeError) as exc:
problems.append(f"--options file could not be read as JSON "
f"({type(exc).__name__}: {exc}); the config was NOT checked")
# SAFETY-60: the validator accepts any dict, so the shape is checked here.
bad = (shape_problem(opts, option_keys().__contains__, "--options")
if opts is not None else None)
if bad:
problems.append(f"{bad}; the config was NOT checked")
opts = None
if a.options and opts is not None:
try:
from app.validate import validate_startup_config
@@ -281,7 +326,7 @@ def main():
print(f" - {p}")
else:
print(" live options pass this version's validator")
else:
elif not a.options:
skipped.append("--options was not given, so the live config was NEVER CHECKED "
"against this version's validator. A config refused at startup is "
"exit 78. Export the options and re-run before deploying.")
+37 -8
View File
@@ -46,8 +46,11 @@ EXIT CODES
1 ⭐ AT LEAST ONE OF THOSE HAPPENED. This is the code that must be
reachable: `test_rehearse.py` mutates the shipped migration nine
different ways and requires this exit code for each.
2 the rehearsal could not be performed (bad file, unparseable config, the
entity-id model failed its own anchor). ⚠️ NOT A PASS.
2 the rehearsal could not be performed (bad file, unparseable config, a
state or options file of the wrong shape, the entity-id model failed
its own anchor). ⚠️ NOT A PASS.
3 no findings, but --no-live-entities: the orphan check was not graded
against the install. ⚠️ NOT A PASS.
"""
import argparse
@@ -64,10 +67,12 @@ from app.inverter import UNIT_A, UNIT_STORE_KEYS, Inverter, roster_names # noqa
from app.main import Controller # noqa: E402
from app import mqtt # noqa: E402
from app.p1 import is_enabled # noqa: E402
from app.store import (DEFAULTS, MAINT_UNIT_KEYS, Store, # noqa: E402
unit_key)
from app.store import (DEFAULTS, MAINT_SCHEMA_KEY, MAINT_UNIT_KEYS, # noqa: E402
SCHEMA_KEY, Store, unit_key)
from predeploy_check import option_keys, shape_problem # noqa: E402
EXIT_OK, EXIT_FINDING, EXIT_COULD_NOT_RUN = 0, 1, 2
EXIT_OK, EXIT_FINDING, EXIT_COULD_NOT_RUN, EXIT_DEGRADED = 0, 1, 2, 3
ADDON = Path(__file__).resolve().parent.parent
class Refused(Exception):
@@ -184,8 +189,29 @@ def load_options(path: str) -> dict:
obj = _mini_yaml(text)
else:
obj = yaml.safe_load(text)
if not isinstance(obj, dict):
raise Refused(f"{path}: options must be a mapping, got {type(obj).__name__}")
# SAFETY-62: a wrapped export is a dict too, so assert the keys are options.
bad = shape_problem(obj, option_keys(str(ADDON)).__contains__, path)
if bad:
raise Refused(bad)
return obj
_UNIT_PREFIX = unit_key("\0", "").split("\0")[0]
_STORE_KEYS = {*DEFAULTS, *MAINT_UNIT_KEYS, SCHEMA_KEY, MAINT_SCHEMA_KEY}
def load_state(path: str) -> dict:
"""Refuse a state file `Store.load()` would silently start fresh on (SAFETY-62)."""
try:
obj = json.loads(Path(path).read_text(encoding="utf-8"))
except ValueError as err:
raise Refused(f"{path}: not a readable JSON state file ({err}); "
"Store.load() would start fresh and grade an empty store")
bad = shape_problem(
obj, lambda k: k in _STORE_KEYS or str(k).startswith(_UNIT_PREFIX),
path, allow_empty=True)
if bad:
raise Refused(bad)
return obj
@@ -1061,6 +1087,7 @@ def main(argv=None) -> int:
if args.live_entities else None)
if not Path(args.state).is_file():
raise Refused(f"{args.state}: not a file")
load_state(args.state)
except (OSError, Refused) as err:
print(f"REFUSED: {err}", file=sys.stderr)
return EXIT_COULD_NOT_RUN
@@ -1179,7 +1206,9 @@ def main(argv=None) -> int:
print(" the install's own entity list. This result does not cover "
"orphaned entities.")
print("=" * 78)
return EXIT_FINDING if findings else EXIT_OK
if findings:
return EXIT_FINDING
return EXIT_DEGRADED if degraded else EXIT_OK
if __name__ == "__main__":