mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
fix(config): refuse unreadable config overwrites
This commit is contained in:
parent
5b04a024a5
commit
b109adede6
3 changed files with 70 additions and 1 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue