62014d8dd3
The guard's "use a separate worktree or temporary clone" advice sent agents to /tmp by default. /tmp is RAM-backed tmpfs on most distros, and parallel salvage clones each running npm ci (~1.6GB per clone) filled a 32GB tmpfs to 97% during a 15-subagent campaign, ENOSPC-ing sibling test runs. The message now recommends `git clone --shared <root> ~/.hermes/scratch/<task>` (honoring HERMES_HOME), warns that dependency installs belong on real disk, and tells the agent to delete the clone once the branch is pushed.
382 lines
13 KiB
Python
382 lines
13 KiB
Python
"""Tests for tools/self_repo_guard.py — the running-source-checkout git guard."""
|
|
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from tools.self_repo_guard import (
|
|
detect_self_repo_git_mutation,
|
|
get_running_source_root,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def repo(tmp_path):
|
|
root = tmp_path / "hermes-agent"
|
|
root.mkdir()
|
|
subprocess.run(["git", "init", "-q", str(root)], check=True)
|
|
(root / "agent").mkdir()
|
|
return root.resolve()
|
|
|
|
|
|
def _detect(command, cwd, root):
|
|
return detect_self_repo_git_mutation(command, str(cwd), source_root=root)
|
|
|
|
|
|
class TestBlocksMutationsInSourceRepo:
|
|
@pytest.mark.parametrize(
|
|
"sub",
|
|
[
|
|
"checkout pr-51020",
|
|
"switch main",
|
|
"bisect start",
|
|
"bisect good HEAD~10",
|
|
"reset --hard origin/main",
|
|
"reset --har origin/main",
|
|
"rebase origin/main",
|
|
"merge origin/main",
|
|
"pull",
|
|
"restore .",
|
|
"stash",
|
|
"stash pop",
|
|
"clean -fd",
|
|
"cherry-pick abc123",
|
|
"revert HEAD",
|
|
],
|
|
)
|
|
def test_cwd_inside_repo(self, repo, sub):
|
|
hit, msg = _detect(f"git {sub}", repo, repo)
|
|
assert hit is True
|
|
assert str(repo) in msg
|
|
|
|
def test_cwd_in_repo_subdirectory(self, repo):
|
|
hit, _ = _detect("git checkout main", repo / "agent", repo)
|
|
assert hit is True
|
|
|
|
def test_dash_c_targeting_repo_from_outside(self, repo, tmp_path):
|
|
hit, _ = _detect(f"git -C {repo} checkout pr-51020", tmp_path, repo)
|
|
assert hit is True
|
|
|
|
def test_cd_into_repo_then_checkout(self, repo, tmp_path):
|
|
hit, _ = _detect(f"cd {repo} && git checkout pr-51020", tmp_path, repo)
|
|
assert hit is True
|
|
|
|
def test_relative_cd_into_repo(self, repo):
|
|
hit, _ = _detect("cd hermes-agent && git pull", repo.parent, repo)
|
|
assert hit is True
|
|
|
|
def test_mutation_after_safe_command(self, repo):
|
|
hit, _ = _detect("git status; git reset --hard HEAD~1", repo, repo)
|
|
assert hit is True
|
|
|
|
def test_wrapped_in_sudo_env(self, repo):
|
|
hit, _ = _detect("sudo env GIT_PAGER=cat git checkout main", repo, repo)
|
|
assert hit is True
|
|
|
|
@pytest.mark.parametrize(
|
|
"command",
|
|
[
|
|
"sudo -u root git checkout main",
|
|
"env -u GIT_PAGER git switch main",
|
|
"/usr/bin/git checkout main",
|
|
"sh -c 'git checkout main'",
|
|
"bash -lc 'git switch main'",
|
|
"bash -o pipefail -c 'git checkout main'",
|
|
"bash +O extglob -c 'git checkout main'",
|
|
"zsh -yc 'git checkout main'",
|
|
"dash -Vc 'git checkout main'",
|
|
"ksh -Gc 'git checkout main'",
|
|
],
|
|
)
|
|
def test_wrappers_and_nested_shells(self, repo, command):
|
|
hit, _ = _detect(command, repo, repo)
|
|
assert hit is True
|
|
|
|
@pytest.mark.parametrize(
|
|
"command",
|
|
[
|
|
"gh pr checkout 51020",
|
|
"hub pr checkout 51020",
|
|
],
|
|
)
|
|
def test_pr_checkout_clients(self, repo, command):
|
|
hit, _ = _detect(command, repo, repo)
|
|
assert hit is True
|
|
|
|
def test_explicit_work_tree_targeting_repo(self, repo, tmp_path):
|
|
command = f"git --git-dir={repo / '.git'} --work-tree={repo} checkout main"
|
|
hit, _ = _detect(command, tmp_path, repo)
|
|
assert hit is True
|
|
|
|
def test_git_environment_targeting_repo(self, repo, tmp_path):
|
|
command = f"GIT_DIR={repo / '.git'} GIT_WORK_TREE={repo} git checkout main"
|
|
hit, _ = _detect(command, tmp_path, repo)
|
|
assert hit is True
|
|
|
|
def test_inline_git_alias(self, repo):
|
|
hit, _ = _detect("git -c alias.co=checkout co main", repo, repo)
|
|
assert hit is True
|
|
|
|
def test_configured_git_alias(self, repo):
|
|
subprocess.run(
|
|
["git", "-C", str(repo), "config", "alias.co", "checkout"],
|
|
check=True,
|
|
)
|
|
hit, _ = _detect("git co main", repo, repo)
|
|
assert hit is True
|
|
|
|
def test_mutation_in_command_substitution(self, repo):
|
|
hit, _ = _detect('echo "$(git checkout main)"', repo, repo)
|
|
assert hit is True
|
|
|
|
@pytest.mark.parametrize(
|
|
"command",
|
|
[
|
|
'echo "$(echo ready && git checkout main)"',
|
|
"echo `git checkout main`",
|
|
'echo "`git checkout main`"',
|
|
],
|
|
)
|
|
def test_nested_command_lists(self, repo, command):
|
|
hit, _ = _detect(command, repo, repo)
|
|
assert hit is True
|
|
|
|
def test_shell_heredoc_is_executed(self, repo):
|
|
command = "bash <<'EOF'\ngit checkout main\nEOF\n"
|
|
hit, _ = _detect(command, repo, repo)
|
|
assert hit is True
|
|
|
|
def test_tilde_dash_c_path(self, repo, monkeypatch, tmp_path):
|
|
monkeypatch.setenv("HOME", str(repo.parent))
|
|
hit, _ = _detect("git -C ~/hermes-agent checkout main", tmp_path, repo)
|
|
assert hit is True
|
|
|
|
|
|
class TestAllowsSafeCommands:
|
|
@pytest.mark.parametrize(
|
|
"cmd",
|
|
[
|
|
"git status",
|
|
"git log --oneline -5",
|
|
"git diff main...HEAD",
|
|
"git branch --show-current",
|
|
"git stash list",
|
|
"git stash show -p",
|
|
"git stash create",
|
|
"git stash store abc123",
|
|
"git stash drop",
|
|
"git stash clear",
|
|
"git reset --soft HEAD~1",
|
|
"git reset --mixed HEAD~1",
|
|
"git restore --staged pyproject.toml",
|
|
"git clean --dry-run -fd",
|
|
"git clean -nd",
|
|
"git commit -m 'msg'",
|
|
"git add -A",
|
|
"git fetch origin main",
|
|
"git worktree add /tmp/wt feature-branch",
|
|
"git push fork feature-branch",
|
|
"ls -la",
|
|
"grep -rn checkout tools/",
|
|
],
|
|
)
|
|
def test_read_only_and_dev_loop_in_repo(self, repo, cmd):
|
|
hit, _ = _detect(cmd, repo, repo)
|
|
assert hit is False
|
|
|
|
def test_mutation_in_other_repo(self, repo, tmp_path):
|
|
other = tmp_path / "other-project"
|
|
other.mkdir()
|
|
hit, _ = _detect("git checkout main", other, repo)
|
|
assert hit is False
|
|
|
|
def test_dash_c_redirects_out_of_repo(self, repo, tmp_path):
|
|
hit, _ = _detect(f"git -C {tmp_path} checkout main", repo, repo)
|
|
assert hit is False
|
|
|
|
def test_cd_out_of_repo_then_checkout(self, repo, tmp_path):
|
|
hit, _ = _detect(f"cd {tmp_path} && git checkout main", repo, repo)
|
|
assert hit is False
|
|
|
|
def test_mentioning_repo_path_without_targeting_it(self, repo, tmp_path):
|
|
hit, _ = _detect(f"echo {repo} && git checkout main", tmp_path, repo)
|
|
assert hit is False
|
|
|
|
def test_checkout_as_grep_pattern_not_git(self, repo):
|
|
hit, _ = _detect("grep checkout file.txt", repo, repo)
|
|
assert hit is False
|
|
|
|
def test_pr_checkout_words_in_other_gh_command_are_safe(self, repo):
|
|
hit, _ = _detect("gh api /repos/example/pr/checkout", repo, repo)
|
|
assert hit is False
|
|
|
|
@pytest.mark.parametrize(
|
|
"command",
|
|
[
|
|
'echo "safe | git checkout main"',
|
|
"echo '$(git checkout main)'",
|
|
"printf '%s\\n' 'git checkout main'",
|
|
],
|
|
)
|
|
def test_quoted_git_text_is_not_executed(self, repo, command):
|
|
hit, _ = _detect(command, repo, repo)
|
|
assert hit is False
|
|
|
|
@pytest.mark.parametrize(
|
|
"command",
|
|
[
|
|
"cat > script.sh <<'EOF'\ngit checkout main\nEOF\n",
|
|
"python - <<'PY'\nprint('git checkout main')\nPY\n",
|
|
],
|
|
)
|
|
def test_data_heredoc_is_not_executed_as_shell(self, repo, command):
|
|
hit, _ = _detect(command, repo, repo)
|
|
assert hit is False
|
|
|
|
def test_subshell_cd_does_not_leak(self, repo):
|
|
command = f"(cd {repo} && git status); git checkout main"
|
|
hit, _ = _detect(command, repo.parent, repo)
|
|
assert hit is False
|
|
|
|
def test_pipeline_cd_does_not_leak(self, repo):
|
|
command = f"cd {repo} | cat; git checkout main"
|
|
hit, _ = _detect(command, repo.parent, repo)
|
|
assert hit is False
|
|
|
|
def test_successful_cd_or_branch_does_not_run(self, repo):
|
|
command = f"cd {repo} || git checkout main"
|
|
hit, _ = _detect(command, repo.parent, repo)
|
|
assert hit is False
|
|
|
|
def test_empty_command(self, repo):
|
|
hit, _ = _detect("", repo, repo)
|
|
assert hit is False
|
|
|
|
def test_packaged_install_is_inert(self, monkeypatch, tmp_path):
|
|
import tools.self_repo_guard as mod
|
|
|
|
monkeypatch.setattr(mod, "get_running_source_root", lambda: None)
|
|
hit, msg = mod.detect_self_repo_git_mutation("git checkout main", str(tmp_path))
|
|
assert hit is False
|
|
assert msg is None
|
|
|
|
|
|
class TestWorktreeTargetingSourceRoot:
|
|
@pytest.mark.parametrize(
|
|
"sub",
|
|
[
|
|
"remove .",
|
|
"remove -f .",
|
|
"remove --force .",
|
|
"remove -- .",
|
|
"move . {other}",
|
|
"move -f . {other}",
|
|
],
|
|
)
|
|
def test_blocks_relative_target_from_inside(self, repo, tmp_path, sub):
|
|
command = f"git worktree {sub.format(other=tmp_path / 'moved')}"
|
|
hit, msg = _detect(command, repo, repo)
|
|
assert hit is True
|
|
assert str(repo) in msg
|
|
|
|
@pytest.mark.parametrize("action", ["remove", "remove -f", "remove --force"])
|
|
def test_blocks_absolute_target_from_outside(self, repo, tmp_path, action):
|
|
hit, _ = _detect(f"git worktree {action} {repo}", tmp_path, repo)
|
|
assert hit is True
|
|
|
|
def test_blocks_move_of_root_from_outside(self, repo, tmp_path):
|
|
command = f"git worktree move {repo} {tmp_path / 'moved'}"
|
|
hit, _ = _detect(command, tmp_path, repo)
|
|
assert hit is True
|
|
|
|
def test_blocks_dash_c_worktree_remove(self, repo, tmp_path):
|
|
hit, _ = _detect(f"git -C {tmp_path} worktree remove {repo}", tmp_path, repo)
|
|
assert hit is True
|
|
|
|
def test_blocks_parent_relative_target_from_subdirectory(self, repo):
|
|
hit, _ = _detect("git worktree remove ..", repo / "agent", repo)
|
|
assert hit is True
|
|
|
|
def test_blocks_sibling_relative_target(self, repo):
|
|
hit, _ = _detect(f"git worktree remove ../{repo.name}", repo, repo)
|
|
assert hit is True
|
|
|
|
@pytest.mark.parametrize(
|
|
"sub",
|
|
[
|
|
"add {other}",
|
|
"add -b feature {other}",
|
|
"list",
|
|
"list --porcelain",
|
|
"prune",
|
|
"lock {other}",
|
|
"unlock {other}",
|
|
"remove {other}",
|
|
"move {other} {other}-dest",
|
|
],
|
|
)
|
|
def test_allows_other_worktrees_and_add(self, repo, tmp_path, sub):
|
|
command = f"git worktree {sub.format(other=tmp_path / 'other-wt')}"
|
|
hit, _ = _detect(command, repo, repo)
|
|
assert hit is False
|
|
|
|
@pytest.mark.parametrize("sub", ["", "remove", "move", "-f"])
|
|
def test_incomplete_worktree_command_is_not_blocked(self, repo, sub):
|
|
hit, _ = _detect(f"git worktree {sub}".strip(), repo, repo)
|
|
assert hit is False
|
|
|
|
|
|
class TestSourceRootResolution:
|
|
def test_resolves_to_repo_when_git_dir_present(self):
|
|
root = get_running_source_root()
|
|
if root is not None:
|
|
assert (root / ".git").exists()
|
|
|
|
def test_worktree_git_file_counts(self, tmp_path, monkeypatch):
|
|
import tools.self_repo_guard as mod
|
|
|
|
root = tmp_path / "wt"
|
|
root.mkdir()
|
|
(root / ".git").write_text("gitdir: /somewhere/.git/worktrees/wt\n")
|
|
(root / "tools").mkdir()
|
|
fake_file = root / "tools" / "self_repo_guard.py"
|
|
fake_file.write_text("")
|
|
monkeypatch.setattr(mod, "__file__", str(fake_file))
|
|
assert mod.get_running_source_root() == root.resolve()
|
|
|
|
|
|
class TestUnparseableCommands:
|
|
def test_unbalanced_quotes_fall_back(self, repo):
|
|
hit, _ = _detect('git checkout "unterminated', repo, repo)
|
|
assert hit is True
|
|
|
|
def test_subshell_syntax_does_not_crash(self, repo):
|
|
hit, _ = _detect("VAL=$(git rev-parse HEAD) git checkout main", repo, repo)
|
|
assert hit is True
|
|
|
|
|
|
class TestBlockMessageGuidance:
|
|
"""The block message must steer agents to a disk-backed scratch clone,
|
|
not a bare "temporary clone" (agents defaulted to /tmp, which is tmpfs
|
|
on most distros — parallel salvage clones running npm ci filled a 32GB
|
|
tmpfs to 97% in one campaign)."""
|
|
|
|
def test_message_recommends_shared_clone_on_disk(self, repo):
|
|
hit, msg = _detect("git rebase origin/main", repo, repo)
|
|
assert hit is True
|
|
assert "git clone --shared" in msg
|
|
assert "scratch" in msg
|
|
|
|
def test_message_warns_against_tmp_for_dep_installs(self, repo):
|
|
hit, msg = _detect("git rebase origin/main", repo, repo)
|
|
assert hit is True
|
|
assert "tmpfs" in msg
|
|
assert "Delete the clone" in msg
|
|
|
|
def test_scratch_hint_honors_hermes_home(self, repo, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", "/custom/hermes-home")
|
|
hit, msg = _detect("git rebase origin/main", repo, repo)
|
|
assert hit is True
|
|
assert "/custom/hermes-home/scratch" in msg
|