From ecbe6ef0dd574312bbdc80e63f588e4b547cb8b8 Mon Sep 17 00:00:00 2001 From: Erosika Date: Thu, 16 Jul 2026 12:37:25 -0400 Subject: [PATCH] feat(tools): hard-block self-repo git mutations in terminal_tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the self-repo guard in next to the gateway lifecycle hard-block, before the force check — force=True cannot make the command safe, only delay the crash. Local backend only: sandboxed backends cannot reach the host checkout. The block message explains the version-skew mechanism and redirects to git worktree add / a temp clone, or running the command outside hermes with a restart after. --- tests/tools/test_terminal_self_repo_guard.py | 91 ++++++++++++++++++++ tools/terminal_tool.py | 26 ++++++ 2 files changed, 117 insertions(+) create mode 100644 tests/tools/test_terminal_self_repo_guard.py diff --git a/tests/tools/test_terminal_self_repo_guard.py b/tests/tools/test_terminal_self_repo_guard.py new file mode 100644 index 0000000000000..486e82119463f --- /dev/null +++ b/tests/tools/test_terminal_self_repo_guard.py @@ -0,0 +1,91 @@ +"""terminal_tool wiring tests for the self-repo git mutation guard.""" +import json +from contextlib import ExitStack +from unittest.mock import patch, MagicMock + +import pytest + +import tools.self_repo_guard as self_repo_guard + + +def _make_env_config(**overrides): + config = { + "env_type": "local", + "timeout": 180, + "cwd": "/tmp", + "host_cwd": None, + "modal_mode": "auto", + "docker_image": "", + "singularity_image": "", + "modal_image": "", + "daytona_image": "", + } + config.update(overrides) + return config + + +@pytest.fixture +def repo(tmp_path): + root = tmp_path / "hermes-agent" + (root / ".git").mkdir(parents=True) + return root.resolve() + + +def _run(command, config, monkeypatch, repo_root, **kwargs): + from tools.terminal_tool import terminal_tool + + monkeypatch.setattr(self_repo_guard, "get_running_source_root", lambda: repo_root) + mock_env = MagicMock() + mock_env.execute.return_value = {"output": "ok", "returncode": 0} + mock_env.cwd = config["cwd"] + + with ExitStack() as stack: + stack.enter_context(patch("tools.terminal_tool._get_env_config", return_value=config)) + stack.enter_context(patch("tools.terminal_tool._start_cleanup_thread")) + stack.enter_context(patch("tools.terminal_tool._active_environments", {"default": mock_env})) + stack.enter_context(patch("tools.terminal_tool._last_activity", {"default": 0})) + stack.enter_context(patch("tools.terminal_tool._check_all_guards", return_value={"approved": True})) + result = json.loads(terminal_tool(command=command, **kwargs)) + return result, mock_env + + +class TestSelfRepoGuardWiring: + def test_blocks_checkout_in_source_repo(self, repo, monkeypatch): + config = _make_env_config(cwd=str(repo)) + result, env = _run("git checkout pr-51020", config, monkeypatch, repo) + assert result["status"] == "blocked" + assert "version skew" in result["error"] + assert str(repo) in result["error"] + env.execute.assert_not_called() + + def test_force_cannot_bypass(self, repo, monkeypatch): + config = _make_env_config(cwd=str(repo)) + result, env = _run("git reset --hard origin/main", config, monkeypatch, repo, force=True) + assert result["status"] == "blocked" + env.execute.assert_not_called() + + def test_workdir_targeting_repo_is_blocked(self, repo, monkeypatch, tmp_path): + config = _make_env_config(cwd=str(tmp_path)) + result, env = _run("git pull", config, monkeypatch, repo, workdir=str(repo)) + assert result["status"] == "blocked" + env.execute.assert_not_called() + + def test_readonly_git_passes_through(self, repo, monkeypatch): + config = _make_env_config(cwd=str(repo)) + result, env = _run("git status", config, monkeypatch, repo) + assert result.get("status") != "blocked" + env.execute.assert_called_once() + + def test_mutation_outside_repo_passes_through(self, repo, monkeypatch, tmp_path): + other = tmp_path / "other" + other.mkdir() + config = _make_env_config(cwd=str(other)) + result, env = _run("git checkout main", config, monkeypatch, repo) + assert result.get("status") != "blocked" + env.execute.assert_called_once() + + def test_packaged_install_passes_through(self, repo, monkeypatch): + config = _make_env_config(cwd=str(repo)) + result, env = _run("git checkout main", config, monkeypatch, None) + assert result.get("status") != "blocked" + env.execute.assert_called_once() diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 93ac4c1f565fd..b87dbb830cf27 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -2688,6 +2688,32 @@ def terminal_tool( "status": "error", }, ensure_ascii=False) + # Hard-block: git commands that rewrite the working tree of the + # source checkout this hermes process runs from (editable/source + # installs only — packaged installs have no .git and the guard is + # inert). Swapping code on disk under the live interpreter causes + # version skew: already-imported modules stay old while later lazy + # imports load the new code, crashing long after the command that + # caused it. Like the gateway lifecycle guard above, this applies + # unconditionally — force=True cannot make the command safe. Local + # backend only: sandboxed backends can't reach the host checkout. + if env_type == "local": + from tools.self_repo_guard import detect_self_repo_git_mutation + _self_repo_hit, _self_repo_msg = detect_self_repo_git_mutation( + command, workdir or cwd + ) + if _self_repo_hit: + logger.warning( + "Blocked self-repo git mutation (command: %s)", + _safe_command_preview(command), + ) + return json.dumps({ + "output": "", + "exit_code": 1, + "error": _self_repo_msg, + "status": "blocked", + }, ensure_ascii=False) + # Pre-exec security checks (tirith + dangerous command detection) # Skip check if force=True (user has confirmed they want to run it) approval_note = None