fix(checkpoints): isolate shadow git repo from user's global config (#11261)

Users with 'commit.gpgsign = true' in their global git config got a
pinentry popup (or a failed commit) every time the agent took a
background filesystem snapshot — every write_file, patch, or diff
mid-session. With GPG_TTY unset, pinentry-qt/gtk would spawn a GUI
window, constantly interrupting the session.

The shadow repo is internal Hermes infrastructure.  It must not
inherit user-level git settings (signing, hooks, aliases, credential
helpers, etc.) under any circumstance.

Fix is layered:

1. _git_env() sets GIT_CONFIG_GLOBAL=os.devnull,
   GIT_CONFIG_SYSTEM=os.devnull, and GIT_CONFIG_NOSYSTEM=1.  Shadow
   git commands no longer see ~/.gitconfig or /etc/gitconfig at all
   (uses os.devnull for Windows compat).

2. _init_shadow_repo() explicitly writes commit.gpgsign=false and
   tag.gpgSign=false into the shadow's own config, so the repo is
   correct even if inspected or run against directly without the
   env vars, and for older git versions (<2.32) that predate
   GIT_CONFIG_GLOBAL.

3. _take() passes --no-gpg-sign inline on the commit call.  This
   covers existing shadow repos created before this fix — they will
   never re-run _init_shadow_repo (it is gated on HEAD not existing),
   so they would miss layer 2.  Layer 1 still protects them, but the
   inline flag guarantees correctness at the commit call itself.

Existing checkpoints, rollback, list, diff, and restore all continue
to work — history is untouched.  Users who had the bug stop getting
pinentry popups; users who didn't see no observable change.

Tests: 5 new regression tests in TestGpgAndGlobalConfigIsolation,
including a full E2E repro with fake HOME, global gpgsign=true, and
a deliberately broken GPG binary — checkpoint succeeds regardless.
This commit is contained in:
Teknium 2026-04-16 16:06:49 -07:00 committed by GitHub
parent d38b73fa57
commit edefec4e68
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 143 additions and 3 deletions

View file

@ -587,3 +587,112 @@ class TestSecurity:
result = mgr.restore(str(work_dir), target_hash, file_path="subdir/test.txt")
assert result["success"] is True
# =========================================================================
# GPG / global git config isolation
# =========================================================================
# Regression tests for the bug where users with ``commit.gpgsign = true``
# in their global git config got a pinentry popup (or a failed commit)
# every time the agent took a background snapshot.
import os as _os
class TestGpgAndGlobalConfigIsolation:
def test_git_env_isolates_global_and_system_config(self, tmp_path):
"""_git_env must null out GIT_CONFIG_GLOBAL / GIT_CONFIG_SYSTEM so the
shadow repo does not inherit user-level gpgsign, hooks, aliases, etc."""
env = _git_env(tmp_path / "shadow", str(tmp_path))
assert env["GIT_CONFIG_GLOBAL"] == _os.devnull
assert env["GIT_CONFIG_SYSTEM"] == _os.devnull
assert env["GIT_CONFIG_NOSYSTEM"] == "1"
def test_init_sets_commit_gpgsign_false(self, work_dir, checkpoint_base, monkeypatch):
monkeypatch.setattr("tools.checkpoint_manager.CHECKPOINT_BASE", checkpoint_base)
shadow = _shadow_repo_path(str(work_dir))
_init_shadow_repo(shadow, str(work_dir))
# Inspect the shadow's own config directly — the settings must be
# written into the repo, not just inherited via env vars.
result = subprocess.run(
["git", "config", "--file", str(shadow / "config"), "--get", "commit.gpgsign"],
capture_output=True, text=True,
)
assert result.stdout.strip() == "false"
def test_init_sets_tag_gpgsign_false(self, work_dir, checkpoint_base, monkeypatch):
monkeypatch.setattr("tools.checkpoint_manager.CHECKPOINT_BASE", checkpoint_base)
shadow = _shadow_repo_path(str(work_dir))
_init_shadow_repo(shadow, str(work_dir))
result = subprocess.run(
["git", "config", "--file", str(shadow / "config"), "--get", "tag.gpgSign"],
capture_output=True, text=True,
)
assert result.stdout.strip() == "false"
def test_checkpoint_works_with_global_gpgsign_and_broken_gpg(
self, work_dir, checkpoint_base, monkeypatch, tmp_path
):
"""The real bug scenario: user has global commit.gpgsign=true but GPG
is broken or pinentry is unavailable. Before the fix, every snapshot
either failed or spawned a pinentry window. After the fix, snapshots
succeed without ever invoking GPG."""
monkeypatch.setattr("tools.checkpoint_manager.CHECKPOINT_BASE", checkpoint_base)
# Fake HOME with global gpgsign=true and a deliberately broken GPG
# binary. If isolation fails, the commit will try to exec this
# nonexistent path and the checkpoint will fail.
fake_home = tmp_path / "fake_home"
fake_home.mkdir()
(fake_home / ".gitconfig").write_text(
"[user]\n email = real@user.com\n name = Real User\n"
"[commit]\n gpgsign = true\n"
"[tag]\n gpgSign = true\n"
"[gpg]\n program = /nonexistent/fake-gpg-binary\n"
)
monkeypatch.setenv("HOME", str(fake_home))
monkeypatch.delenv("GPG_TTY", raising=False)
monkeypatch.delenv("DISPLAY", raising=False) # block GUI pinentry
mgr = CheckpointManager(enabled=True)
assert mgr.ensure_checkpoint(str(work_dir), reason="with-global-gpgsign") is True
assert len(mgr.list_checkpoints(str(work_dir))) == 1
def test_checkpoint_works_on_prefix_shadow_without_local_gpgsign(
self, work_dir, checkpoint_base, monkeypatch, tmp_path
):
"""Users with shadow repos created before the fix will not have
commit.gpgsign=false in their shadow's own config. The inline
``--no-gpg-sign`` flag on the commit call must cover them."""
monkeypatch.setattr("tools.checkpoint_manager.CHECKPOINT_BASE", checkpoint_base)
# Simulate a pre-fix shadow repo: init without commit.gpgsign=false
# in its own config. _init_shadow_repo now writes it, so we must
# manually remove it to mimic the pre-fix state.
shadow = _shadow_repo_path(str(work_dir))
_init_shadow_repo(shadow, str(work_dir))
subprocess.run(
["git", "config", "--file", str(shadow / "config"),
"--unset", "commit.gpgsign"],
capture_output=True, text=True, check=False,
)
subprocess.run(
["git", "config", "--file", str(shadow / "config"),
"--unset", "tag.gpgSign"],
capture_output=True, text=True, check=False,
)
# And simulate hostile global config
fake_home = tmp_path / "fake_home"
fake_home.mkdir()
(fake_home / ".gitconfig").write_text(
"[commit]\n gpgsign = true\n"
"[gpg]\n program = /nonexistent/fake-gpg-binary\n"
)
monkeypatch.setenv("HOME", str(fake_home))
monkeypatch.delenv("GPG_TTY", raising=False)
monkeypatch.delenv("DISPLAY", raising=False)
mgr = CheckpointManager(enabled=True)
assert mgr.ensure_checkpoint(str(work_dir), reason="prefix-shadow") is True
assert len(mgr.list_checkpoints(str(work_dir))) == 1

View file

@ -126,7 +126,22 @@ def _shadow_repo_path(working_dir: str) -> Path:
def _git_env(shadow_repo: Path, working_dir: str) -> dict:
"""Build env dict that redirects git to the shadow repo."""
"""Build env dict that redirects git to the shadow repo.
The shadow repo is internal Hermes infrastructure it must NOT inherit
the user's global or system git config. User-level settings like
``commit.gpgsign = true``, signing hooks, or credential helpers would
either break background snapshots or, worse, spawn interactive prompts
(pinentry GUI windows) mid-session every time a file is written.
Isolation strategy:
* ``GIT_CONFIG_GLOBAL=<os.devnull>`` ignore ``~/.gitconfig`` (git 2.32+).
* ``GIT_CONFIG_SYSTEM=<os.devnull>`` ignore ``/etc/gitconfig`` (git 2.32+).
* ``GIT_CONFIG_NOSYSTEM=1`` legacy belt-and-suspenders for older git.
The shadow repo still has its own per-repo config (user.email, user.name,
commit.gpgsign=false) set in ``_init_shadow_repo``.
"""
normalized_working_dir = _normalize_path(working_dir)
env = os.environ.copy()
env["GIT_DIR"] = str(shadow_repo)
@ -134,6 +149,13 @@ def _git_env(shadow_repo: Path, working_dir: str) -> dict:
env.pop("GIT_INDEX_FILE", None)
env.pop("GIT_NAMESPACE", None)
env.pop("GIT_ALTERNATE_OBJECT_DIRECTORIES", None)
# Isolate the shadow repo from the user's global/system git config.
# Prevents commit.gpgsign, hooks, aliases, credential helpers, etc. from
# leaking into background snapshots. Uses os.devnull for cross-platform
# support (``/dev/null`` on POSIX, ``nul`` on Windows).
env["GIT_CONFIG_GLOBAL"] = os.devnull
env["GIT_CONFIG_SYSTEM"] = os.devnull
env["GIT_CONFIG_NOSYSTEM"] = "1"
return env
@ -211,6 +233,13 @@ def _init_shadow_repo(shadow_repo: Path, working_dir: str) -> Optional[str]:
_run_git(["config", "user.email", "hermes@local"], shadow_repo, working_dir)
_run_git(["config", "user.name", "Hermes Checkpoint"], shadow_repo, working_dir)
# Explicitly disable commit/tag signing in the shadow repo. _git_env
# already isolates from the user's global config, but writing these into
# the shadow's own config is belt-and-suspenders — it guarantees the
# shadow repo is correct even if someone inspects or runs git against it
# directly (without the GIT_CONFIG_* env vars).
_run_git(["config", "commit.gpgsign", "false"], shadow_repo, working_dir)
_run_git(["config", "tag.gpgSign", "false"], shadow_repo, working_dir)
info_dir = shadow_repo / "info"
info_dir.mkdir(exist_ok=True)
@ -552,9 +581,11 @@ class CheckpointManager:
logger.debug("Checkpoint skipped: no changes in %s", working_dir)
return False
# Commit
# Commit. ``--no-gpg-sign`` inline covers shadow repos created before
# the commit.gpgsign=false config was added to _init_shadow_repo — so
# users with existing checkpoints never hit a GPG pinentry popup.
ok, _, err = _run_git(
["commit", "-m", reason, "--allow-empty-message"],
["commit", "-m", reason, "--allow-empty-message", "--no-gpg-sign"],
shadow, working_dir, timeout=_GIT_TIMEOUT * 2,
)
if not ok: