Files
hermes-agent/scripts/check_config_yaml_writers.py
JoaoMarcos44 16b214e1a1 fix(config): make destructive replacements explicit
atomic_config_write() now refuses deletion-by-omission at any mapping depth:
atomic_roundtrip_yaml_save treats keys absent from the payload as deleted
recursively, while the old docstring described the call as a merge, so a
partial dict could silently erase unrelated config (#125107). A new
atomic_config_replace() names the deliberate full-state replacement contract
explicitly, and the owners that intentionally remove state (save_config,
provider reset/switch, credential mirror scrub, doctor key migration, profile
channel stripping, TUI full-state saves) route through it. Additive writers
stay on the guarded atomic_config_write.

Salvages #125152 (identical patch, cherry-picked; conflict in
_write_user_config resolved to pass atomic_config_replace through the new
telemetry wrapper) and carries the executable bit main expects on
scripts/check_config_yaml_writers.py.

Fixes #125107

Co-authored-by: JoaoMarcos44 <joaomarcosdias444@gmail.com>
2026-09-29 20:24:19 -04:00

121 lines
4.8 KiB
Python
Executable File

#!/usr/bin/env python3
"""Fail when a ``config.yaml`` is written by anything but the comment-preserving writer.
Every writer of ``~/.hermes/config.yaml`` (and ``profiles/*/config.yaml``) must go through
``hermes_cli.config.atomic_config_write`` (safe) or ``atomic_config_replace`` (explicit full-state)
→ ``utils.atomic_roundtrip_yaml_save`` (ruamel
round-trip). A PyYAML dump (``yaml.dump`` / ``yaml.safe_dump`` / ``utils.atomic_yaml_write``)
of a config path re-serialises the parsed dict and destroys every user comment — the #92554
class, which regressed several times because each new writer picked the plain dumper again.
Flags, in the scanned trees:
* a call to ``atomic_yaml_write`` / ``yaml.dump`` / ``yaml.safe_dump`` / ``safe_dump`` whose
first argument names a config path (``config_path``, ``cfg_path``, ``config.yaml``,
``get_config_path()``, ``_active_config_path()``, ``live_path``);
* ``<config path>.write_text(... dump(...) ...)``;
* any ``yaml.dump`` / ``yaml.safe_dump`` / ``atomic_yaml_write`` call inside ``hermes_cli/config.py``
or ``hermes_cli/config_*.py`` (the config system has exactly one on-disk primitive).
Suppress a true false positive with ``# config-writer: ok — <why>`` on the call's line.
Usage: python3 scripts/check_config_yaml_writers.py [paths...]
"""
from __future__ import annotations
import ast
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
DEFAULT_TREES = ("hermes_cli", "agent", "gateway", "tui_gateway", "cron", "plugins", "tools", "cli.py", "utils.py")
# The config writer module and the on-disk primitive it wraps.
ALLOWED_FILES = {ROOT / "utils.py"}
DUMPERS = {"atomic_yaml_write", "safe_dump", "dump"}
CONFIG_PATH_RE = re.compile(
r"config_path|cfg_path|config\.yaml|get_config_path\(\)|_active_config_path\(\)|\blive_path\b")
CONFIG_MODULE_RE = re.compile(r"^hermes_cli/config(_[a-z_]+)?\.py$")
SUPPRESS = "# config-writer: ok"
def _func_name(node: ast.AST) -> str | None:
if isinstance(node, ast.Attribute):
return node.attr
if isinstance(node, ast.Name):
return node.id
return None
def _is_yaml_dump(call: ast.Call, src: str) -> bool:
name = _func_name(call.func)
if name == "atomic_yaml_write":
return True
if name in ("safe_dump", "dump"):
# ``yaml.dump`` / ``yaml.safe_dump`` / ``yaml_rt.dump``; plain ``json.dump`` is not YAML.
receiver = ast.get_source_segment(src, call.func.value) if isinstance(call.func, ast.Attribute) else ""
return "yaml" in (receiver or "").lower() or name == "safe_dump"
return False
def scan_file(path: Path) -> list[str]:
src = path.read_text(encoding="utf-8-sig")
try:
tree = ast.parse(src)
except SyntaxError:
return []
lines = src.splitlines()
rel = path.relative_to(ROOT).as_posix()
in_config_module = bool(CONFIG_MODULE_RE.match(rel))
problems: list[str] = []
def flag(node: ast.Call, why: str) -> None:
line = lines[node.lineno - 1]
if SUPPRESS in line:
return
problems.append(
f"{rel}:{node.lineno}: {why} — route it through the hermes_cli.config writer seam")
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
if _is_yaml_dump(node, src):
if in_config_module:
flag(node, "PyYAML dump inside the config system")
continue
first = ast.get_source_segment(src, node.args[0]) if node.args else ""
if first and CONFIG_PATH_RE.search(first):
flag(node, f"PyYAML dump of a config path ({first})")
elif _func_name(node.func) == "write_text" and isinstance(node.func, ast.Attribute):
receiver = ast.get_source_segment(src, node.func.value) or ""
body = ast.get_source_segment(src, node) or ""
if CONFIG_PATH_RE.search(receiver) and "dump(" in body:
flag(node, f"write_text of a YAML dump onto a config path ({receiver})")
return problems
def main(argv: list[str]) -> int:
targets = [ROOT / a for a in argv] or [ROOT / t for t in DEFAULT_TREES]
files: list[Path] = []
for t in targets:
if t.is_dir():
files.extend(p for p in t.rglob("*.py") if "node_modules" not in p.parts)
elif t.suffix == ".py" and t.exists():
files.append(t)
problems: list[str] = []
for f in sorted(set(files)):
if f.resolve() in ALLOWED_FILES:
continue
problems.extend(scan_file(f))
if problems:
print("config.yaml must only be written through the comment-preserving writer:", file=sys.stderr)
for p in problems:
print(f" {p}", file=sys.stderr)
return 1
print(f"check_config_yaml_writers: OK ({len(files)} files)")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))