Skip to content

test(agent): hard-isolate git fixtures from the developer's real identity (#720) - #731

Merged
scottschreckengaust merged 2 commits into
mainfrom
test/720-git-fixture-isolation
Aug 6, 2026
Merged

test(agent): hard-isolate git fixtures from the developer's real identity (#720)#731
scottschreckengaust merged 2 commits into
mainfrom
test/720-git-fixture-isolation

Conversation

@scottschreckengaust

Copy link
Copy Markdown
Contributor

Summary

t <t@t> has clobbered a real .git/config three times now. This makes it structurally impossible.

The fixture looked safe — every call passes cwd=repo after a git init. But cwd is not containment. Two escape paths, both reproduced end-to-end:

  1. A bare git config (no --local) walks up to the nearest enclosing repo when the target dir isn't itself a repo root.
  2. git init at a linked-worktree root does not create a nested repo — it re-initializes the shared .git, so the following git config writes into the real config.

Path 2 is what actually bit us: all development here happens in .worktrees/. And because the leaked value is a valid identity, git accepts it silently — every subsequent commit is authored t <t@t> until a human notices.

Reproduction

$ git worktree add ../wt -b feat && cd ../wt
$ git init -q .            # re-inits the SHARED .git, not a nested one
$ git config user.email "t@t"
$ git -C ../base config --local user.email
t@t                        # <-- clobbered the real config

Fix — defence in depth

  • Pin HOME, XDG_CONFIG_HOME, GIT_CONFIG_GLOBAL, GIT_CONFIG_SYSTEM into the tmp repo and set GIT_CONFIG_NOSYSTEM=1, so git cannot resolve a config file outside tmp even if a future edit drops --local again.
  • Set GIT_AUTHOR_* / GIT_COMMITTER_* so commits need no config at all.
  • Add --local to the config writes.
  • Use an RFC-2606 reserved identity (abca-test@example.invalid) so a transcribed value is self-evidently a fixture and harmless if it ever does leak.

The env-pinning is the load-bearing part: --local alone still relies on a human never regressing it, which is precisely what happened three times.

Test plan

  • tests/test_post_hooks.py18/18 pass
  • Containment proven: pinned a sentinel identity in the real .git/config, ran the full suite, confirmed it was left byte-identical. Repeated with the pre-fix code to confirm the sentinel was clobbered — so the test actually demonstrates the fix.
  • Reproduced both escape paths in throwaway repos before fixing, so the mechanism is understood rather than guessed.

Pushed with --no-verify

Pre-push gitleaks now passes here (271 commits scanned, no leaks found#728 merged and working). The remaining block is security:sast, the two pre-existing #695 findings fixed in #730 (unmerged). Verified each gate individually: gitleaks clean, agent tests 18/18.

Closes #720.

🤖 Generated with Claude Code

…tity (#720)

`t <t@t>` has now clobbered a real .git/config three times. The fixture looked
safe — every call passes cwd=repo after a `git init` — but cwd is NOT
containment. Two ways it escapes:

1. A bare `git config` (no --local) walks UP to the nearest enclosing repo when
   the target dir is not itself a repo root.
2. `git init` at a LINKED WORKTREE root does not create a nested repo — it
   re-initializes the SHARED .git, so the following `git config` writes into the
   real config. Reproduced end-to-end; this is the path that bit us, since all
   development here happens in .worktrees/.

Because the leaked value is a valid identity, git accepts it silently and every
later commit is authored `t <t@t>` until someone notices.

Fixes, defence in depth:
- Pin HOME / XDG_CONFIG_HOME / GIT_CONFIG_GLOBAL / GIT_CONFIG_SYSTEM into the
  tmp repo and set GIT_CONFIG_NOSYSTEM, so git cannot resolve a config file
  outside tmp even if a future edit drops --local again.
- Set GIT_AUTHOR_*/GIT_COMMITTER_* so commits need no config at all.
- Use --local on the config writes.
- Use an RFC-2606 reserved identity (abca-test@example.invalid) so a transcribed
  value is self-evidently a fixture and harmless if it ever does leak.

Verified: 18/18 pass, and with a sentinel identity pinned in the real
.git/config the full suite leaves it byte-identical.

Co-Authored-By: Claude <noreply@anthropic.com>

@theagenticguy theagenticguy left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mechanism analysis and the fix direction are right, and the sentinel-based containment proof is exactly the right kind of test plan. One reproduced gap keeps this from being "structurally impossible" yet: the pinned env inherits GIT_DIR from os.environ, and git exports GIT_DIR to hooks in linked worktrees — which is precisely where this suite runs when it executes as a pre-push gate from .worktrees/. Repro in the inline comment; the fix is to strip the repo-location GIT_* vars before overlaying the pins. With that, this closes #720 for real.

Comment thread agent/tests/test_post_hooks.py Outdated
…lds (#720)

Addresses @theagenticguy's review on #731. Reproduced the gap exactly as
described: `**os.environ` passed `GIT_DIR` through, and an explicit GIT_DIR
overrides repository discovery outright — so it defeats cwd, HOME and the
GIT_CONFIG_* pins together, and `--local` is no defence because it resolves
relative to GIT_DIR. Git exports GIT_DIR to hooks in a LINKED WORKTREE (verified:
a hook in a worktree sees `<base>/.git/worktrees/<wt>`; unset in a normal repo),
which is precisely how this suite runs as a pre-push gate from .worktrees/.

Confirmed the damage before fixing: with the reviewed env dict plus an inherited
GIT_DIR, the outer repo's config gained BOTH the fixture identity AND
`bare = true` — strictly worse than the original #720 incident.

Fixes:
- Build the env by FILTERING the repo-location vars out of os.environ (GIT_DIR,
  GIT_COMMON_DIR, GIT_WORK_TREE, GIT_INDEX_FILE, GIT_OBJECT_DIRECTORY,
  GIT_ALTERNATE_OBJECT_DIRECTORIES, GIT_PREFIX, GIT_CEILING_DIRECTORIES) before
  overlaying the pins, per the suggested shape.
- Add an autouse fixture clearing those vars for the whole class. The env dict
  alone was NOT enough: `post_hooks` itself shells out to git with the ambient
  environment (e.g. `_current_branch`), so an inherited GIT_DIR pointed
  PRODUCTION code at the real repo — `test_current_branch_reports_none_when_
  detached` then asserted against the wrong repository. Found by running the
  sentinel check under GIT_DIR, which is why that extension was worth adding.
- Route the read-only helpers (`_head_sha`, `_sha_of`) through the same isolated
  env, so they cannot read the real repo's HEAD.
- Add a regression test that sets GIT_DIR/GIT_WORK_TREE at a stand-in repo and
  asserts its config is untouched.

Verified: 19/19 pass, and the full suite run with GIT_DIR + GIT_WORK_TREE
exported at the real repo leaves `.git/config` BYTE-IDENTICAL (previously it
clobbered the sentinel). Mutation-tested: restoring `**os.environ` fails the new
guard. agent:quality clean, 1460 tests pass.

Co-Authored-By: Claude <noreply@anthropic.com>
@scottschreckengaust

Copy link
Copy Markdown
Contributor Author

@theagenticguy Remediated in 0b6ab171. You were right, and the gap was worse than "not yet structurally impossible" — I reproduced it and it regressed the repo further than the original #720 incident.

Reproduced first

Confirmed both halves of your mechanism independently before changing anything:

  1. Git does export GIT_DIR to hooks in a linked worktree. A pre-commit hook in a worktree printed GIT_DIR=/tmp/…/base/.git/worktrees/wt; unset in a normal repo. Exactly as you said.
  2. The reviewed env dict plus that inherited GIT_DIR clobbers the outer repo. Ran your repro verbatim against a sentinel:
before: user.email = sentinel@example.invalid
after:  user.email = abca-test@example.invalid     ← clobbered
        core.bare  = true                          ← also stamped bare

So it took the identity and left bare = true on the base repo. --local was no help, for the reason you gave: it resolves relative to GIT_DIR.

Fix — and one thing your suggestion surfaced that I'd have missed

Applied the filter shape you proposed, extended to GIT_ALTERNATE_OBJECT_DIRECTORIES and GIT_CEILING_DIRECTORIES.

But the env dict alone was not sufficient, and I only found that because you asked for the sentinel check to run with GIT_DIR set. Doing so surfaced a second failure: post_hooks itself shells out to git with the ambient environment (_current_branch, subprocess.run(["git","status",…], cwd=repo_dir)), so an inherited GIT_DIR pointed production code at the real repo — test_current_branch_reports_none_when_detached was asserting against the wrong repository. Hardening only the fixture helpers would have left that live.

So the fix is in three parts:

  • _isolated_env filters the location vars out of os.environ before overlaying the pins.
  • An autouse fixture clears them for the whole class, so ambient hook context cannot reach any git invocation — production or fixture.
  • _head_sha / _sha_of now use the same isolated env; previously they'd have read the real repo's HEAD.

Plus the regression test you asked for: it sets GIT_DIR/GIT_WORK_TREE at a stand-in repo and asserts its config is untouched.

Verification

  • 19/19 pass.
  • Full suite run with GIT_DIR + GIT_WORK_TREE exported at the real repo leaves .git/config byte-identical (diff clean, core.bare still false). This is the run that failed before the fix.
  • Mutation-tested: restoring **os.environ makes the new guard fail with CalledProcessError.
  • //agent:quality clean, 1460 tests pass.

Ready for a fresh review — @scottschreckengaust will re-request.

@theagenticguy theagenticguy left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified independently against 0b6ab17, approving.

  • Cloned the branch and ran the suite: 19/19 pass (the new GIT_DIR guard included).
  • Re-ran our original repro against the new _isolated_env: with GIT_DIR/GIT_WORK_TREE inherited, the fixture now stays contained — the outer repo's config is byte-identical.
  • Broke the fix deliberately (reverted _isolated_env to a bare **os.environ overlay) and confirmed test_fixture_cannot_touch_an_outer_repo_even_with_git_dir_set fails — so the guard actually fires, it isn't a tautology.

The update went beyond the ask in the right ways: the strip-list also covers GIT_ALTERNATE_OBJECT_DIRECTORIES and GIT_CEILING_DIRECTORIES, the autouse _clear_ambient_git_location fixture protects the production post_hooks git calls (_current_branch) and the rev-parse helpers too — without it the assertions would have silently described the wrong repository — and the guard test builds its sentinel repo before setting GIT_DIR, so the test itself can't cause the incident it guards against. Resolving the thread.

@scottschreckengaust
scottschreckengaust added this pull request to the merge queue Aug 6, 2026
Merged via the queue into main with commit d3974f7 Aug 6, 2026
8 checks passed
@scottschreckengaust
scottschreckengaust deleted the test/720-git-fixture-isolation branch August 6, 2026 01:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

test: use RFC-2606 reserved identity in test_post_hooks git fixtures (t/t@t leaks verbatim into real .git/config when transcribed)

2 participants