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:
parent
206531a1e1
commit
ecbe6ef0dd
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue