feat(tools): hard-block self-repo git mutations in terminal_tool

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.
This commit is contained in:
Erosika 2026-07-16 12:37:25 -04:00 committed by kshitij
parent 206531a1e1
commit ecbe6ef0dd
2 changed files with 117 additions and 0 deletions

View File

@ -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()

View File

@ -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