ADR 0022 — Hermetic test guard: `go test` cannot resolve real credentials or mutate the real checkout
- Status: Accepted
- Date: 2026-07-07
- Tracking: legacy tracker 2034
Context
A handler test called issuesDevelop without sandboxing. Because
serviceissue.Develop operates on the process working directory’s git repo and
resolves credentials from the ambient environment, the test — run from the real
madtea checkout — resolved real issue legacy tracker 42 through the live forge API, created
and checked out a branch, and pushed it to origin. It did this silently,
through a full green gate, twice (origin gained
issue-42-phase-3-git-commands-migration).
Two ambient dependencies made that possible:
- Credential resolution reads real config.
internal/configresolves the forge URL + token from the real global/per-checkout git config (or the secure backend keyed off it) whenever explicit env config is absent. A test that does not inject config inherits the developer’s live credentials. - Repo discovery trusts the process cwd.
internal/git’sIsGitRepo/GetRepoRootresolve whatever repo the test happens to becd’d into. Run from the real checkout, a mutating service op mutates real history.
Point fixes (sandboxing individual tests with t.Chdir(t.TempDir()), as legacy tracker 2029
did for one develop test) are convention, not mechanism: the next unsandboxed
test reintroduces the hazard, and the gate stays green while it does.
Decision
Two structural guards, both keyed on testing.Testing() (Go stdlib; false
outside go test, so production binaries are untouched), make the two hazards
impossible rather than merely discouraged.
Config guard (kills the network half)
Under go test, the AMBIENT credential-resolution paths in internal/config
refuse with a loud, explanatory error citing legacy tracker 2034:
getConfigFromGit(feedsGetConfig/GetConfigNoRepo),GetCredentials(after its explicit-env short-circuit),GetCredentialsWithSource(after its env short-circuit; having no error channel, it degrades to the all-empty “not configured” signal).
The EXPLICIT env-injection path (MADTEA_URL + MADTEA_TOKEN, via
getConfigFromEnv and each caller’s own env short-circuit) is allowed — it
is explicit, test-owned state, not ambient, and is how fake-server tests inject
config today.
Git-mutation guard (kills the git half)
Under go test, service-layer repo discovery refuses when the resolved worktree
root is NOT beneath the OS temp dir (i.e. it is the real checkout):
GetRepoRootDirreturns the legacy tracker 2034 guard error;IsGitRepoDir— the gate every mutating service op (branch create/delete, commit, stage, push, pull, fetch, worktree, remote) checks — reportsfalse, so those ops fail closed at detection before any mutation;serviceissue.Developgates onGetRepoRoot, so the incident flow now fails at its first step with the legacy tracker 2034 error, before any issue fetch, branch creation, or push.
Tests operating in t.TempDir() repos resolve a root under os.TempDir() and
are unaffected.
Structural allowances (facts about test state, never a switch)
Mirroring ADR 0009’s empty-repo carve-out, every allowance keys on a fact about test state, not on any settable configuration:
Config guard applies a single UNIFIED predicate: resolution is permitted iff BOTH config scopes are test-owned or neutralized —
- the global scope:
HOMEunder the OS temp dir ORGIT_CONFIG_GLOBALunder the OS temp dir ORGIT_CONFIG_GLOBAL=/dev/null, AND - the repo scope: the process cwd resolves to NO git worktree toplevel (nothing per-repo to leak) OR that toplevel resolves under the OS temp dir.
Both halves are required. A temp-scoped
HOMEwhile the cwd is still the real checkout is refused — per-repo credentials (madtea.url/madtea.tokenin a checkout’s own.git/config) are the documented default setup, so a temp-HOME-with-real-cwd allowance would re-open the network half of the incident. The binding contract clause is “refuse anything reaching real user/global/checkout state,” not merely real global state.- the global scope:
Git-mutation guard permits any repo whose worktree root resolves under the OS temp dir.
Both use symlink-resolved path comparison so a /tmp vs /private/tmp
divergence never produces a false negative.
Residual limitations (accepted, not bugs)
GetCredentialsWithSourcedegrades SILENTLY. UnlikeGetCredentials/getConfigFromGit, this function has no error return channel (callers such aswhoamionly display the result and must distinguish “not configured” from a hard failure). Under the guard it therefore returns the all-empty “not configured” tuple rather than a loud legacy tracker 2034 error — the refusal is correct (no real credentials leak) but is not surfaced to the caller as an explanatory message. This is an accepted tradeoff of keeping the 4-string signature.GIT_CONFIG_SYSTEM(/etc/gitconfig) is still readable. The config guard keys onHOME/GIT_CONFIG_GLOBALand the cwd’s worktree — it does not neutralize the system-scope git config. In practice madtea credentials are never written to/etc/gitconfig, and a system-scope guard would add a subprocess and a maintenance surface for a hazard no real setup exhibits, so a system-scope guard is deliberately NOT implemented. Tests that need full isolation from a hostile/etc/gitconfigsetGIT_CONFIG_SYSTEM=/dev/nullthemselves; this is noted here so the boundary is explicit.
The rejected env opt-in
The issue body proposed MADTEA_TEST_ALLOW_REAL_CONFIG=1. Rejected. Per
ADR 0009 (agent-facing guards are non-overridable; no agent-reachable opt-out —
an escape the guarded party can reach defeats the guard) and the standing rule
that enforcement gates get no agent-usable bypass (the human is the escape
hatch), the allowance must be STRUCTURAL. A settable switch is exactly the
foot-gun ADR 0009 removed from the hooks; re-adding one here would let a test (or
an agent editing a test) turn the guard off the moment it is inconvenient. There
is no bypass switch, and the error messages advertise none — they point only at
the structural path forward (run in a temp-dir repo / inject explicit test
config via env).
Consequences
- The incident is now impossible to reproduce silently. The acceptance test
TestIssuesDevelop_NoChdir_HitsGuardruns the exact incident flow against the real checkout and asserts the legacy tracker 2034 guard error with no branch created. - Latent ambient-config reliance is surfaced, not hidden. Two tests that
relied on ambient credential resolution (
TestIssuesDelete_MultipleNumbers,TestPrsIsMerged_MultipleNumbers) were made hermetic by injecting explicit env config — the guard’s own advice — rather than by weakening the guard. - No production impact.
testing.Testing()is false outsidego test, so every guard is a no-op in shipped binaries. - Lineage. This extends ADR 0009’s non-overridable-guard posture from the Bash hooks to the Go test process, and reuses its structural-carve-out shape.
Alternatives considered
- A
MADTEA_TEST_ALLOW_REAL_CONFIGenv opt-in (the issue’s proposal). Rejected — see above; it is an agent-reachable escape, which ADR 0009 forbids. - Convention only (lint / TestMain sweep / reviewer discipline). Rejected: the incident passed a full green gate twice. A mechanism that fails closed is the only thing that makes the hazard structurally impossible.
- Guarding
IsGitRepoDirby panic to surface a legacy tracker 2034 message. Rejected: a panic is unidiomatic and would crash the MCP server should it ever fire outside tests; insteadIsGitRepoDirfails closed tofalseand the develop flow gates on the error-returningGetRepoRoot, so the legacy tracker 2034 message still surfaces where it matters.
Canonical source: docs/adr/0022-hermetic-test-guard.md in the madtea repo.