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