hermes-agent/tests/hermes_cli/test_config_read_guard.py
teknium1 ed33ebca1d refactor: canonical config loaders for behavioral reads + guarded raw-read primitive (kills the managed-scope/env-expansion drift class)
The disease: ~15 scattered raw yaml.safe_load(config.yaml) reads that
silently miss managed-scope overlay, ${ENV_VAR} expansion, profile-aware
pathing, and root-model normalization. Every new config feature needed an
N-site sweep (incident chain 9cbcc0c9c8732293cf87b0e47a98f91928aa0443). This commit assigns every raw read to an owner and adds a
lint-guard test so the class cannot regrow.

New primitive (additive-only change to hermes_cli/config.py):
  read_user_config_raw(path=None) — reads the user file EXACTLY as
  written; docstring states it is ONLY legal for write-back round-trips
  and raw-file diagnostics. Behavioral reads must use
  load_config()/load_config_readonly().

BEHAVIOR FIXES (class-a sites migrated to a canonical loader — these
previously read values that could DIFFER from the effective config):

  gateway/run.py _try_resolve_fallback_provider → _load_gateway_runtime_config
    keys: fallback_providers/fallback_model (provider, model, base_url,
    api_key). Drift fixed: a managed-pinned fallback chain was ignored;
    an api_key of "${OPENROUTER_API_KEY}" reached the resolver unexpanded.
  gateway/run.py GatewayRunner._load_provider_routing → same loader
    key: provider_routing. Drift fixed: managed-pinned routing prefs and
    ${VAR} templates were ignored.
  gateway/run.py GatewayRunner._load_fallback_model → same loader
    keys: fallback chain. Same drift as above.
  gateway/run.py GatewayRunner._refresh_fallback_model
    keeps the raw primitive (its last-known-good-on-parse-failure contract
    forbids the fail-open loader, which returns {} on a torn write) but now
    applies managed overlay + env expansion inline. Drift fixed: chain
    edits under managed scope / env templates were previously frozen out.
  tui_gateway/server.py _load_cfg (72 behavioral call sites)
    now = raw read + managed overlay (pre-existing) + NEW ${VAR} expansion,
    split from a new _load_cfg_raw() write-back primitive. Drift fixed:
    e.g. custom_prompt: "hello ${VAR}", agent.system_prompt, model,
    api_key/base_url templates reached sessions unexpanded. DEFAULT_CONFIG
    is deliberately NOT merged (callers treat missing keys as unset;
    `_load_cfg() == {}` sentinels and _save_cfg round-trips depend on it).
  tui_gateway/server.py _profile_configured_cwd
    keys: terminal.cwd of a NON-launch profile. Drift fixed: managed
    overlay + ${VAR} expansion now apply (load_config() would resolve the
    wrong profile's home, so the raw primitive + inline pipeline is used).
  plugins/platforms/telegram/adapter.py _reload_dm_topics_from_config
    → load_config_readonly(). keys: platforms.telegram.extra.dm_topics.
    Drift fixed: managed overlay + profile-aware pathing + expansion.
  plugins/memory/holographic _load_plugin_config → load_config_readonly().
    keys: plugins.hermes-memory-store.*. Same drift class.

WRITE-BACK ROUND-TRIPS (class-b: stay raw BY DESIGN via read_user_config_raw;
merging defaults/overlay would pollute the saved user file):
  gateway/slash_commands.py: model persist x2, _save_gateway_config_key,
    memory/skills write_approval toggles
  gateway/platforms/yuanbao.py auto-sethome
  tui_gateway/server.py _write_config_key + all cfg→_save_cfg blocks
    (reasoning show/hide/full/clamp, details_mode[.section], prompt)
    → new _load_cfg_raw()
  plugins/memory/holographic save_config

RAW-FILE DIAGNOSTICS + presence-sensitive bridges (class-c: stay raw,
now via the shared primitive with an explanatory comment):
  hermes_cli/doctor.py x5 (model validation, stale-root-keys, .env drift,
    deprecation sweep, memory-provider probe — the latter two keep their
    inline managed overlay where they had one)
  gateway/run.py _bridge_max_turns_from_config and the module-level
    TERMINAL_*/HERMES_* env bridge (bridging merged defaults would export
    all of DEFAULT_CONFIG into the environment; both keep their inline
    overlay + expansion)
  hermes_cli/send_cmd.py env bridge (same presence-sensitivity)
  hermes_cli/gateway.py multiplex-conflict probe (reads the DEFAULT root's
    config, not the active profile's — load_config is the wrong owner)
  hermes_cli/profiles.py / hermes_cli/web_server.py / tools/wake_word.py
    multi-profile reads (load_config targets only the ACTIVE profile home)
  cron/jobs.py _resolve_default_model_snapshot and cron/scheduler.py
    run_job config read keep their existing inline overlay+expansion but
    now share the primitive (their fail-open + last-value semantics and
    the deliberate no-defaults merge are preserved exactly).

Failure-semantics audit: every migrated site preserves its exact previous
behavior on missing file ({} / early return) and parse failure (raise into
the caller's existing except, warn, last-known-good, or fail-open) —
read_user_config_raw intentionally mirrors bare open()+safe_load semantics
(raises on parse errors, {} only on FileNotFoundError/non-dict root).

Guard: tests/hermes_cli/test_config_read_guard.py scans the tree for
yaml.safe_load within 6 lines of a 'config.yaml' reference outside an
explicit ALLOWLIST (hermes_cli/config.py, gateway/config.py, gateway/run.py
fallback path, hermes_cli/managed_scope.py which reads the MANAGED file,
gateway/readiness.py parse-health probe) and fails on new offenders.

E2E: tests/hermes_cli/test_config_loader_e2e.py runs a subprocess with a
temp HERMES_HOME (config.yaml containing ${E2E_PROMPT_SUFFIX}) plus a
HERMES_MANAGED_DIR overlay pinning agent.reasoning_effort, asserting
tui _load_cfg resolves "hello world"/"high" while _load_cfg_raw +
_save_cfg round-trip the template and user value verbatim with no
managed/default leakage.
2026-07-29 10:53:29 -07:00

109 lines
4.3 KiB
Python

"""Lint guard: no new raw yaml.safe_load(config.yaml) reads outside owner modules.
The drift class this kills: scattered ``yaml.safe_load`` reads of the user's
``config.yaml`` silently miss the managed-scope overlay, ``${ENV_VAR}``
expansion, profile-aware pathing, and root-model normalization. Each new
config feature has historically required an N-site sweep (incident chain:
9cbcc0c9c8 → 732293cf87 → b0e47a98f9 → 1928aa0443).
Canonical owners:
* ``hermes_cli/config.py`` — ``load_config()`` / ``load_config_readonly()``
(merged + managed + env-expanded), ``read_raw_config()`` and
``read_user_config_raw()`` (the ONLY legal raw primitives: write-back
round-trips + raw-file diagnostics).
* ``gateway/config.py`` — the gateway's ``load_gateway_config`` owner.
* ``gateway/run.py`` — ``_load_gateway_config()``'s monkeypatched-home
fallback path (delegates to ``read_raw_config`` when paths agree).
Everything else must import one of those. If this test fails on your new
code, use ``load_config()``/``load_config_readonly()`` for behavioral reads,
or ``read_user_config_raw()`` for write-back round-trips — do not add your
file to the allowlist without a reason of the same class.
"""
from __future__ import annotations
import re
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
# Files where a yaml.safe_load near a config.yaml reference is legal.
# Keep this list SHORT and justified:
ALLOWLIST = {
# Canonical loader owners.
"hermes_cli/config.py",
"gateway/config.py",
# _load_gateway_config()'s fallback path for tests that monkeypatch
# gateway.run._hermes_home (delegates to read_raw_config otherwise).
"gateway/run.py",
# Reads the MANAGED-scope config.yaml (/etc/hermes/...), not the user's —
# it IS the overlay source; the canonical loaders call into it.
"hermes_cli/managed_scope.py",
# Parse-health probe: intentionally answers "does the raw file parse?".
"gateway/readiness.py",
}
# Directories that never count (tests may build fixture configs freely).
EXCLUDED_DIR_PARTS = {
"tests", ".venv", ".git", ".worktrees", "node_modules", "website",
"docs", "scripts", "examples", "apps",
}
# A safe_load within this many lines of a config.yaml reference is treated
# as a raw user-config read.
PROXIMITY = 6
SAFE_LOAD_RE = re.compile(r"\bsafe_load\s*\(")
CONFIG_YAML_RE = re.compile(r"""["']config\.yaml["']""")
def _iter_source_files():
for path in REPO_ROOT.rglob("*.py"):
rel = path.relative_to(REPO_ROOT)
if any(part in EXCLUDED_DIR_PARTS for part in rel.parts):
continue
yield rel, path
def test_no_raw_config_yaml_reads_outside_owner_modules():
offenders: list[str] = []
for rel, path in _iter_source_files():
rel_str = str(rel).replace("\\", "/")
if rel_str in ALLOWLIST:
continue
try:
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError:
continue
cfg_lines = [i for i, ln in enumerate(lines) if CONFIG_YAML_RE.search(ln)]
if not cfg_lines:
continue
for i, ln in enumerate(lines):
if not SAFE_LOAD_RE.search(ln):
continue
# Comment/docstring mentions don't count.
stripped = ln.strip()
if stripped.startswith("#"):
continue
if any(abs(i - j) <= PROXIMITY for j in cfg_lines):
offenders.append(f"{rel_str}:{i + 1}: {stripped}")
assert not offenders, (
"Raw yaml.safe_load of config.yaml outside allowlisted owner modules.\n"
"Behavioral reads must use hermes_cli.config.load_config()/"
"load_config_readonly() (or gateway _load_gateway_config); write-back "
"round-trips and raw-file diagnostics must use "
"hermes_cli.config.read_user_config_raw().\nOffenders:\n "
+ "\n ".join(offenders)
)
def test_read_user_config_raw_exists_and_documented():
"""The shared raw primitive must exist and carry its legality docstring."""
from hermes_cli.config import read_user_config_raw
doc = read_user_config_raw.__doc__ or ""
assert "ONLY legal for write-back round-trips and raw-file diagnostics" in doc
assert "load_config()" in doc