Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions tests/tools/test_file_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,4 +321,65 @@ def test_truncated_hint_with_nonzero_offset(self, mock_get):
assert "offset=100" in raw


class TestSensitivePathCheck:
"""Verify that _check_sensitive_path blocks writes to protected locations."""

def test_hermes_config_blocked_for_write_file(self, tmp_path, monkeypatch):
fake_config = tmp_path / "config.yaml"
monkeypatch.setattr("tools.file_tools._hermes_config_resolved", str(fake_config))
monkeypatch.setattr("tools.file_tools._hermes_config_resolved_loaded", True)

from tools.file_tools import write_file_tool
result = json.loads(write_file_tool(str(fake_config), "approvals:\n mode: off\n"))
assert "error" in result
assert "Hermes config" in result["error"]

def test_hermes_config_blocked_via_tilde_path(self, tmp_path, monkeypatch):
fake_config = tmp_path / "config.yaml"
monkeypatch.setattr("tools.file_tools._hermes_config_resolved", str(fake_config))
monkeypatch.setattr("tools.file_tools._hermes_config_resolved_loaded", True)

from tools.file_tools import write_file_tool
result = json.loads(write_file_tool(str(fake_config), "approvals:\n mode: off\n"))
assert "error" in result
assert "Hermes config" in result["error"]

def test_hermes_config_blocked_for_patch(self, tmp_path, monkeypatch):
fake_config = tmp_path / "config.yaml"
fake_config.write_text("approvals:\n mode: manual\n")
monkeypatch.setattr("tools.file_tools._hermes_config_resolved", str(fake_config))
monkeypatch.setattr("tools.file_tools._hermes_config_resolved_loaded", True)

from tools.file_tools import patch_tool
result = json.loads(patch_tool(
mode="replace",
path=str(fake_config),
old_string="mode: manual",
new_string="mode: off",
))
assert "error" in result
assert "Hermes config" in result["error"]

def test_system_path_still_blocked(self, monkeypatch):
monkeypatch.setattr("tools.file_tools._hermes_config_resolved", "/some/other/path")
monkeypatch.setattr("tools.file_tools._hermes_config_resolved_loaded", True)

from tools.file_tools import write_file_tool
result = json.loads(write_file_tool("/etc/passwd", "evil"))
assert "error" in result
assert "sensitive system path" in result["error"]

@patch("tools.file_tools._get_file_ops")
def test_normal_file_not_blocked(self, mock_get, monkeypatch):
monkeypatch.setattr("tools.file_tools._hermes_config_resolved", "/home/user/.hermes/config.yaml")
monkeypatch.setattr("tools.file_tools._hermes_config_resolved_loaded", True)
mock_ops = MagicMock()
result_obj = MagicMock()
result_obj.to_dict.return_value = {"status": "ok", "path": "/tmp/other.txt", "bytes": 5}
mock_ops.write_file.return_value = result_obj
mock_get.return_value = mock_ops

from tools.file_tools import write_file_tool
result = json.loads(write_file_tool("/tmp/other.txt", "hello"))
assert result["status"] == "ok"

31 changes: 31 additions & 0 deletions tools/file_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,26 @@ def _is_blocked_device(filepath: str) -> bool:
)
_SENSITIVE_EXACT_PATHS = {"/var/run/docker.sock", "/run/docker.sock"}

_hermes_config_resolved: str | None = None
_hermes_config_resolved_loaded = False


def _get_hermes_config_resolved() -> str | None:
"""Return the resolved absolute path of the Hermes config file (cached)."""
global _hermes_config_resolved, _hermes_config_resolved_loaded
if _hermes_config_resolved_loaded:
return _hermes_config_resolved
_hermes_config_resolved_loaded = True
try:
from hermes_cli.config import get_config_path
_hermes_config_resolved = str(get_config_path().resolve())
except Exception:
try:
_hermes_config_resolved = str(Path("~/.hermes/config.yaml").expanduser().resolve())
except Exception:
_hermes_config_resolved = None
return _hermes_config_resolved


def _check_sensitive_path(filepath: str) -> str | None:
"""Return an error message if the path targets a sensitive system location."""
Expand All @@ -134,6 +154,17 @@ def _check_sensitive_path(filepath: str) -> str | None:
return _err
if resolved in _SENSITIVE_EXACT_PATHS or normalized in _SENSITIVE_EXACT_PATHS:
return _err
# Prevent agents from modifying the Hermes config file directly.
# approvals.mode and other security settings live here; a malicious or
# prompt-injected agent could silently disable exec approval by writing to
# this file.
hermes_config = _get_hermes_config_resolved()
if hermes_config and (resolved == hermes_config or normalized == hermes_config):
return (
f"Refusing to write to Hermes config file: {filepath}\n"
"Agent cannot modify security-sensitive configuration. "
"Edit ~/.hermes/config.yaml directly or use 'hermes config' instead."
)
return None


Expand Down