mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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 9cbcc0c9c8 → 732293cf87 → b0e47a98f9 →
1928aa0443). 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.
109 lines
4.3 KiB
Python
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
|