diff --git a/tests/tools/test_file_tools.py b/tests/tools/test_file_tools.py index c2d75bf5d06c..46b88b21d595 100644 --- a/tests/tools/test_file_tools.py +++ b/tests/tools/test_file_tools.py @@ -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" diff --git a/tools/file_tools.py b/tools/file_tools.py index 3b6f45942252..4664aa47da4a 100644 --- a/tools/file_tools.py +++ b/tools/file_tools.py @@ -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.""" @@ -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