NousResearch/hermes-agent

Config writes overwriting unreadable existing config.yaml files

Cluster 58781. Data as of

Nothing open on GitHub. Fix merged in #58986 on 5 Jul 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #58986. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #58986. That is separate from the fix event.
Still open
Nothing open on GitHub.

These members concern config writes that could overwrite an existing config.yaml that cannot be read. Two are pull requests titled as fixes to refuse such overwrites (one described as salvaging #58782), and one is an issue describing the behavior.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #58986 fix(config): refuse unreadable config overwrites (salvage #58782)

    merged to main

    files: agent/onboarding.py, gateway/platforms/yuanbao.py, gateway/slash_commands.py, hermes_cli/auth.py, hermes_cli/config.py, hermes_cli/doctor.py, hermes_cli/xai_retirement.py, plugins/platforms/telegram/adapter.py, tests/hermes_cli/test_config.py, tests/hermes_cli/test_migrate_xai.py, tui_gateway/server.py

    Show the diff
    • agent/onboarding.py
      @@ -209,7 +209,7 @@ def mark_seen(config_path: Path, flag: str) -> bool:
           """
           try:
               import yaml
      -        from utils import atomic_yaml_write
      +        from hermes_cli.config import atomic_config_write
           except Exception as e:  # pragma: no cover — dependency issue
               logger.debug("onboarding: failed to import yaml/utils: %s", e)
               return False
      @@ -228,7 +228,7 @@ def mark_seen(config_path: Path, flag: str) -> bool:
               if seen.get(flag) is True:
                   return True  # already marked — nothing to do
               seen[flag] = True
      -        atomic_yaml_write(config_path, cfg)
      +        atomic_config_write(config_path, cfg)
               return True
           except Exception as e:
               logger.debug("onboarding: failed to mark flag %s: %s", flag, e)
    • gateway/platforms/yuanbao.py
      @@ -1650,7 +1650,7 @@ async def handle(self, ctx: InboundContext, next_fn) -> None:
                   if _should_set:
                       try:
                           from hermes_constants import get_hermes_home
      -                    from utils import atomic_yaml_write
      +                    from hermes_cli.config import atomic_config_write
                           import yaml
       
                           _home = get_hermes_home()
      @@ -1660,7 +1660,7 @@ async def handle(self, ctx: InboundContext, next_fn) -> None:
                               with open(config_path, encoding="utf-8") as f:
                                   user_config = yaml.safe_load(f) or {}
                           user_config["YUANBAO_HOME_CHANNEL"] = ctx.chat_id
      -                    atomic_yaml_write(config_path, user_config)
      +                    atomic_config_write(config_path, user_config)
                           os.environ["YUANBAO_HOME_CHANNEL"] = str(ctx.chat_id)
                           logger.info(
                               "[%s] Auto-sethome: designated %s (%s) as Yuanbao home channel",
    • gateway/slash_commands.py
      @@ -38,10 +38,9 @@
           build_session_key,
           is_shared_multi_user_session,
       )
      -from hermes_cli.config import cfg_get, clear_model_endpoint_credentials
      +from hermes_cli.config import atomic_config_write, cfg_get, clear_model_endpoint_credentials
       from utils import (
           atomic_json_write,
      -    atomic_yaml_write,
           base_url_host_matches,
           is_truthy_value,
       )
      @@ -2093,7 +2092,7 @@ def _resolve_prompt(value):
                       if "agent" not in config or not isinstance(config.get("agent"), dict):
                           config["agent"] = {}
                       config["agent"]["system_prompt"] = ""
      -                atomic_yaml_write(config_path, config)
      +                atomic_config_write(config_path, config)
                   except Exception as e:
                       return t("gateway.personality.save_failed", error=str(e))
                   self._ephemeral_system_prompt = ""
      @@ -2106,7 +2105,7 @@ def _resolve_prompt(value):
                       if "agent" not in config or not isinstance(config.get("agent"), dict):
                           config["agent"] = {}
                       config["agent"]["system_prompt"] = new_prompt
      -                atomic_yaml_write(config_path, config)
      +                atomic_config_write(config_path, config)
                   except Exception as e:
                       return t("gateway.personality.save_failed", error=str(e))
       
      @@ -2652,7 +2651,7 @@ def _save_config_key(key_path: str, value):
                               current[k] = {}
                           current = current[k]
                       current[keys[-1]] = value
      -                atomic_yaml_write(config_path, user_config)
      +                atomic_config_write(config_path, user_config)
                       return True
                   except Exception as e:
                       logger.error("Failed to save config key %s: %s", key_path, e)
      @@ -2755,7 +2754,7 @@ def _set_approval(enabled: bool):
                       with open(config_path, encoding="utf-8") as f:
                           user_config = yaml.safe_load(f) or {}
                   user_config.setdefault("memory", {})["write_approval"] = bool(enabled)
      -            atomic_yaml_write(config_path, user_config)
      +            atomic_config_write(config_path, user_config)
                   # New setting must take effect next message → drop cached agent.
                   self._evict_cached_agent(session_key)
       
      @@ -2811,7 +2810,7 @@ def _set_approval(enabled: bool):
                       with open(config_path, encoding="utf-8") as f:
                           user_config = yaml.safe_load(f) or {}
                   user_config.setdefault("skills", {})["write_approval"] = bool(enabled)
      -            atomic_yaml_write(config_path, user_config)
      +            atomic_config_write(config_path, user_config)
                   # New setting must take effect next message → drop cached agent.
                   self._evict_cached_agent(session_key)
       
      @@ -2863,7 +2862,7 @@ def _save_config_key(key_path: str, value):
                               current[k] = {}
                           current = current[k]
                       current[keys[-1]] = value
      -                atomic_yaml_write(config_path, user_config)
      +                atomic_config_write(config_path, user_config)
                       return True
                   except Exception as e:
                       logger.error("Failed to save config key %s: %s", key_path, e)
      @@ -2960,7 +2959,7 @@ async def _handle_verbose_command(self, event: MessageEvent) -> str:
                   if platform_key not in display["platforms"] or not isinstance(display["platforms"].get(platform_key), dict):
                       display["platforms"][platform_key] = {}
                   display["platforms"][platform_key]["tool_progress"] = new_mode
      -            atomic_yaml_write(config_path, user_config)
      +            atomic_config_write(config_path, user_config)
                   return (
                       f"{descriptions[new_mode]}\n"
                       + t("gateway.verbose.saved_suffix", platform=platform_key)
      @@ -3035,7 +3034,7 @@ async def _handle_footer_command(self, event: MessageEvent) -> str:
                   if not isinstance(display.get("runtime_footer"), dict):
                       display["runtime_footer"] = {}
                   display["runtime_footer"]["enabled"] = new_state
      -            atomic_yaml_write(config_path, user_config)
      +            atomic_config_write(config_path, user_config)
               except Exception as e:
                   logger.warning("Failed to save runtime_footer.enabled: %s", e)
                   return t("gateway.config_save_failed", error=e)
    • hermes_cli/auth.py
      @@ -43,7 +43,12 @@
       
       import httpx
       
      -from hermes_cli.config import get_hermes_home, get_config_path, read_raw_config
      +from hermes_cli.config import (
      +    get_hermes_home,
      +    get_config_path,
      +    read_raw_config,
      +    require_readable_config_before_write,
      +)
       from hermes_constants import OPENROUTER_BASE_URL, secure_parent_dir
       from agent.credential_persistence import sanitize_borrowed_credential_payload
       from utils import atomic_replace, atomic_yaml_write, env_float, is_truthy_value
      @@ -6335,6 +6340,7 @@ def _update_config_for_provider(
           # Update config.yaml model section
           config_path = get_config_path()
           config_path.parent.mkdir(parents=True, exist_ok=True)
      +    require_readable_config_before_write(config_path)
       
           config = read_raw_config()
       
      @@ -6427,6 +6433,7 @@ def _reset_config_provider() -> Path:
           config_path = get_config_path()
           if not config_path.exists():
               return config_path
      +    require_readable_config_before_write(config_path)
       
           config = read_raw_config()
           if not config:
    • hermes_cli/config.py
      @@ -6499,6 +6499,56 @@ def read_raw_config() -> Dict[str, Any]:
               return data
       
       
      +def require_readable_config_before_write(config_path: Optional[Path] = None) -> None:
      +    """Refuse to replace an existing config.yaml that cannot be read."""
      +    if config_path is None:
      +        config_path = get_config_path()
      +    try:
      +        config_path.stat()
      +    except FileNotFoundError:
      +        return
      +    except OSError as exc:
      +        raise RuntimeError(
      +            f"Refusing to overwrite {config_path}: existing config.yaml cannot be accessed "
      +            f"({exc}). Fix the file permissions or move it aside first."
      +        ) from exc
      +
      +    try:
      +        with open(config_path, "rb") as f:
      +            f.read(1)
      +    except OSError as exc:
      +        raise RuntimeError(
      +            f"Refusing to overwrite {config_path}: existing config.yaml cannot be read "
      +            f"({exc}). Fix the file permissions or move it aside first."
      +        ) from exc
      +
      +
      +def atomic_config_write(config_path: Path, data: Any, **kwargs: Any) -> None:
      +    """Fail-closed atomic write for ``config.yaml``.
      +
      +    The single chokepoint every config-update path should use instead of
      +    calling :func:`utils.atomic_yaml_write` directly. It runs
      +    :func:`require_readable_config_before_write` first, so a full-file
      +    replacement can never silently clobber an existing ``config.yaml`` that
      +    degraded to an empty dict on read (permission error, broken mount,
      +    transient I/O). New-file creation still works when the path is absent.
      +
      +    Root cause this guards: ``read_raw_config()`` returns ``{}`` for BOTH an
      +    absent file and an unreadable-but-present file. Callers that read then
      +    overwrite can't tell the two apart, so an unreadable config would be
      +    replaced with only defaults or the single edited section. Routing every
      +    write through this helper enforces the invariant in one place rather than
      +    relying on each of ~15 independent write sites to remember the guard.
      +
      +    ``kwargs`` are forwarded verbatim to ``atomic_yaml_write``
      +    (``sort_keys``, ``default_flow_style``, ``extra_content``, ...).
      +    """
      +    from utils import atomic_yaml_write
      +
      +    require_readable_config_before_write(config_path)
      +    atomic_yaml_write(config_path, data, **kwargs)
      +
      +
       def load_config() -> Dict[str, Any]:
           """Load configuration from ~/.hermes/config.yaml.
       
      @@ -6864,6 +6914,7 @@ def save_config(
       
               ensure_hermes_home()
               config_path = get_config_path()
      +        require_readable_config_before_write(config_path)
               # Compute explicit user paths BEFORE any normalisation --------
               # _normalize_max_turns_config may inject agent.max_turns from
               # DEFAULT_CONFIG; using the raw dict preserves which paths the
      @@ -7843,6 +7894,7 @@ def set_config_value(key: str, value: str):
           # Read the raw user config (not merged with defaults) to avoid
           # dumping all default values back to the file
           config_path = get_config_path()
      +    require_readable_config_before_write(config_path)
           user_config = {}
           if config_path.exists():
               try:
    • hermes_cli/doctor.py
      @@ -983,8 +983,8 @@ def run_doctor(args):
                                   model_section[k] = raw_config.pop(k)
                               else:
                                   raw_config.pop(k)
      -                    from utils import atomic_yaml_write
      -                    atomic_yaml_write(config_path, raw_config)
      +                    from hermes_cli.config import atomic_config_write
      +                    atomic_config_write(config_path, raw_config)
                           check_ok("Migrated stale root-level keys into model section")
                           fixed_count += 1
                       else:
    • hermes_cli/xai_retirement.py
      @@ -242,6 +242,9 @@ def apply_migration(
               )
               shutil.copy2(config_path, backup_path)
       
      +    from hermes_cli.config import require_readable_config_before_write
      +
      +    require_readable_config_before_write(config_path)
           with config_path.open("w", encoding="utf-8") as fh:
               yaml.dump(doc, fh)
       
    • plugins/platforms/telegram/adapter.py
      @@ -2727,9 +2727,9 @@ def _persist_dm_topic_thread_id(
                       changed = True
       
                   if changed:
      -                from utils import atomic_yaml_write
      +                from hermes_cli.config import atomic_config_write
       
      -                atomic_yaml_write(
      +                atomic_config_write(
                           config_path,
                           config,
                           default_flow_style=False,
    • tests/hermes_cli/test_config.py
      @@ -24,6 +24,7 @@
           save_env_value,
           save_env_value_secure,
           sanitize_env_file,
      +    set_config_value,
           write_platform_config_field,
           _sanitize_env_lines,
       )
      @@ -267,6 +268,17 @@ def test_corrupt_symlink_config_not_backed_up(self, tmp_path):
       
       
       class TestSaveAndLoadRoundtrip:
      +    @staticmethod
      +    def _deny_config_reads(config_path):
      +        real_open = open
      +
      +        def fake_open(file, mode="r", *args, **kwargs):
      +            if Path(file) == config_path and "r" in mode:
      +                raise PermissionError("denied")
      +            return real_open(file, mode, *args, **kwargs)
      +
      +        return fake_open
      +
           def test_roundtrip(self, tmp_path):
               with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
                   config = load_config()
      @@ -282,6 +294,58 @@ def test_roundtrip(self, tmp_path):
                   assert saved["agent"]["max_turns"] == 42
                   assert "max_turns" not in saved
       
      +    def test_save_config_refuses_to_overwrite_unreadable_existing_config(self, tmp_path):
      +        config_path = tmp_path / "config.yaml"
      +        original = "model: test/original\n"
      +        config_path.write_text(original, encoding="utf-8")
      +
      +        with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
      +            with patch("builtins.open", side_effect=self._deny_config_reads(config_path)):
      +                with pytest.raises(RuntimeError, match="Refusing to overwrite"):
      +                    save_config({"model": "test/replacement"})
      +
      +        assert config_path.read_text(encoding="utf-8") == original
      +
      +    def test_config_set_refuses_to_overwrite_unreadable_existing_config(self, tmp_path):
      +        config_path = tmp_path / "config.yaml"
      +        original = "model:\n  provider: openrouter\n"
      +        config_path.write_text(original, encoding="utf-8")
      +
      +        with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
      +            with patch("builtins.open", side_effect=self._deny_config_reads(config_path)):
      +                with pytest.raises(RuntimeError, match="Refusing to overwrite"):
      +                    set_config_value("model.provider", "openai")
      +
      +        assert config_path.read_text(encoding="utf-8") == original
      +
      +    def test_atomic_config_write_refuses_unreadable_existing_config(self, tmp_path):
      +        """The shared chokepoint every sibling write site routes through must
      +        fail closed on an unreadable existing config.yaml — this locks in the
      +        whole bug class (gateway slash commands, doctor --fix, yuanbao/telegram
      +        auto-sethome, tui_gateway _save_cfg), not just the three named paths."""
      +        from hermes_cli.config import atomic_config_write
      +
      +        config_path = tmp_path / "config.yaml"
      +        original = "model:\n  provider: openrouter\n"
      +        config_path.write_text(original, encoding="utf-8")
      +
      +        with patch("builtins.open", side_effect=self._deny_config_reads(config_path)):
      +            with pytest.raises(RuntimeError, match="Refusing to overwrite"):
      +                atomic_config_write(config_path, {"model": {"provider": "openai"}})
      +
      +        assert config_path.read_text(encoding="utf-8") == original
      +
      +    def test_atomic_config_write_creates_new_file(self, tmp_path):
      +        """A genuinely absent config.yaml must still be created — the guard
      +        only refuses to clobber an existing-but-unreadable file."""
      +        from hermes_cli.config import atomic_config_write
      +
      +        config_path = tmp_path / "config.yaml"
      +        assert not config_path.exists()
      +        atomic_config_write(config_path, {"model": {"provider": "openrouter"}})
      +        assert config_path.exists()
      +        assert "openrouter" in config_path.read_text(encoding="utf-8")
      +
           def test_save_config_normalizes_legacy_root_level_max_turns(self, tmp_path):
               with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
                   save_config({"model": "test/custom-model", "max_turns": 37})
    • tests/hermes_cli/test_migrate_xai.py
      @@ -221,3 +221,30 @@ def test_apply_twice_is_safe(self, trap_config: Path):
               assert issues_2 == []
               result_2 = apply_migration(trap_config, issues_2)
               assert result_2.config_changed is False
      +
      +
      +# ---------------------------------------------------------------------------
      +# Fail-closed on unreadable existing config
      +# ---------------------------------------------------------------------------
      +
      +class TestUnreadableExistingConfig:
      +    def test_apply_refuses_to_overwrite_unreadable_config(self, trap_config: Path):
      +        """apply_migration must not clobber an existing config.yaml it can't
      +        read. It reads the file first (which raises on an unreadable file), and
      +        the require_readable_config_before_write guard before the write is a
      +        belt-and-suspenders backstop for the read-then-write window. Either way
      +        the original bytes must survive."""
      +        import os
      +
      +        issues = find_retired_xai_refs(_parse(trap_config))
      +        assert issues  # sanity: trap_config has retired refs
      +        original = trap_config.read_bytes()
      +
      +        os.chmod(trap_config, 0o000)
      +        try:
      +            with pytest.raises((PermissionError, RuntimeError, OSError)):
      +                apply_migration(trap_config, issues, backup=False)
      +        finally:
      +            os.chmod(trap_config, 0o644)
      +
      +        assert trap_config.read_bytes() == original
    • tui_gateway/server.py
      @@ -1805,10 +1805,10 @@ def _apply_managed(cfg: dict) -> dict:
       def _save_cfg(cfg: dict):
           global _cfg_cache, _cfg_mtime, _cfg_path
       
      -    from utils import atomic_yaml_write
      +    from hermes_cli.config import atomic_config_write
       
           path = _hermes_home / "config.yaml"
      -    atomic_yaml_write(path, cfg)
      +    atomic_config_write(path, cfg)
           with _cfg_lock:
               _cfg_cache = copy.deepcopy(cfg)
               _cfg_path = path

1 PR closed without merging

From the close comments: merged via another change 1

  • #58782 fix(config): refuse unreadable config overwrites · merged via another change → b109ade, #58986, #58781
Closed issues · 1
  • Closed#58781Config writes can overwrite an unreadable existing config.yaml
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Merged PRs
#58986
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
5 Jul 2026

For your agent

The same facts, typed.