hermes - ✅(Solved) Fix bug: `save_config` writes resolved plaintext back to config.yaml, destroying `${ENV_VAR}` references and leaking secrets [3 pull requests, 2 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
NousResearch/hermes-agent#11551Fetched 2026-04-18 06:00:18
View on GitHub
Comments
2
Participants
2
Timeline
8
Reactions
0
Author
Participants
Timeline (top)
cross-referenced ×4commented ×2closed ×1referenced ×1

When config.yaml uses ${ENV_VAR} references for secrets (e.g. api_key: ${TU_ZI_API_KEY}), any action that triggers save_config — including /model <name> --globaloverwrites those references with the resolved plaintext value. Subsequent reads and diffs will show the raw secret, defeating the purpose of using env-var interpolation in the first place.

This is a security footgun: users who move secrets out of config.yaml into ~/.hermes/.env following the documented guidance will find their keys silently rewritten into config.yaml the next time they change a model or any other setting persisted via /--global.

Root Cause

load_config() calls _expand_env_vars(...) unconditionally before returning the parsed dict:

https://github.com/NousResearch/hermes-agent/blob/main/hermes_cli/config.py#L2644

return _expand_env_vars(_normalize_root_model_keys(_normalize_max_turns_config(config)))

_expand_env_vars does a one-way substitution — \${VAR}os.environ[VAR] — with no record of the original template:

https://github.com/NousResearch/hermes-agent/blob/main/hermes_cli/config.py#L2542-L2547

if isinstance(obj, str):
    return re.sub(
        r\"\\\${([^}]+)}\",
        lambda m: os.environ.get(m.group(1), m.group(0)),
        obj,
    )

save_config then dumps the in-memory (already-expanded) dict verbatim to disk:

https://github.com/NousResearch/hermes-agent/blob/main/hermes_cli/config.py#L2744

There is no complementary re-collapse step on save, and no flag to preserve the raw string.

Fix Action

Workaround

Users must avoid every command that triggers save_config (including /model --global, hermes profile edits, some dashboard actions) and manually rewrite ${...} back after every accidental save. Not practical.

PR fix notes

PR #11579: fix(config): prevent save_config from leaking ${ENV_VAR} secrets to c…

Description (problem / solution / changelog)

…onfig.yaml #11551

What does this PR do?

This PR addresses a critical security footgun (Issue #11551) where save_config inadvertently writes resolved plaintext secrets back to config.yaml, destroying the original ${ENV_VAR} references.

When users follow the documentation to move secrets into ~/.hermes/.env using placeholder syntax (e.g., api_key: ${TU_ZI_API_KEY}), any action that persists the config (like /model <name> --global) currently collapses and hardcodes the actual secret into the YAML file.

Changes Made

  • Added a runtime _ENV_REVERSE_MAP to track injected secrets during _expand_env_vars.
  • Introduced _collapse_env_vars to restore the original ${VAR} placeholders.
  • Modified save_config to pipe the configuration dictionary through _collapse_env_vars immediately before serializing it to disk via atomic_yaml_write.

This ensures the runtime still consumes the expanded secrets naturally, while the on-disk config.yaml remains scrubbed and perfectly preserves the user's placeholder references.

Related Issue

Fixes #11551

Testing

  • Verified that load_config successfully expands the environment variables.
  • Verified that triggering a save_config operation (e.g., changing models globally) patches config.yaml while preserving the ${VAR} syntax instead of leaking the plaintext credentials.
<!-- Describe the change clearly. What problem does it solve? Why is this approach the right one? -->

Related Issue

<!-- Link the issue this PR addresses. If no issue exists, consider creating one first. -->

Fixes #

Type of Change

<!-- Check the one that applies. -->
  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

<!-- List the specific changes. Include file paths for code changes. -->

How to Test

<!-- Steps to verify this change works. For bugs: reproduction steps + proof that the fix works. -->

Checklist

<!-- Complete these before requesting review. -->

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: <!-- e.g. Ubuntu 24.04, macOS 15.2, Windows 11 -->

Documentation & Housekeeping

<!-- Check all that apply. It's OK to check "N/A" if a category doesn't apply to your change. -->
  • I've updated relevant documentation (README, docs/, docstrings) — or N/A
  • I've updated cli-config.yaml.example if I added/changed config keys — or N/A
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — or N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — or N/A
  • I've updated tool descriptions/schemas if I changed tool behavior — or N/A

For New Skills

<!-- Only fill this out if you're adding a skill. Delete this section otherwise. -->
  • This skill is broadly useful to most users (if bundled) — see Contributing Guide
  • SKILL.md follows the standard format (frontmatter, trigger conditions, steps, pitfalls)
  • No external dependencies that aren't already available (prefer stdlib, curl, existing Hermes tools)
  • I've tested the skill end-to-end: hermes --toolsets skills -q "Use the X skill to do Y"

Screenshots / Logs

<!-- If applicable, add screenshots or log output showing the fix/feature in action. -->

Changed files

  • hermes_cli/config.py (modified, +32/-13)

PR #11615: fix(config): preserve env refs when save_config rewrites config

Description (problem / solution / changelog)

Summary

load_config() expands ${ENV_VAR} references for runtime use, but save_config() was writing that expanded in-memory config back to config.yaml. Any flow that round-trips through save_config() after loading config could therefore replace env-backed secrets with plaintext on disk.

This PR preserves the raw ${ENV_VAR} templates from the existing config.yaml when the persisted value is otherwise unchanged after expansion. It also keeps that preservation working if the env var rotates between load_config() and save_config() in the same process.

Problem

This is the config round-trip secret leak described in issue #11551.

Before this change:

  • hermes_cli/config.py:load_config() always called _expand_env_vars(...)
  • hermes_cli/config.py:save_config() then dumped the expanded dict verbatim
  • a config like:
    api_key: ${TU_ZI_API_KEY}
    could become:
    api_key: sk-...
    after modifying some unrelated field and saving

That is a security footgun for users who intentionally keep secrets in .env instead of config.yaml.

Fix

  • hermes_cli/config.py

    • add _preserve_env_ref_templates(...) to restore raw ${VAR} templates from the existing on-disk config when the current value still semantically matches either the last loaded expansion or the current env expansion
    • cache the last expanded config returned by load_config() so env var rotation between load and save still preserves the raw template
    • keep mixed literal/template strings as caller-owned once their rendered value diverges, and document that boundary in code
    • fall back to positional list matching when named config objects contain duplicate name values instead of silently shadowing one entry
  • tests/hermes_cli/test_config_env_refs.py

    • unrelated config change preserves ${ENV_VAR}
    • env var rotation between load and save still preserves ${ENV_VAR}
    • partial-string edit boundary is documented with a regression test
    • duplicate name entries in custom_providers fall back to positional preservation
    • unresolved refs remain unchanged
    • intentional replacement of a secret field with a new literal still writes the new literal
  • tests/cli/test_cli_save_config_value.py

    • keep the regression guard proving the direct key-update path used by /model --global preserves unrelated env-ref fields

Tests

Ran targeted regression coverage:

python -m pytest -p no:cacheprovider \
  tests/hermes_cli/test_config.py \
  tests/cli/test_cli_save_config_value.py \
  tests/hermes_cli/test_config_env_refs.py -q

Result:

59 passed in 3.77s

I also re-ran a manual temp-HERMES_HOME repro with both an unrelated config edit and an env-var rotation between load/save. In both cases the raw ${TU_ZI_API_KEY} template stayed on disk.

Notes

The direct /model --global path on current main already writes through save_config_value() against raw YAML. The core bug fixed here is the broader save_config() round-trip path; this PR keeps the slash-command path covered as a regression guard.

The disk-reconciliation approach still does not remediate configs that already leaked plaintext secrets, and it cannot recover templates on the very first write when no raw config.yaml exists yet. I have kept those limitations out of scope for this PR rather than broadening the patch into a larger config refactor.

Changed files

  • hermes_cli/config.py (modified, +96/-4)
  • tests/cli/test_cli_save_config_value.py (modified, +18/-0)
  • tests/hermes_cli/test_config_env_refs.py (added, +169/-0)

PR #11892: fix(config): preserve ${ENV_VAR} placeholders through save_config (#11551)

Description (problem / solution / changelog)

Summary

save_config() no longer overwrites ${ENV_VAR} placeholders in config.yaml with resolved plaintext secrets. Fixes #11551.

Root cause

load_config() unconditionally calls _expand_env_vars(), resolving ${VAR} to the live env value in memory. Any subsequent save_config() — triggered by /model --global, profile switches, or any other persisted change — dumped that expanded dict back to disk, silently baking the plaintext secret into config.yaml and destroying the user's placeholder.

Approach

Salvaged from #11615 (binhnt92). On save, re-read the raw config from disk and walk it in parallel with the in-memory config. For each string leaf that was a ${…} template on disk, restore the template if the in-memory value matches either:

  • the env var's current expansion of that template, or
  • the expansion observed at the last load_config() call (cached by path)

If the in-memory value has been changed to something different, it's left alone — users can still intentionally replace a templated secret with a literal, and mixed-content strings like Bearer ${X} are handled too.

For named list entries (e.g. custom_providers), matching is by name so reordering doesn't drop the template. Falls back to positional matching when names are duplicated.

Why this one over the three other open PRs for #11551

PRApproachIssue
#11579 (devorun)Module-global reverse-map populated on expand; substring replace on saveGlobal state never clears, substring matching can false-positive, no tests, doesn't survive process restart
#11881 (kagura-agent)Re-read raw, restore by dotted pathNo safety check — blindly overwrites in-memory value with template even if user intentionally edited it
#10108 (allonious)Re-read raw, restore only where current value == os.environ[VAR]re.fullmatch means strings like https://api.com/${PATH} aren't handled; positional-only list matching
#11615 (binhnt92) — thisRe-read raw + cached load-time expansion + named-list matchingSemantically correct across env rotation, preserves intentional edits, handles partial templates

Will close the other three after this merges, with credit to each contributor.

Changes

  • hermes_cli/config.py: add _LAST_EXPANDED_CONFIG_BY_PATH cache, _preserve_env_ref_templates(), and _items_by_unique_name(); save_config() now pipes through the preserver before serializing
  • tests/hermes_cli/test_config_env_refs.py: 6 new scenarios covering unrelated-change, unresolved refs, intentional edits, env rotation, partial templates, duplicate-name positional fallback
  • tests/cli/test_cli_save_config_value.py: guard for save_config_value path

Validation

BeforeAfter
${TU_ZI_API_KEY} survives /model --globalplaintext leaks to config.yamlplaceholder preserved
Targeted tests (test_config_env_refs + test_config_env_expansion + test_cli_save_config_value)19 passing25 passing
E2E: load → unrelated change → savesecret in fileplaceholder in file
E2E: load → env var rotates → unrelated change → saveplaintext leaksplaceholder preserved, runtime uses new value
E2E: load → user assigns literal → savetemplate re-applied (bug)literal persisted

Changed files

  • hermes_cli/config.py (modified, +96/-4)
  • tests/cli/test_cli_save_config_value.py (modified, +18/-0)
  • tests/hermes_cli/test_config_env_refs.py (added, +169/-0)

Code Example

TU_ZI_API_KEY=sk-realsecret...

---

custom_providers:
   - name: tuzi
     base_url: https://api.tu-zi.com
     api_key: ${TU_ZI_API_KEY}
     model: claude-opus-4-6

---

/model doubao-pro --global

---

api_key: sk-realsecret...   # <- no longer ${TU_ZI_API_KEY}

---

return _expand_env_vars(_normalize_root_model_keys(_normalize_max_turns_config(config)))

---

if isinstance(obj, str):
    return re.sub(
        r\"\\\${([^}]+)}\",
        lambda m: os.environ.get(m.group(1), m.group(0)),
        obj,
    )
RAW_BUFFERClick to expand / collapse

Summary

When config.yaml uses ${ENV_VAR} references for secrets (e.g. api_key: ${TU_ZI_API_KEY}), any action that triggers save_config — including /model <name> --globaloverwrites those references with the resolved plaintext value. Subsequent reads and diffs will show the raw secret, defeating the purpose of using env-var interpolation in the first place.

This is a security footgun: users who move secrets out of config.yaml into ~/.hermes/.env following the documented guidance will find their keys silently rewritten into config.yaml the next time they change a model or any other setting persisted via /--global.

Reproduction

  1. In ~/.hermes/.env:
    TU_ZI_API_KEY=sk-realsecret...
  2. In ~/.hermes/config.yaml:
    custom_providers:
    - name: tuzi
      base_url: https://api.tu-zi.com
      api_key: ${TU_ZI_API_KEY}
      model: claude-opus-4-6
  3. Start Hermes and run any action that persists config, e.g. from the Feishu gateway:
    /model doubao-pro --global
  4. Re-open ~/.hermes/config.yaml:
    api_key: sk-realsecret...   # <- no longer ${TU_ZI_API_KEY}

Expected

${TU_ZI_API_KEY} (and any other ${...} reference) should survive a load → modify → save round-trip. The written file should be a minimal patch over the user's original source, not a resolved snapshot.

Actual

All ${...} references in the config are collapsed to their resolved values on the first save_config call. This happens on every /model --global, profile switch, or any other code path that writes the config.

Root Cause

load_config() calls _expand_env_vars(...) unconditionally before returning the parsed dict:

https://github.com/NousResearch/hermes-agent/blob/main/hermes_cli/config.py#L2644

return _expand_env_vars(_normalize_root_model_keys(_normalize_max_turns_config(config)))

_expand_env_vars does a one-way substitution — \${VAR}os.environ[VAR] — with no record of the original template:

https://github.com/NousResearch/hermes-agent/blob/main/hermes_cli/config.py#L2542-L2547

if isinstance(obj, str):
    return re.sub(
        r\"\\\${([^}]+)}\",
        lambda m: os.environ.get(m.group(1), m.group(0)),
        obj,
    )

save_config then dumps the in-memory (already-expanded) dict verbatim to disk:

https://github.com/NousResearch/hermes-agent/blob/main/hermes_cli/config.py#L2744

There is no complementary re-collapse step on save, and no flag to preserve the raw string.

Impact

  • Secrets that the user deliberately moved to .env end up written back to config.yaml, which is often committed / synced / shared.
  • Users following the docs ("Secrets go in .env, everything else in config.yaml") get the opposite of what they asked for.
  • Diff/PR workflows become painful: every --global action rewrites 10+ lines of secrets-vs-placeholders noise.

Suggested Fixes (pick one or combine)

  1. Preserve raw strings on load. Keep a shadow copy of the unexpanded config; the runtime consumes the expanded form, save_config writes the unexpanded form.
  2. Re-collapse before save. Maintain a reverse mapping from resolved value → original ${VAR} and substitute it back when serializing.
  3. Don't expand on load. Defer expansion to the point of use (network client, CLI invocation) rather than doing it at load time. This is the cleanest — the on-disk form is always the source of truth.
  4. Add a config flag preserve_env_refs: true that opts into (1) or (3) without breaking existing users.

Option 3 is structurally cleanest; option 1 is a drop-in fix.

Workaround

Users must avoid every command that triggers save_config (including /model --global, hermes profile edits, some dashboard actions) and manually rewrite ${...} back after every accidental save. Not practical.

Environment

  • Hermes version: current (as of 2026-04-17)
  • OS: macOS 24.6.0
  • Config: ~/.hermes/.env for secrets, ${VAR} references in config.yaml

extent analysis

TL;DR

The most likely fix is to modify the load_config and save_config functions to preserve the original ${...} references in the config file.

Guidance

  • Identify the lines of code responsible for expanding environment variables in the load_config function and consider modifying them to keep track of the original template.
  • Implement a mechanism to re-collapse the expanded values back to their original ${...} form before saving the config file.
  • Consider adding a config flag preserve_env_refs to opt into the new behavior without breaking existing users.
  • Review the save_config function to ensure it writes the unexpanded form of the config to disk.

Example

# Modified load_config function
def load_config():
    # ...
    config = _normalize_root_model_keys(_normalize_max_turns_config(config))
    # Keep track of the original template
    original_config = config
    config = _expand_env_vars(config)
    return config, original_config

# Modified save_config function
def save_config(config, original_config):
    # Write the unexpanded form of the config to disk
    with open('config.yaml', 'w') as f:
        yaml.dump(original_config, f)

Notes

The provided code snippets are based on the assumption that the load_config and save_config functions are responsible for expanding and saving the environment variables. The actual implementation may vary depending on the specifics of the codebase.

Recommendation

Apply the workaround by modifying the load_config and save_config functions to preserve the original ${...} references, as this is the most straightforward solution that does not require significant changes to the existing codebase.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

hermes - ✅(Solved) Fix bug: `save_config` writes resolved plaintext back to config.yaml, destroying `${ENV_VAR}` references and leaking secrets [3 pull requests, 2 comments, 2 participants]