fix(config): refuse unreadable config overwrites

This commit is contained in:
luyifan 2026-07-05 18:15:14 +08:00 committed by kshitij
parent 5b04a024a5
commit b109adede6
3 changed files with 70 additions and 1 deletions

View file

@ -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:

View file

@ -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:

View file

@ -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})