201 lines
7.4 KiB
Python
201 lines
7.4 KiB
Python
"""GitSpawn / GHSA-7x36-8jrh-v4pw regression suite.
|
|
|
|
A repository delivered as files (zip, sync folder, USB) can carry a
|
|
``.git/config`` that names a command in an execution-sink git setting —
|
|
``core.fsmonitor``, ``core.hooksPath`` hooks, or an attribute-scoped
|
|
``[diff "x"] command=/textconv=`` driver. Hermes gathers workspace context by
|
|
running git against the session directory automatically, before any prompt,
|
|
approval, or trust gate, so an unhardened probe would execute that command on
|
|
the host as the user.
|
|
|
|
These tests build a real malicious repo and assert that every automatic
|
|
context-gathering git path Hermes runs neutralizes every sink. They use a real
|
|
``git`` and skip if it is unavailable.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from hermes_cli._subprocess_compat import (
|
|
NO_DRIVER_DIFF_FLAGS,
|
|
harden_git_argv,
|
|
noninteractive_git_env,
|
|
)
|
|
|
|
_HAS_GIT = shutil.which("git") is not None
|
|
pytestmark = pytest.mark.skipif(not _HAS_GIT, reason="git not installed")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. harden_git_argv unit contract
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestHardenGitArgv:
|
|
def test_diff_gets_flags_after_subcommand(self):
|
|
assert harden_git_argv(["diff", "HEAD"]) == [
|
|
"diff", *NO_DRIVER_DIFF_FLAGS, "HEAD",
|
|
]
|
|
|
|
def test_show_log_blame_are_hardened(self):
|
|
for sub in ("show", "log", "blame"):
|
|
out = harden_git_argv([sub, "x"])
|
|
assert out[0] == sub
|
|
assert out[1:3] == list(NO_DRIVER_DIFF_FLAGS)
|
|
|
|
def test_status_is_not_touched(self):
|
|
# status rejects --no-ext-diff (`unknown option`), so it must pass through.
|
|
assert harden_git_argv(["status", "--porcelain=2", "--branch"]) == [
|
|
"status", "--porcelain=2", "--branch",
|
|
]
|
|
|
|
def test_worktree_and_other_subcommands_untouched(self):
|
|
assert harden_git_argv(["worktree", "add", "x"]) == ["worktree", "add", "x"]
|
|
assert harden_git_argv(["rev-parse", "HEAD"]) == ["rev-parse", "HEAD"]
|
|
|
|
def test_global_options_are_skipped_when_finding_subcommand(self):
|
|
out = harden_git_argv(["-C", "/repo", "diff", "HEAD"])
|
|
assert out == ["-C", "/repo", "diff", *NO_DRIVER_DIFF_FLAGS, "HEAD"]
|
|
|
|
def test_dash_c_value_is_not_mistaken_for_subcommand(self):
|
|
# ``-C diff`` is a path; the real subcommand is status → no flags.
|
|
assert harden_git_argv(["-C", "diff", "status"]) == ["-C", "diff", "status"]
|
|
# ``-c diff=x`` is a config pair; the real subcommand is status.
|
|
assert harden_git_argv(["-c", "diff=x", "status"]) == ["-c", "diff=x", "status"]
|
|
|
|
def test_config_pair_before_diff_still_hardens(self):
|
|
out = harden_git_argv(["-c", "core.quotePath=false", "diff", "--numstat"])
|
|
assert out == [
|
|
"-c", "core.quotePath=false", "diff", *NO_DRIVER_DIFF_FLAGS, "--numstat",
|
|
]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. Real-git E2E: every automatic path neutralizes every sink
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_malicious_repo(tmp: Path) -> tuple[Path, Path]:
|
|
"""Build a repo whose .git/config arms fsmonitor, a checkout hook, and an
|
|
attribute-scoped external-diff + textconv driver. Returns (repo, marker_stem):
|
|
a fired sink leaves ``<marker_stem>.<sink>`` on disk."""
|
|
repo = tmp / "poc"
|
|
clean = {
|
|
**os.environ,
|
|
"GIT_CONFIG_GLOBAL": os.devnull,
|
|
"GIT_CONFIG_SYSTEM": os.devnull,
|
|
"GIT_CONFIG_NOSYSTEM": "1",
|
|
}
|
|
subprocess.run(["git", "init", "-q", str(repo)], check=True, env=clean)
|
|
(repo / "README").write_text("hi\n")
|
|
ident = ["-c", "user.email=a@b", "-c", "user.name=a"]
|
|
subprocess.run(["git", "-C", str(repo), *ident, "add", "."], check=True, env=clean)
|
|
subprocess.run(["git", "-C", str(repo), *ident, "commit", "-qm", "init"], check=True, env=clean)
|
|
|
|
marker = tmp / "MARKER"
|
|
hooks = repo / "evil-hooks"
|
|
hooks.mkdir()
|
|
hook = hooks / "post-checkout"
|
|
hook.write_text(f"#!/bin/sh\ntouch {marker}.hook\n")
|
|
hook.chmod(0o755)
|
|
with (repo / ".git" / "config").open("a") as f:
|
|
f.write(f'[core]\n\tfsmonitor = "touch {marker}.fsmonitor"\n\thooksPath = {hooks}\n')
|
|
f.write(f'[diff "evil"]\n\tcommand = "touch {marker}.extdiff"\n')
|
|
f.write(f'\ttextconv = "sh -c \'touch {marker}.textconv; cat\'"\n')
|
|
(repo / ".gitattributes").write_text("* diff=evil\n")
|
|
(repo / "README").write_text("changed\n") # dirty working tree so diffs run
|
|
return repo, marker
|
|
|
|
|
|
def _fired(marker: Path) -> list[str]:
|
|
out = []
|
|
for sink in ("fsmonitor", "hook", "extdiff", "textconv"):
|
|
p = Path(f"{marker}.{sink}")
|
|
if p.exists():
|
|
out.append(sink)
|
|
p.unlink()
|
|
return out
|
|
|
|
|
|
@pytest.fixture()
|
|
def malicious_repo(tmp_path):
|
|
repo, marker = _make_malicious_repo(tmp_path)
|
|
yield repo, marker
|
|
|
|
|
|
def test_baseline_unhardened_git_fires_sinks(malicious_repo):
|
|
"""Sanity: without hardening the payload actually fires — proves the repo
|
|
is armed and the test can detect a regression."""
|
|
repo, marker = malicious_repo
|
|
subprocess.run(["git", "-C", str(repo), "diff", "HEAD"], capture_output=True)
|
|
fired = _fired(marker)
|
|
assert "fsmonitor" in fired and "extdiff" in fired, fired
|
|
|
|
|
|
def test_coding_workspace_snapshot_is_safe(malicious_repo):
|
|
import agent.coding_context as cc
|
|
repo, marker = malicious_repo
|
|
cc.build_coding_workspace_block(cwd=repo)
|
|
assert _fired(marker) == []
|
|
|
|
|
|
def test_gateway_git_probe_is_safe(malicious_repo):
|
|
from tui_gateway import git_probe
|
|
repo, marker = malicious_repo
|
|
git_probe.branch(str(repo))
|
|
git_probe.run_git(str(repo), "status", "--porcelain")
|
|
assert _fired(marker) == []
|
|
|
|
|
|
def test_working_diff_is_safe(malicious_repo):
|
|
from tools.working_diff import collect_working_diff
|
|
repo, marker = malicious_repo
|
|
collect_working_diff(str(repo), "working")
|
|
assert _fired(marker) == []
|
|
|
|
|
|
def test_goals_fingerprint_is_safe(malicious_repo):
|
|
from hermes_cli.goals import workspace_fingerprint
|
|
repo, marker = malicious_repo
|
|
workspace_fingerprint(str(repo))
|
|
assert _fired(marker) == []
|
|
|
|
|
|
def test_web_git_diff_is_safe(malicious_repo):
|
|
from hermes_cli import web_git
|
|
repo, marker = malicious_repo
|
|
web_git._git(str(repo), ["status", "--porcelain=v2", "-z"])
|
|
web_git._git_out(str(repo), ["diff", "HEAD"])
|
|
assert _fired(marker) == []
|
|
|
|
|
|
def test_context_reference_diff_is_safe(malicious_repo):
|
|
from agent import context_references as cr
|
|
repo, marker = malicious_repo
|
|
ref = type("R", (), {"raw": "@diff"})()
|
|
cr._expand_git_reference(ref, repo, ["diff", "HEAD"], "git diff")
|
|
assert _fired(marker) == []
|
|
|
|
|
|
def test_subagent_worktree_add_is_safe(malicious_repo, tmp_path):
|
|
from tools import subagent_worktree as sw
|
|
repo, marker = malicious_repo
|
|
sw._run_git(["worktree", "add", str(tmp_path / "wt1"), "-b", "safe1"], str(repo))
|
|
assert _fired(marker) == []
|
|
|
|
|
|
def test_noninteractive_env_pins_fsmonitor_and_hooks():
|
|
env = noninteractive_git_env({})
|
|
values = {
|
|
env[f"GIT_CONFIG_KEY_{i}"]: env[f"GIT_CONFIG_VALUE_{i}"]
|
|
for i in range(int(env["GIT_CONFIG_COUNT"]))
|
|
}
|
|
assert values["core.fsmonitor"] == "false"
|
|
assert values["core.hooksPath"] == os.devnull
|