diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 3547bb87323..91911e77e17 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -43,7 +43,12 @@ from urllib.parse import parse_qs, urlencode, urlparse 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: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index c9f267996c7..08ca6cadc27 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -6499,6 +6499,30 @@ 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 load_config() -> Dict[str, Any]: """Load configuration from ~/.hermes/config.yaml. @@ -6864,6 +6888,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 +7868,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: diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py index 37740c2ea75..60d23e2a80a 100644 --- a/tests/hermes_cli/test_config.py +++ b/tests/hermes_cli/test_config.py @@ -24,6 +24,7 @@ from hermes_cli.config import ( save_env_value, save_env_value_secure, sanitize_env_file, + set_config_value, write_platform_config_field, _sanitize_env_lines, ) @@ -267,6 +268,17 @@ class TestLoadConfigParseFailure: 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,30 @@ class TestSaveAndLoadRoundtrip: 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_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})