fix(gh): fail closed when GH_TOKEN overrides the resolved identity - #302
Conversation
GH_TOKEN takes precedence over the keyring identity that `gh auth switch` selects, so the existing fail-closed check — which reads the `user:` field from the hosts.yml the wrapper itself just wrote — verified its own output rather than the auth gh actually uses. It reported success while the guarantee it enforces was void. Cheap tier only: this does not route tokens per-org. It converts a silent wrong-identity into a visible error. Operations that previously succeeded as the wrong identity will now fail explicitly, which is the intent. Adds a regression test whose mismatched-token case was observed failing against the previous code. Resolving token_login uses _gh_wrapper_find_real_gh rather than `command gh`: ~/.local/bin/gh is this same wrapper and precedes the real binary in PATH, so `command` would re-enter the function being guarded. test-gh-wrapper-identity.sh now unsets GH_TOKEN alongside its existing HOME sandbox. It sandboxes HOME but inherited the ambient token, so six of its cases began failing once the guard existed — correctly, since a real token did outrank each fixture identity. The guard is right; inheriting the developer's token into those cases was not. Claude-Session: https://claude.ai/code/session_01RBvPRMFfep4ktSDfGe9uHq
|
No blocking issues found. The new VERDICT: PASS |
Gate Review: PR #302Verdict: GH_TOKEN precedence check correctly added to VERDICT: PASS |
…305) The guard added in #302 resolves the token's identity with `gh api user` and treated an empty result as the failure signal. But `gh api` writes its error body to STDOUT, so `2>/dev/null` does not suppress it: a rejected token makes the command substitution capture `{"message": "Bad credentials", ...}`. That value is non-empty, so the "could not be resolved" branch was unreachable, and an expired token was reported as an identity MISMATCH with the JSON error body printed where a login name belongs: [gh] ERROR: GH_TOKEN authenticates as '{ "message": "Bad credentials", ... }' but repo owner 'smartwatermelon' requires 'smartwatermelon' It still failed closed, so this was never a security hole — but it named the wrong cause and sent the reader to "unset GH_TOKEN" when the actual fix is to rotate the token. Decide on the exit status rather than on output emptiness, and drop any value that is not shaped like a GitHub login before it reaches the comparison. The resolution-failure message now names token expiry as the likely cause and gives the command to check it. Found when a real token expired mid-session. Adds a regression test with a stub gh whose output shape was verified against the real binary; all three of its new assertions were observed failing against the previous code. Claude-Session: https://claude.ai/code/session_01RBvPRMFfep4ktSDfGe9uHq Co-authored-by: Claude Code Bot <claude-code@smartwatermelon.github>
What
GH_TOKENtakes precedence over the keyring identity thatgh auth switchselects. The existing fail-closed check in_gh_wrapper_sync_identityreads theuser:field out of thehosts.ymlthat the wrapper itself just wrote — so it verified its own output rather than the authghwould actually use.The sequence:
_gh_wrapper_sync_identity()resolves the owner and picks an identity.gh auth switchsucceeds and updates~/.config/gh/hosts.yml.user:fromhosts.yml, sees the correct name, and passes.ghruns as whateverGH_TOKENholds, ignoring all of it.Step 3 is what makes this a defect rather than a missing feature. That check exists specifically to refuse running as the wrong identity, and it reported success while the guarantee it enforces was void.
This is the cheap tier: it does not route tokens per-org. It converts a silent wrong-identity into a visible error.
Expected consequence, stated up front
Operations that currently succeed as the wrong identity will begin failing visibly. That is the intent, and it surfaces on first use rather than at deploy time.
Measured blast radius with a token authenticating as
smartwatermelon:smartwatermelonandnightowlstudiollcrepos — unaffected (both map tosmartwatermelon). Verified live againstsmartwatermelon/dev-envandsmartwatermelon/dotfiles.andrewmrich— Beacon-BioSignals checkouts,AndrewMRich-owned repos — now fail with an explicit error instead of acting as the wrong account.Testing
bash/tests/test-gh-wrapper-gh-token-precedence.shpins the inverted assertion. Its mismatched-token case was observed failing against the pre-fix wrapper before the guard was written — proven against a pristineHEADcopy, and re-proven after each subsequent rewrite of the test:Full suite:
SUMMARY: 24 passed, 0 failed, 24 total.shellcheck -S infoclean on all three files, nodisabledirectives.Two things worth a reviewer's attention
_gh_wrapper_find_real_ghinstead ofcommand gh.~/.local/bin/ghis this wrapper and precedes the real binary inPATH, socommand ghwould re-enter the very function being guarded. The helper does aPATHscan that skips this file.test-gh-wrapper-identity.shneeded a fix, and the fix went in the test. Six of its cases started failing once the guard existed. It sandboxesHOME,GIT_CONFIG_GLOBALandGIT_CONFIG_SYSTEM, but inherited the developer's ambientGH_TOKEN— which correctly outranked every fixture identity. The guard was right; inheriting a real token into those cases was not. Fixed by unsettingGH_TOKENalongside the existingHOMEsandbox.That fix deliberately did not go into
bash/tests/lib/git-env-isolation.sh. That helper's header explicitly scopes it to git repository-selection variables and documents what it declines to touch;GH_TOKENis aghcredential, not a repo selector.Verified the isolation actually holds rather than assuming it: running the identity suite with a hostile
GH_TOKEN=fake-ambient-token CLAUDE_GH_TOKEN_LOGIN=andrewmrichinjected gives 17 PASS / 0 FAIL.Known follow-up, deliberately not built here
When
CLAUDE_GH_TOKEN_LOGINis unset, the production path resolves the token's identity with agh api usercall — one network round trip perghinvocation in the hot path. Session-level caching is the obvious next step and is filed separately rather than bundled in.The full per-org router stays deferred: it needs a second PAT for
andrewmrichand reverses a documented product decision inclaude-wrapper/README.md.https://claude.ai/code/session_01RBvPRMFfep4ktSDfGe9uHq