Conversation
When config.yaml has a YAML parse error, the gateway falls back to .env/gateway.json defaults. Without a guard, any subsequent save_config() call overwrites the user's real settings with those defaults — destroying custom_providers, mcp_servers, matrix config, etc. This adds: - HERMES_CONFIG_PARSE_FAILED env var guard set by gateway on parse failure, checked by save_config() and save_config_value() before writing. - config.yaml.broken / config.yaml.error artifacts saved for recovery. - config.recover_status / config.recover_write TUI gateway RPC methods. - hermes config recover CLI command that calls an LLM directly to fix the broken YAML, validates the output, and writes via write_raw_config() (bypasses the guard intentionally). - write_raw_config() in hermes_cli/config.py for the recovery escape hatch. - raw_text parameter on atomic_yaml_write() for arbitrary content writes. Recovery artifacts are cleaned up on next successful config load.
teknium1
left a comment
There was a problem hiding this comment.
Thanks for targeting a real destructive-config failure mode. Current main still has a fresh-process variant: hermes_cli/config.py:6937-6993 falls back to defaults when no last-known-good config exists, while tui_gateway/server.py:13929-13944 loads and saves that config during tools.configure.
Problems
- The new flag is set only by
gateway/config.py:778. The TUI mutation path does not callload_gateway_config(); its sole call is the handoff flow attui_gateway/server.py:6360. A fresh TUI process with invalid YAML can therefore still reachsave_config()without the guard. _config_recoverincludes complete raw YAML in an OpenAI request. Config files can contain API keys (cli.py:3657), so this may disclose credentials to the repair provider.- The diff adds recovery RPC methods but no
ui-tui/client integration, leaving them unused.
Suggested changes
- Put parse-validity refusal at the shared config write boundary, rather than in a gateway-local environment flag.
- Add a fresh-process TUI regression that asserts malformed-config bytes are unchanged after
tools.configure. - Split or redact-and-confirm the LLM recovery flow before sending config contents externally.
Automated hermes-sweeper review.
| # Guard: prevent save_config / save_config_value from writing back | ||
| # defaults (which would destroy the user's real config). Cleared | ||
| # on the next successful config load above. | ||
| os.environ.setdefault("HERMES_CONFIG_PARSE_FAILED", "1") |
There was a problem hiding this comment.
This flag is set only when load_gateway_config() runs. tui_gateway/server.py's tools.configure path calls shared load_config() then save_config() directly, so a fresh TUI process with invalid YAML never receives this guard. Put parse-validity refusal in the shared write boundary and cover that path with an E2E regression.
|
Hey @Societus — thanks for putting this PR together, it targets a genuinely destructive failure mode. I hit it in production today, and the trigger is exactly the fresh-process gap @teknium1 flagged in review. Wanted to share my reproduction + evidence in case it helps move this forward. What happenedRan What was lost: all custom providers, fallback provider chains, secrets provider configuration, MCP server configs, platform toolset assignments, and plugin enable/disable state. The dashboard UI showed every tab blank — plugins, MCPs, memory provider, compression engine, all reset to defaults. Recovery was possible only because a pre-update state snapshot had captured the intact config + env files minutes before the pull. Forensic evidenceThe gateway boot on new code showed a degraded The Why this PR's current guard wouldn't have caught itThe Code-level root cause (verified on current
|
The gateway-local HERMES_CONFIG_PARSE_FAILED flag only protects processes
that went through load_gateway_config(). A fresh TUI or dashboard process
reaches save_config() via tools.configure without that flag ever being set.
If config.yaml is unparseable, read_raw_config() returns {}, and save_config()
proceeds to write defaults — wiping every custom section.
This adds a fail-closed guard at the shared write boundary in both
save_config() and set_config_value(): if config.yaml exists and is non-empty
on disk but read_raw_config() returned {}, refuse the write.
Co-authored-by: ruangraung <noreply.github.com>
Co-documented-by: ruangraung
Reported-by: teknium1
The reproduction, root-cause analysis, and guard prototype were provided
by @ruangraung in PR NousResearch#14276 discussion. The approach was recommended by
@teknium1 in review.
|
@ruangraung — solid reproduction and root-cause analysis. I was checking some update on this when I got the initial sweeper notification, but your comment added some good detail and an expanded on it a bit. I verified every claim against current main: read_raw_config() swallows exceptions to {}, require_readable_config_before_write() only checks file readability via stat + read(1) with no parse validity check, and the tools.configure path at tui_gateway/server.py:14116 calls save_config() without going through load_gateway_config(), so the gateway-local flag in this PR never fires on that path. I implemented your suggested guard at the shared write boundary. Both save_config() and set_config_value() now refuse to write when config.yaml is non-empty on disk but read_raw_config() returns {}. Three regression tests cover the wipe scenario, the fresh-install edge case, and the set_config_value path. 121/121 config tests pass. The commit is in def52cc, credited to you as co-author since the reproduction, root-cause analysis, and guard prototype were all yours. The credential redaction concern @teknium1 raised on the LLM recovery flow is a separate issue I'll address in a follow-up so it doesn't block this one. |
The config-recovery flow (_config_recover CLI and config.recover_status/ config.recover_write TUI RPCs) sends broken config.yaml content to an external LLM for syntax repair. config.yaml routinely contains inline API keys (provider api_key fields), MCP server tokens, and database passwords — all disclosed to the repair provider in cleartext. This adds reversible secret redaction: - _redact_yaml_secrets() walks parsed YAML, replaces values under credential key names (api_key, token, password, etc.) with numbered placeholders (__REDACTED_N__), and catches inline token patterns (sk-, ghp_, etc.) via the existing agent.redact prefix regexes. Falls back to regex-only redaction when the YAML can't be parsed. - _restore_yaml_secrets() replaces placeholders with original values after the LLM returns repaired YAML. - CLI flow: redacts before prompt, restores after LLM response. - TUI flow: recover_status redacts and stashes the mapping; recover_write restores from the mapping before writing. Addresses @teknium1 review concern: 'includes complete raw YAML in an OpenAI request. Config files can contain API keys, so this may disclose credentials to the repair provider.'
Integration testing of the recovery flow revealed that the YAML-level walk in _redact_yaml_secrets stored masked values in the secret mapping instead of originals. The Hermes venv ships a security-hardened PyYAML C extension that masks known token patterns (sk-*, ghp_*, etc.) at the scanner level before Python code sees them — so yaml.safe_load() on broken config text returns already-masked strings like "sk-or-...f456". The restore step then wrote the masked version back, corrupting credentials in the repaired config. Reworked the redaction to operate on raw text before any YAML parsing. The key-name walk (api_key, token, password, etc.) now runs as a regex pass on the raw YAML string, matching the indent-aware "key: value" pattern. This catches the same credentials the YAML walk did, but sees real values because the C scanner never runs. The inline-token prefix patterns (sk-, ghp_, etc.) remain as a second layer on raw text, unchanged from the previous commit. Also fixes a pre-existing bug in write_raw_config(): the function referenced atomic_yaml_write as a bare name without importing it, silently raising NameError and returning False. Added the missing import. Added integration tests (test_config_recover_integration.py) that exercise the full CLI and TUI recovery flows end-to-end (mocked LLM): - Real secrets never appear in the LLM prompt - Real secrets are present in the written config file after restore - The TUI recover_status → recover_write round-trip preserves secrets The unit tests from the previous commit could not catch this because they validated the round-trip against values that were already masked by the C extension on input.
|
I double checked the failing test slices 4,5 and 8 and found no impact from the commits towards the PR. Loading and saving config portions of gateway/config.py, hermes_cli/config.py, cli.py, utils.py, and scripts/release.py should be safe. The failing tests all live in tests/agent/test_compression_concurrent_fork.py, a compression lock test added after this PR opened. It has a timing race on a threading.Event that's unrelated to config parsing. |
|
@Societus I dug into the CI failure myself before posting, and I think the cause is different from what we assumed, and it's actually our guard, not a pre-existing test. The failing tests are not
Root cause: the guard itself. The job logs show the guard firing right before each failure:
Those profile-scoped dashboard tests create a fresh Why it misfires: Suggested fix: refuse only on a real parse error, not on a valid-empty result. In the guard, attempt the parse inline and only refuse when YAML actually raises: import yaml
try:
with open(config_path, "r", encoding="utf-8") as _f:
yaml.safe_load(_f)
except (yaml.YAMLError, OSError, UnicodeDecodeError):
logger.warning(
"Refusing to save config: %s failed to parse — writing now "
"would wipe all custom settings. Fix the YAML syntax, then retry.",
config_path,
)
return
# a valid `{}` parses fine and falls through to the normal write pathThat keeps the original protection (a garbage/unreadable config won't be wiped) while letting a fresh Heads up on our side too: my local fail-closed patch uses the same Happy to sketch the exact diff + a regression test (a |
|
@ruangraung However you'd like to proceed, please feel free to do so, unfortunately yesterday was my last day to have any tinkering time for the week so anything I do next is going to be probably a week or more out. |
|
@Societus thank you for the handoff, and nice work on the guard intent and the secret-redaction hardening, good catches. I've opened a follow-up PR (#NEW) that fixes the CI regression. The issue was that Applied to both When you're back next week, we'd love your review. Happy to cherry-pick your original commits if that's cleaner, or absorb any feedback. |
|
Closing — the write-guard half of this PR is now superseded by #96169 (salvage of #71385): the shared You get credit for being the first to identify and attack this bug class back in April — the "save_config({}) after a swallowed parse error mass-destroys custom_providers" diagnosis in your description is exactly the failure mode the merged fix closes, and #65975/#71385 both built on the trail this PR started. The |
Body
When config.yaml has a YAML syntax error, the gateway falls back to
.env/gateway.jsondefaults and keeps running. The problem is that any subsequentsave_config()call — a model switch, a statusbar toggle — writes those bare defaults back to disk, destroying the user's custom_providers, mcp_servers, matrix config, everything that wasn't in the fallback. This is particularly nasty for credential hygiene:_load_cfg()swallows the parse error and returns{}, so any TUI-triggered config write callssave_config({}), which normalizes against DEFAULT_CONFIG and writes defaults to disk — stripping every custom provider and mass-orphaning their credentials in auth.json at once.This adds an environment flag (
HERMES_CONFIG_PARSE_FAILED) that the gateway sets on parse failure.save_config()andsave_config_value()check it and refuse to write. The gateway keeps running on fallback defaults, sessions keep working, but the broken config stays untouched on disk. The flag clears on the next successful load.For recovery,
hermes config recoverreads the broken YAML and error, calls an LLM directly to fix the syntax, validates the output, and writes the repaired config throughwrite_raw_config()— a bypass that skips the guard intentionally. The TUI gateway also getsconfig.recover_statusandconfig.recover_writeRPC methods for frontend integration.Changes
gateway/config.py: sets/clears guard flag, savesconfig.yaml.broken/config.yaml.errorhermes_cli/config.py:save_config()checks guard; addswrite_raw_config()for recoverycli.py:save_config_value()checks guardutils.py:atomic_yaml_write()gainsraw_textparameterhermes_cli/main.py: addshermes config recover --model <spec>subcommandtui_gateway/server.py: addsconfig.recover_statusandconfig.recover_writeRPC methodsTesting
65 existing config/gateway tests pass. Guard verified: flag blocks save_config, file mtime unchanged.