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).
345 lines
14 KiB
Python
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)
|