SAFETY-60/62: operator tools refuse input they cannot read
checks / unit (pull_request) Successful in 8m31s
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:
co-authored by
Claude Opus 5.5
parent
a45ed06c05
commit
f4f72211a9
@@ -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.")
|
||||
|
||||
Reference in New Issue
Block a user