Files
hermes-agent/tests/hermes_cli/test_noninteractive_git.py
Teknium fa425c942e test: stub the safe.directory pre-read by default; map privacydied's email
noninteractive_git_env() now spawns `git config --get-all safe.directory` before
building the env. Eight tests fake subprocess.run/Popen with a fixed sequence of
expected git calls (update check, plugin pull, MCP install, bounded probe) and the
extra spawn tripped them in CI. An autouse fixture stubs the read to "no entries";
the two carve-out invariant tests opt back in with @pytest.mark.real_safe_directory
(and were confirmed to still exercise the real read: the ordering test would fail
against the stub).

contributors/emails: pry@privacydied.net -> privacydied (check-attribution).
2026-09-11 19:01:47 -07:00

345 lines
14 KiB
Python

"""Non-interactive internal git invocations (port of openai/codex#34540/#34612).
Internal git plumbing (MCP catalog installs, plugin install/update, profile
distribution staging, worktree base fetches, desktop review-pane git/gh) must
never block on a credential prompt: nobody is attached to answer it, so a
prompt is an indefinite hang (or a dead wait until the timeout).
Two layers of coverage:
1. Unit contract on :func:`hermes_cli._subprocess_compat.noninteractive_git_env`.
2. A real-git E2E proving the env actually disables the prompt: a local HTTP
server answers 401 with a Basic challenge; ``git clone`` against it with
the hardened env fails *fast* with "terminal prompts disabled" instead of
waiting for a username.
3. Plumbing tests asserting each internal call site passes ``stdin=DEVNULL``
and the hardened env to subprocess.
"""
from __future__ import annotations
import http.server
import os
import shutil
import subprocess
import threading
import time
from pathlib import Path
import pytest
from hermes_cli._subprocess_compat import noninteractive_git_env
# ---------------------------------------------------------------------------
# 1. Env helper contract
# ---------------------------------------------------------------------------
class TestNoninteractiveGitEnv:
def test_sets_prompt_kill_switches(self):
env = noninteractive_git_env({})
assert env["GIT_TERMINAL_PROMPT"] == "0"
assert env["GCM_INTERACTIVE"] == "Never"
def test_defaults_to_process_environ_copy(self, monkeypatch):
monkeypatch.setenv("HERMES_TEST_SENTINEL", "xyz")
env = noninteractive_git_env()
assert env["HERMES_TEST_SENTINEL"] == "xyz"
assert env["GIT_TERMINAL_PROMPT"] == "0"
# Never mutates the live process environment.
assert os.environ.get("GIT_TERMINAL_PROMPT") != "0" or True
assert "GCM_INTERACTIVE" not in os.environ or os.environ["GCM_INTERACTIVE"] == env["GCM_INTERACTIVE"]
def test_overrides_explicit_prompt_enable(self):
env = noninteractive_git_env({"GIT_TERMINAL_PROMPT": "1"})
assert env["GIT_TERMINAL_PROMPT"] == "0"
def test_strips_ambient_git_config_injection(self):
env = noninteractive_git_env(
{
"GIT_CONFIG_COUNT": "2",
"GIT_CONFIG_KEY_0": "core.pager",
"GIT_CONFIG_VALUE_0": "less",
"GIT_CONFIG_KEY_1": "core.hooksPath",
"GIT_CONFIG_VALUE_1": ".git/hooks",
"GIT_CONFIG_PARAMETERS": "'core.pager=less'",
}
)
assert env["GIT_CONFIG_COUNT"] != "2"
assert "GIT_CONFIG_PARAMETERS" not in env
values = {
env[f"GIT_CONFIG_KEY_{idx}"]: env[f"GIT_CONFIG_VALUE_{idx}"]
for idx in range(int(env["GIT_CONFIG_COUNT"]))
}
assert values["core.pager"] == "cat"
assert values["core.hooksPath"] == os.devnull
assert values["credential.helper"] == ""
def test_disables_pagers_hooks_editors_and_user_config(self):
env = noninteractive_git_env({})
values = {
env[f"GIT_CONFIG_KEY_{idx}"]: env[f"GIT_CONFIG_VALUE_{idx}"]
for idx in range(int(env["GIT_CONFIG_COUNT"]))
}
assert env["GIT_CONFIG_GLOBAL"] == os.devnull
assert env["GIT_CONFIG_SYSTEM"] == os.devnull
assert env["GIT_CONFIG_NOSYSTEM"] == "1"
assert env["GIT_PAGER"] == "cat"
assert env["PAGER"] == "cat"
assert env["GIT_EDITOR"] == "true"
assert values["core.fsmonitor"] == "false"
assert values["core.hooksPath"] == os.devnull
assert values["core.editor"] == "true"
assert values["sequence.editor"] == "true"
assert values["diff.external"] == ""
@pytest.mark.real_safe_directory
def test_safe_directory_preserves_git_ordering_and_reset_markers(self, tmp_path, monkeypatch):
"""The user's effective trust policy is replayed verbatim, resets included.
``safe.directory`` is an ordered multi-valued setting where an empty value resets every
earlier entry, which is how a user revokes a system-wide ``safe.directory=*`` and then
names only the repositories they trust. Reading global-before-system, dropping the empty
marker, or de-duplicating turns ``* -> reset -> /trusted/only`` into ``/trusted/only, *``
and silently restores the wildcard the user revoked. Contract: the injected sequence equals
what git itself reports for the same config (system scope first, then global, verbatim).
"""
system_config = tmp_path / "system-gitconfig"
system_config.write_text("[safe]\n\tdirectory = *\n", encoding="utf-8")
global_config = tmp_path / "gitconfig"
global_config.write_text(
"[safe]\n\tdirectory = \n\tdirectory = /trusted/only\n", encoding="utf-8"
)
monkeypatch.setenv("GIT_CONFIG_SYSTEM", str(system_config))
monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(global_config))
# Ambient GIT_CONFIG_KEY_n=safe.directory must not be laundered through alongside the
# user's own entries -- only the config files are a trust source.
env = noninteractive_git_env(
{
**os.environ,
"GIT_CONFIG_COUNT": "1",
"GIT_CONFIG_KEY_0": "safe.directory",
"GIT_CONFIG_VALUE_0": "/attacker/controlled",
}
)
# Isolation itself is unchanged: the values ride the KEY_n channel, not the config file.
assert env["GIT_CONFIG_GLOBAL"] == os.devnull
assert env["GIT_CONFIG_SYSTEM"] == os.devnull
injected = [
env[f"GIT_CONFIG_VALUE_{idx}"]
for idx in range(int(env["GIT_CONFIG_COUNT"]))
if env[f"GIT_CONFIG_KEY_{idx}"] == "safe.directory"
]
# git's own effective view of the same two files, lowest-precedence scope first.
expected = subprocess.run(
["git", "config", "-z", "--get-all", "safe.directory"],
capture_output=True, text=True, check=True,
env={**os.environ, "GIT_CONFIG_SYSTEM": str(system_config),
"GIT_CONFIG_GLOBAL": str(global_config)},
).stdout.split("\0")[:-1]
assert expected == ["*", "", "/trusted/only"], "git's documented reset shape changed"
assert injected == expected
@pytest.mark.real_safe_directory
def test_safe_directory_reset_still_revokes_wildcard_for_real_git(self, tmp_path):
"""End-to-end: a revoked wildcard stays revoked, and the named repo stays usable.
Proves the injected sequence produces the same *trust decision* real git makes, not merely
the same list. Both repos are made cross-owner via ``safe.directory=*`` being the only
thing that could authorise them, so the negative control fails exactly as the user's
interactive git does.
"""
if not shutil.which("git"):
pytest.skip("git not installed")
def _repo(name: str) -> Path:
path = tmp_path / name
path.mkdir()
subprocess.run(["git", "init", "-q"], cwd=path, check=True)
subprocess.run(
["git", "-c", "user.email=t@t", "-c", "user.name=t",
"commit", "-q", "--allow-empty", "-m", "x"],
cwd=path, check=True,
)
return path
trusted = _repo("trusted")
unrelated = _repo("unrelated")
system_config = tmp_path / "system-gitconfig"
system_config.write_text("[safe]\n\tdirectory = *\n", encoding="utf-8")
global_config = tmp_path / "gitconfig"
global_config.write_text(
f"[safe]\n\tdirectory = \n\tdirectory = {trusted}\n", encoding="utf-8"
)
env = noninteractive_git_env(
{**os.environ, "GIT_CONFIG_SYSTEM": str(system_config),
"GIT_CONFIG_GLOBAL": str(global_config)}
)
# Force the ownership check that safe.directory governs; without it git trusts the repo
# because the test process owns the checkout it just created.
env["GIT_TEST_ASSUME_DIFFERENT_OWNER"] = "1"
def _rev_parse(repo: Path) -> int:
return subprocess.run(
["git", "-C", str(repo), "rev-parse", "HEAD"],
capture_output=True, text=True, env=env, stdin=subprocess.DEVNULL,
).returncode
assert _rev_parse(trusted) == 0, "the explicitly trusted repo must stay usable"
assert _rev_parse(unrelated) != 0, (
"the global empty reset revoked the system wildcard, so an unrelated cross-owner "
"repo must still be refused"
)
def test_ssh_host_key_prompts_fail_closed(self):
"""core.sshCommand is pinned to BatchMode ssh (#104591).
ssh bypasses ``stdin=DEVNULL`` and ``GIT_TERMINAL_PROMPT`` — an unknown host key (or
password auth) opens ``/dev/tty`` directly and steals the caller's terminal. Under this
env the ssh child of a git fetch must fail fast instead of prompting; an
agent-authenticated ssh still succeeds.
"""
env = noninteractive_git_env({})
values = {
env[f"GIT_CONFIG_KEY_{idx}"]: env[f"GIT_CONFIG_VALUE_{idx}"]
for idx in range(int(env["GIT_CONFIG_COUNT"]))
}
assert values["core.sshCommand"] == "ssh -o BatchMode=yes"
# Config-layer pin only: GIT_SSH_COMMAND is never set or overridden here, so a user's
# explicit env var still takes precedence over core.sshCommand.
override = "ssh -i custom-key -o BatchMode=no"
assert noninteractive_git_env({"GIT_SSH_COMMAND": override})["GIT_SSH_COMMAND"] == override
# ---------------------------------------------------------------------------
# 2. Real-git E2E: 401 remote fails fast instead of prompting
# ---------------------------------------------------------------------------
class _BasicAuthChallenge(http.server.BaseHTTPRequestHandler):
def _challenge(self):
self.send_response(401)
self.send_header("WWW-Authenticate", 'Basic realm="hermes-test"')
self.send_header("Content-Length", "0")
self.end_headers()
do_GET = _challenge
do_POST = _challenge
def log_message(self, *args): # silence test output
pass
@pytest.mark.skipif(shutil.which("git") is None, reason="git not installed")
def test_git_clone_against_auth_remote_fails_fast(tmp_path: Path):
server = http.server.HTTPServer(("127.0.0.1", 0), _BasicAuthChallenge)
port = server.server_address[1]
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
t0 = time.monotonic()
env = noninteractive_git_env()
# noninteractive_git_env deliberately leaves GIT_ASKPASS/SSH_ASKPASS
# alone so a user's WORKING helper can still authenticate. This test
# asserts the no-helper fail-fast path, so strip them — otherwise a
# dev shell's VS Code askpass helper (GIT_ASKPASS=...askpass.sh)
# blocks waiting on the editor and the clone times out locally.
for var in ("GIT_ASKPASS", "SSH_ASKPASS", "VSCODE_GIT_ASKPASS_NODE",
"VSCODE_GIT_ASKPASS_MAIN", "VSCODE_GIT_ASKPASS_EXTRA_ARGS",
"VSCODE_GIT_IPC_HANDLE"):
env.pop(var, None)
proc = subprocess.run(
["git", "clone", f"http://127.0.0.1:{port}/private.git",
str(tmp_path / "dest")],
capture_output=True,
text=True,
timeout=30,
stdin=subprocess.DEVNULL,
env=env,
)
elapsed = time.monotonic() - t0
assert proc.returncode != 0
# With GIT_TERMINAL_PROMPT=0 git refuses to ask for a username
# instead of blocking on a prompt.
stderr = proc.stderr.lower()
assert (
"terminal prompts disabled" in stderr
or "authentication failed" in stderr
), f"unexpected git error: {proc.stderr!r}"
# Fail-fast, not a hang-until-timeout.
assert elapsed < 20
finally:
server.shutdown()
server.server_close()
# ---------------------------------------------------------------------------
# 3. Call-site plumbing: internal git callers pass stdin=DEVNULL + env
# ---------------------------------------------------------------------------
def _capture_run(monkeypatch, module, **result_kwargs):
"""Monkeypatch ``module.subprocess.run`` recording every call's kwargs."""
calls: list[dict] = []
class _Result:
returncode = result_kwargs.get("returncode", 0)
stdout = result_kwargs.get("stdout", "")
stderr = result_kwargs.get("stderr", "")
def fake_run(argv, **kwargs):
calls.append({"argv": list(argv), **kwargs})
return _Result()
monkeypatch.setattr(module.subprocess, "run", fake_run)
return calls
def _assert_noninteractive(call: dict):
# A stdin fed by ``input=`` (git credential fill's request) is written and closed, not a terminal.
assert call.get("stdin") is subprocess.DEVNULL or "input" in call, call["argv"]
env = call.get("env")
assert env is not None and env.get("GIT_TERMINAL_PROMPT") == "0", call["argv"]
def test_mcp_catalog_git_install_runs_noninteractively(monkeypatch, tmp_path):
from hermes_cli import mcp_catalog
calls = _capture_run(monkeypatch, mcp_catalog)
monkeypatch.setattr(mcp_catalog.shutil, "which", lambda name: "/usr/bin/git")
monkeypatch.setattr(mcp_catalog, "_install_root", lambda: tmp_path)
entry = mcp_catalog.CatalogEntry(
name="test-mcp",
description="",
source="official",
transport=mcp_catalog.TransportSpec(type="stdio", command="python"),
auth=mcp_catalog.AuthSpec(type="none"),
install=mcp_catalog.InstallSpec(
type="git",
url="https://github.com/example/mcp.git",
ref="main",
bootstrap=[],
),
)
mcp_catalog._do_git_install(entry)
assert calls
for call in calls:
if call["argv"][0].endswith("git") or "git" in call["argv"][0]:
_assert_noninteractive(call)