Files
hermes-agent/tests/security/test_gitspawn_config_injection.py
T
Teknium f6234d00c5 fix(security): close GitSpawn RCE class — malicious repo .git/config no longer executes on context gathering (GHSA-7x36-8jrh-v4pw)
Hermes gathers workspace context by running git against the session
directory automatically — the coding-workspace snapshot, gateway
project-tree build, /diff, @diff|@staged context refs, goal-gate
fingerprint, and -w startup worktree add — before any prompt, tool call,
approval, or trust gate. Those probes ran the system git without
stripping the repository's own config, so a repo delivered as files with
its .git directory intact (a shared zip, sync folder, or USB stick;
git clone never transfers .git/config) could set an execution-sink git
setting and get arbitrary host code execution as the user with nothing
on screen.

- core.fsmonitor / core.hooksPath / pager / editor / credential helper:
  neutralized by routing every automatic probe through
  noninteractive_git_env(), which pins those keys to inert values via
  GIT_CONFIG_* and ignores global/system config. bounded_git_probe (the
  reported sink, coding_context._git + tui_gateway.git_probe) now defaults
  to that env; worktree-add, working_diff, web_git, context_references,
  goals, and subagent_worktree route through it too.
- Attribute-scoped [diff "x"] command=/textconv= drivers: the attacker
  names the driver in .gitattributes, so GIT_CONFIG_KEY overrides can't
  enumerate them. Added harden_git_argv(), which inserts
  --no-ext-diff --no-textconv on diff-rendering subcommands (diff/show/
  log/blame) only — status et al reject the flags. Both flags required
  (verified empirically; each alone leaves the other live).

Builds on the noninteractive_git_env config-scrubbing from the
gemini-cli #28792 port. Real-git E2E regression suite arms a malicious
repo and asserts every automatic path neutralizes fsmonitor, hooks,
external-diff, and textconv; a baseline test proves the repo is armed.
2026-09-02 10:33:43 -07:00

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