From b4221c6db2f45fc8fe4a95e38ad23f400865683f Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:05:22 -0700 Subject: [PATCH] Inspired by Claude Code: protect session transcripts --- agent/file_safety.py | 11 +++ tests/agent/test_file_safety_session_state.py | 73 +++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 tests/agent/test_file_safety_session_state.py diff --git a/agent/file_safety.py b/agent/file_safety.py index 2f4b3666e28..8957c26d5a4 100644 --- a/agent/file_safety.py +++ b/agent/file_safety.py @@ -118,6 +118,17 @@ def _classify_write_denial(path: str) -> Optional[str]: continue for base_real in hermes_dirs: + # Session transcripts are application-owned state. Letting the agent's + # generic file tools rewrite state.db or legacy JSON snapshots can + # falsify conversation history and invalidate resume/compression state. + try: + if resolved == os.path.realpath(os.path.join(base_real, "state.db")): + return True + sessions_real = os.path.realpath(os.path.join(base_real, "sessions")) + if resolved == sessions_real or resolved.startswith(sessions_real + os.sep): + return True + except Exception: + pass try: mcp_real = os.path.realpath(os.path.join(base_real, mcp_tokens_dir_name)) if resolved == mcp_real or resolved.startswith(mcp_real + os.sep): diff --git a/tests/agent/test_file_safety_session_state.py b/tests/agent/test_file_safety_session_state.py new file mode 100644 index 00000000000..3f0af7d70da --- /dev/null +++ b/tests/agent/test_file_safety_session_state.py @@ -0,0 +1,73 @@ +"""Session transcript stores are read-only to agent file tools. + +Inspired by Claude Code 2.1.205's auto-mode rule preventing transcript +manipulation. Hermes keeps canonical conversation history in state.db and may +also emit legacy JSON snapshots under sessions/; agent tools must not rewrite +or delete either store. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + + +@pytest.fixture() +def fake_homes(tmp_path, monkeypatch): + import agent.file_safety as fs + + root = tmp_path / ".hermes" + profile = root / "profiles" / "work" + profile.mkdir(parents=True) + monkeypatch.setattr(fs, "_hermes_home_path", lambda: profile) + monkeypatch.setattr(fs, "_hermes_root_path", lambda: root) + return root, profile + + +@pytest.mark.parametrize("relative", ["state.db", "sessions/session_abc.json"]) +def test_session_state_paths_are_write_denied(fake_homes, relative): + from agent.file_safety import is_write_denied + + _root, profile = fake_homes + target = profile / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text("existing transcript", encoding="utf-8") + + assert is_write_denied(str(target)) is True + + +def test_default_profile_state_db_is_write_denied_from_profile(fake_homes): + from agent.file_safety import is_write_denied + + root, _profile = fake_homes + target = root / "state.db" + target.write_text("canonical transcript", encoding="utf-8") + + assert is_write_denied(str(target)) is True + + +def test_project_local_state_db_remains_writable(fake_homes, tmp_path): + from agent.file_safety import is_write_denied + + target = tmp_path / "project" / "state.db" + target.parent.mkdir() + target.write_text("application database", encoding="utf-8") + + assert is_write_denied(str(target)) is False + + +def test_write_file_tool_preserves_existing_session_snapshot(fake_homes, monkeypatch): + import tools.file_tools as ft + + _root, profile = fake_homes + target = profile / "sessions" / "session_abc.json" + target.parent.mkdir(parents=True) + target.write_text("original transcript", encoding="utf-8") + monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": None) + + result = json.loads(ft.write_file_tool(str(target), "tampered")) + + assert "error" in result + assert target.read_text(encoding="utf-8") == "original transcript"