hermes-agent/tests/tools/test_file_write_safety.py
Teknium 39975613b1
test: prune wave 2 + speed fixes — 28,106 → 19,757 test functions, suite wall 315s → 294s
Second, deeper pass over tools/gateway/hermes_cli plus first pass over
the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker,
dashboard, conformance, monitoring, secret_sources, hermes_state,
providers). Same rubric as wave 1 (AGENTS.md test policy); security,
alternation/caching invariants, issue-number regressions, and E2E kept.

Real test-quality fixes found and rooted out along the way:
- tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls
  (DEFAULT_CONFIG smart-approval leaked in) — pinned approval
  mode=manual via autouse fixture: 17.4s → 0.4s.
- test_model_switch_custom_providers.py / test_user_providers_model_switch.py
  silently probed live provider catalogs (~2s/test) — stubbed
  cached_provider_model_ids/provider_model_ids/fetch_api_models.
- test_telegram_noise_filter.py: 15-platform copy-paste matrix over
  shared gateway.run logic → 3 representative platforms (55s → 3.9s).
- test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on
  MagicMock agents — interrupt.side_effect now clears _running_agents
  (22s → 1.0s).
- test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x
  (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps
  patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait
  5s → 0.5s.
- test_telegram_init_deadline.py: loop-block margin restored to 1.0s
  with rationale comment — the watchdog-dump assertion needs the loop
  blocked well past deadline+grace under parallel load (flaked once in
  the 40-worker verification run at a 0.2s margin).

Verification: full hermetic suite via scripts/run_tests.sh —
2,438 files, 21,718 tests passed, 0 failed, 293.9s wall.
Suite totals vs original baseline: 46,820 → 19,757 test functions
(−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
2026-07-29 13:39:40 -07:00

313 lines
12 KiB
Python

"""Tests for file write safety and HERMES_WRITE_SAFE_ROOT sandboxing.
Based on PR #1085 by ismoilh (salvaged).
"""
import os
from pathlib import Path
import pytest
from tools.file_operations import _is_write_denied
class TestStaticDenyList:
"""Basic sanity checks for the static write deny list."""
def test_temp_file_not_denied_by_default(self, tmp_path: Path):
target = tmp_path / "regular.txt"
assert _is_write_denied(str(target)) is False
def test_etc_shadow_is_denied(self):
assert _is_write_denied("/etc/shadow") is True
class TestSafeWriteRoot:
"""HERMES_WRITE_SAFE_ROOT should sandbox writes to a specific subtree."""
def test_writes_inside_safe_root_are_allowed(self, tmp_path: Path, monkeypatch):
safe_root = tmp_path / "workspace"
child = safe_root / "subdir" / "file.txt"
os.makedirs(child.parent, exist_ok=True)
monkeypatch.setenv("HERMES_WRITE_SAFE_ROOT", str(safe_root))
assert _is_write_denied(str(child)) is False
def test_safe_root_with_tilde_expansion(self, tmp_path: Path, monkeypatch):
"""~ in HERMES_WRITE_SAFE_ROOT should be expanded."""
# Use a real subdirectory of tmp_path so we can test tilde-style paths
safe_root = tmp_path / "workspace"
inside = safe_root / "file.txt"
os.makedirs(safe_root, exist_ok=True)
monkeypatch.setenv("HERMES_WRITE_SAFE_ROOT", str(safe_root))
assert _is_write_denied(str(inside)) is False
def test_safe_root_does_not_override_static_deny(self, tmp_path: Path, monkeypatch):
"""Even if a static-denied path is inside the safe root, it's still denied."""
# Point safe root at home to include ~/.ssh
monkeypatch.setenv("HERMES_WRITE_SAFE_ROOT", os.path.expanduser("~"))
assert _is_write_denied(os.path.expanduser("~/.ssh/id_rsa")) is True
class TestMultipleSafeWriteRoots:
"""HERMES_WRITE_SAFE_ROOT with multiple colon-separated directories."""
def test_write_inside_first_root_allowed(self, tmp_path: Path, monkeypatch):
root_a = tmp_path / "workspace_a"
root_b = tmp_path / "workspace_b"
child = root_a / "subdir" / "file.txt"
os.makedirs(child.parent, exist_ok=True)
os.makedirs(root_b, exist_ok=True)
monkeypatch.setenv("HERMES_WRITE_SAFE_ROOT", f"{root_a}{os.pathsep}{root_b}")
assert _is_write_denied(str(child)) is False
def test_trailing_separator_ignored(self, tmp_path: Path, monkeypatch):
root = tmp_path / "workspace"
inside = root / "file.txt"
os.makedirs(root, exist_ok=True)
monkeypatch.setenv("HERMES_WRITE_SAFE_ROOT", f"{root}{os.pathsep}")
assert _is_write_denied(str(inside)) is False
def test_static_deny_still_wins_with_multiple_roots(self, tmp_path: Path, monkeypatch):
"""Static deny list takes priority even when multiple safe roots include home."""
root = tmp_path / "workspace"
os.makedirs(root, exist_ok=True)
monkeypatch.setenv(
"HERMES_WRITE_SAFE_ROOT",
f"{root}{os.pathsep}{os.path.expanduser('~')}",
)
assert _is_write_denied(os.path.expanduser("~/.ssh/id_rsa")) is True
def test_duplicate_roots_deduplicated(self, tmp_path: Path, monkeypatch):
root = tmp_path / "workspace"
inside = root / "file.txt"
os.makedirs(root, exist_ok=True)
monkeypatch.setenv(
"HERMES_WRITE_SAFE_ROOT",
f"{root}{os.pathsep}{root}",
)
assert _is_write_denied(str(inside)) is False
class TestGetWriteDeniedError:
"""get_write_denied_error() should distinguish credential vs safe-root blocks."""
def test_credential_path_message(self):
from agent.file_safety import get_write_denied_error
err = get_write_denied_error(os.path.expanduser("~/.ssh/id_rsa"))
assert err is not None
assert "protected system/credential file" in err
assert "HERMES_WRITE_SAFE_ROOT" not in err
def test_safe_root_message(self, tmp_path: Path, monkeypatch):
from agent.file_safety import get_write_denied_error
safe_root = tmp_path / "workspace"
outside = tmp_path / "outside.txt"
os.makedirs(safe_root, exist_ok=True)
monkeypatch.setenv("HERMES_WRITE_SAFE_ROOT", str(safe_root))
err = get_write_denied_error(str(outside))
assert err is not None
assert "outside HERMES_WRITE_SAFE_ROOT" in err
assert str(safe_root) in err
assert "protected system/credential file" not in err
def test_allowed_path_returns_none(self, tmp_path: Path):
from agent.file_safety import get_write_denied_error
target = tmp_path / "ok.txt"
assert get_write_denied_error(str(target)) is None
class TestSafeRootDenialMessageIntegration:
"""Regression tests verifying that file-tools surface the correct denial
message when HERMES_WRITE_SAFE_ROOT blocks a path.
Prior to this fix, ALL write denials returned the same "protected
system/credential file" message regardless of root cause. These tests
exercise the actual write_file / patch_replace code path, not just
the get_write_denied_error() helper in isolation.
"""
@pytest.fixture
def ops(self, tmp_path: Path):
from tools.environments.local import LocalEnvironment
from tools.file_operations import ShellFileOperations
env = LocalEnvironment(cwd=str(tmp_path))
return ShellFileOperations(env, cwd=str(tmp_path))
def test_write_file_safe_root_outside_shows_safe_root_message(
self, ops, tmp_path: Path, monkeypatch
):
safe_root = tmp_path / "workspace"
safe_root.mkdir()
outside = tmp_path / "other" / "file.txt"
outside.parent.mkdir()
monkeypatch.setenv("HERMES_WRITE_SAFE_ROOT", str(safe_root))
res = ops.write_file(str(outside), "content")
assert res.error is not None
assert "outside HERMES_WRITE_SAFE_ROOT" in res.error
assert str(safe_root) in res.error
assert "credential" not in res.error
assert not outside.exists()
def test_write_file_credential_path_shows_credential_message(
self, ops, tmp_path: Path
):
res = ops.write_file("/etc/shadow", "content")
assert res.error is not None
assert "protected system/credential file" in res.error
assert "outside" not in res.error
def test_write_file_allowed_path_returns_no_error(
self, ops, tmp_path: Path, monkeypatch
):
safe_root = tmp_path / "workspace"
safe_root.mkdir()
inside = safe_root / "file.txt"
monkeypatch.setenv("HERMES_WRITE_SAFE_ROOT", str(safe_root))
res = ops.write_file(str(inside), "content")
assert res.error is None
assert inside.read_text() == "content"
class TestCheckSensitivePathMacOSBypass:
"""Verify _check_sensitive_path blocks /private/etc paths (issue #8734)."""
def test_etc_hosts_blocked(self):
from tools.file_tools import _check_sensitive_path
assert _check_sensitive_path("/etc/hosts") is not None
def test_private_etc_hosts_blocked(self):
from tools.file_tools import _check_sensitive_path
assert _check_sensitive_path("/private/etc/hosts") is not None
def test_private_etc_ssh_config_blocked(self):
from tools.file_tools import _check_sensitive_path
assert _check_sensitive_path("/private/etc/ssh/sshd_config") is not None
def test_private_var_blocked(self):
from tools.file_tools import _check_sensitive_path
assert _check_sensitive_path("/private/var/db/something") is not None
def test_boot_still_blocked(self):
from tools.file_tools import _check_sensitive_path
assert _check_sensitive_path("/boot/grub/grub.cfg") is not None
def test_safe_path_allowed(self):
from tools.file_tools import _check_sensitive_path
assert _check_sensitive_path("/tmp/safe_file.txt") is None
class TestAtomicWrite:
"""write_file / patch land via a temp-file + atomic rename.
The invariant: a write that fails partway NEVER corrupts the existing
file, and the swap is a real rename (so a reader either sees the full
old content or the full new content, never a half-written file). These
run against a real LocalEnvironment so the actual shell script executes.
"""
@pytest.fixture
def ops(self, tmp_path: Path):
from tools.environments.local import LocalEnvironment
from tools.file_operations import ShellFileOperations
env = LocalEnvironment(cwd=str(tmp_path))
return ShellFileOperations(env, cwd=str(tmp_path))
def test_overwrite_changes_inode(self, ops, tmp_path: Path):
# A real rename allocates a new inode for the target; an in-place
# rewrite would keep the same inode. This proves the swap is atomic.
target = tmp_path / "f.txt"
target.write_text("v1")
ino_before = os.stat(target).st_ino
res = ops.write_file(str(target), "v2 content")
assert res.error is None, res.error
assert target.read_text() == "v2 content"
assert os.stat(target).st_ino != ino_before
def test_no_temp_file_leaked_on_success(self, ops, tmp_path: Path):
target = tmp_path / "f.txt"
ops.write_file(str(target), "hello\n")
assert [p for p in os.listdir(tmp_path) if ".hermes-tmp" in p] == []
def test_patch_routes_through_atomic_write(self, ops, tmp_path: Path):
target = tmp_path / "edit.py"
target.write_text("a = 1\nb = 2\nc = 3\n")
os.chmod(target, 0o600)
res = ops.patch_replace(str(target), "b = 2", "b = 22")
assert res.success, res.error
assert target.read_text() == "a = 1\nb = 22\nc = 3\n"
assert (os.stat(target).st_mode & 0o777) == 0o600
class TestBomHandling:
"""UTF-8 BOM is stripped on read and preserved across write/patch.
A BOM (U+FEFF, bytes EF BB BF) is an invisible leading marker some
Windows editors prepend. The agent should never see it in read output,
but a file that had one on disk must keep it after an edit so the byte
signature is preserved.
"""
BOM = "\ufeff"
@pytest.fixture
def ops(self, tmp_path: Path):
from tools.environments.local import LocalEnvironment
from tools.file_operations import ShellFileOperations
env = LocalEnvironment(cwd=str(tmp_path))
return ShellFileOperations(env, cwd=str(tmp_path))
def test_helpers(self):
from tools.file_operations import _strip_bom, _has_bom
assert _strip_bom("\ufeffhello") == ("hello", True)
assert _strip_bom("hello") == ("hello", False)
assert _strip_bom("") == ("", False)
# mid-string BOM is data, not a marker — left alone
assert _strip_bom("a\ufeffb") == ("a\ufeffb", False)
assert _has_bom("\ufeffx") is True
assert _has_bom("x") is False
assert _has_bom(None) is False
def test_read_strips_bom(self, ops, tmp_path: Path):
target = tmp_path / "bom.py"
# Write raw bytes with a real UTF-8 BOM prefix.
target.write_bytes(self.BOM.encode("utf-8") + b"import os\nx = 1\n")
res = ops.read_file(str(target))
assert res.error is None, res.error
# Line 1 content must NOT carry the phantom U+FEFF.
first_line = res.content.split("\n", 1)[0]
assert self.BOM not in first_line
assert first_line.endswith("import os")
def test_patch_matches_first_line_through_bom(self, ops, tmp_path: Path):
# The whole point: an edit targeting the BOM-prefixed first line
# must match cleanly (the matcher sees BOM-stripped content).
target = tmp_path / "mod.py"
target.write_bytes(self.BOM.encode("utf-8") + b"import os\nimport sys\n")
res = ops.patch_replace(str(target), "import os", "import os, json")
assert res.success, res.error
raw = target.read_bytes()
assert raw == self.BOM.encode("utf-8") + b"import os, json\nimport sys\n"
if __name__ == "__main__":
pytest.main([__file__, "-v"])