mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-12 08:51:53 +00:00
* Inspired by Claude Code: /compress here [N] — boundary-aware 'summarize up to here' Adds a user-chosen compression boundary to the existing /compress command. /compress here [N] summarizes everything except the most recent N exchanges (default 2), which are preserved verbatim — letting the user pick the compression boundary instead of relying on the automatic token-budget heuristic. Inspired by Claude Code's Rewind 'Summarize up to here' action (v2.1.139, Week 20, May 2026): https://code.claude.com/docs/en/whats-new/2026-w20 - hermes_cli/partial_compress.py: pure split/parse helpers + seam-alternation guard (shared by CLI and gateway). - cli.py / gateway/run.py: route 'here [N]' / '--keep N' to partial compression; compress only the head, re-append the verbatim tail through the seam guard. - Preserves message-flow role alternation (seam guard merges any illegal user->user / assistant->assistant adjacency). - Reuses the existing _compress_context session-rotation/lock machinery — no changes to the compression core. - Bare /compress (full) and /compress <focus> behavior unchanged. Tests: 12 helper unit tests + 5 CLI integration tests + E2E (interleaved tool-call transcript, degenerate/multimodal seams, real handler path). * fix(file-tools): make write_file/patch atomic (temp-file + rename) write_file streamed content straight into the target via `cat > path`, so a crash, SIGKILL, or truncated pipe mid-write left the file half-written and corrupt. patch_replace routes through write_file, so it shared the flaw. Now writes stream into a temp file in the SAME directory and `mv` it over the target — a real same-filesystem rename, which is atomic on POSIX and on every terminal backend (local/docker/ssh/modal). A failed write leaves the original byte-intact and leaks no temp file. The existing file's mode is preserved across the swap (stat + chmod, GNU/BSD), and content still rides stdin so there's no ARG_MAX limit. A trap cleans the temp on any error path. Tests: added TestAtomicWrite (real LocalEnvironment, no mocks) covering inode-change-on-overwrite, mode preservation, failed-write-leaves-original, no-temp-leak, special chars, and patch routing. Updated two mocks in test_file_operations.py that keyed on the literal `cat >` write command to key on the stdin_data behavioral signal instead. 200 file-tool tests green.
187 lines
7.7 KiB
Python
187 lines
7.7 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_ssh_key_is_denied(self):
|
|
assert _is_write_denied(os.path.expanduser("~/.ssh/id_rsa")) is True
|
|
|
|
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_writes_to_safe_root_itself_are_allowed(self, tmp_path: Path, monkeypatch):
|
|
safe_root = tmp_path / "workspace"
|
|
os.makedirs(safe_root, exist_ok=True)
|
|
|
|
monkeypatch.setenv("HERMES_WRITE_SAFE_ROOT", str(safe_root))
|
|
assert _is_write_denied(str(safe_root)) is False
|
|
|
|
def test_writes_outside_safe_root_are_denied(self, tmp_path: Path, monkeypatch):
|
|
safe_root = tmp_path / "workspace"
|
|
outside = tmp_path / "other" / "file.txt"
|
|
os.makedirs(safe_root, exist_ok=True)
|
|
os.makedirs(outside.parent, exist_ok=True)
|
|
|
|
monkeypatch.setenv("HERMES_WRITE_SAFE_ROOT", str(safe_root))
|
|
assert _is_write_denied(str(outside)) is True
|
|
|
|
def test_safe_root_env_ignores_empty_value(self, tmp_path: Path, monkeypatch):
|
|
target = tmp_path / "regular.txt"
|
|
monkeypatch.setenv("HERMES_WRITE_SAFE_ROOT", "")
|
|
assert _is_write_denied(str(target)) is False
|
|
|
|
def test_safe_root_unset_allows_all(self, tmp_path: Path, monkeypatch):
|
|
target = tmp_path / "regular.txt"
|
|
monkeypatch.delenv("HERMES_WRITE_SAFE_ROOT", raising=False)
|
|
assert _is_write_denied(str(target)) 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 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_overwrite_preserves_mode(self, ops, tmp_path: Path):
|
|
target = tmp_path / "perms.txt"
|
|
target.write_text("old")
|
|
os.chmod(target, 0o640)
|
|
res = ops.write_file(str(target), "new")
|
|
assert res.error is None, res.error
|
|
assert (os.stat(target).st_mode & 0o777) == 0o640
|
|
|
|
def test_failed_write_leaves_original_intact(self, ops, tmp_path: Path):
|
|
# A read-only parent directory means the temp file can't be created,
|
|
# so the write fails BEFORE any rename. The original must survive
|
|
# byte-for-byte and no temp file may be left behind.
|
|
if hasattr(os, "geteuid") and os.geteuid() == 0:
|
|
pytest.skip("root bypasses directory permission bits")
|
|
locked = tmp_path / "locked"
|
|
locked.mkdir()
|
|
target = locked / "f.txt"
|
|
target.write_text("ORIGINAL\n")
|
|
os.chmod(locked, 0o500) # r-x: cannot create entries inside
|
|
try:
|
|
res = ops.write_file(str(target), "SHOULD NOT LAND")
|
|
finally:
|
|
os.chmod(locked, 0o700) # restore for cleanup
|
|
assert res.error is not None
|
|
assert target.read_text() == "ORIGINAL\n"
|
|
assert [p for p in os.listdir(locked) if ".hermes-tmp" in p] == []
|
|
|
|
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_special_chars_roundtrip(self, ops, tmp_path: Path):
|
|
target = tmp_path / "special.txt"
|
|
tricky = "q 'single' \"double\" $VAR `cmd` \\back\nünïcödé 日本語\n"
|
|
res = ops.write_file(str(target), tricky)
|
|
assert res.error is None, res.error
|
|
assert target.read_text(encoding="utf-8") == tricky
|
|
|
|
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
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pytest.main([__file__, "-v"])
|