Skip to content

fix(gh): fail closed when GH_TOKEN overrides the resolved identity - #302

Merged
twistedmelonman merged 1 commit into
mainfrom
claude/fix-gh-token-precedence-guard-1788407124
Sep 3, 2026
Merged

fix(gh): fail closed when GH_TOKEN overrides the resolved identity#302
twistedmelonman merged 1 commit into
mainfrom
claude/fix-gh-token-precedence-guard-1788407124

Conversation

@twistedmelonman

Copy link
Copy Markdown
Owner

What

GH_TOKEN takes precedence over the keyring identity that gh auth switch selects. The existing fail-closed check in _gh_wrapper_sync_identity reads the user: field out of the hosts.yml that the wrapper itself just wrote — so it verified its own output rather than the auth gh would actually use.

The sequence:

  1. _gh_wrapper_sync_identity() resolves the owner and picks an identity.
  2. gh auth switch succeeds and updates ~/.config/gh/hosts.yml.
  3. The fail-closed check reads user: from hosts.yml, sees the correct name, and passes.
  4. gh runs as whatever GH_TOKEN holds, 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:

  • smartwatermelon and nightowlstudiollc repos — unaffected (both map to smartwatermelon). Verified live against smartwatermelon/dev-env and smartwatermelon/dotfiles.
  • Repos resolving to 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.sh pins the inverted assertion. Its mismatched-token case was observed failing against the pre-fix wrapper before the guard was written — proven against a pristine HEAD copy, and re-proven after each subsequent rewrite of the test:

PASS: no GH_TOKEN: sync succeeds
FAIL: mismatched GH_TOKEN: silently ran as the wrong identity   <-- the defect
PASS: matching GH_TOKEN: proceeds

Full suite: SUMMARY: 24 passed, 0 failed, 24 total. shellcheck -S info clean on all three files, no disable directives.

Two things worth a reviewer's attention

_gh_wrapper_find_real_gh instead of command gh. ~/.local/bin/gh is this wrapper and precedes the real binary in PATH, so command gh would re-enter the very function being guarded. The helper does a PATH scan that skips this file.

test-gh-wrapper-identity.sh needed a fix, and the fix went in the test. Six of its cases started failing once the guard existed. It sandboxes HOME, GIT_CONFIG_GLOBAL and GIT_CONFIG_SYSTEM, but inherited the developer's ambient GH_TOKEN — which correctly outranked every fixture identity. The guard was right; inheriting a real token into those cases was not. Fixed by unsetting GH_TOKEN alongside the existing HOME sandbox.

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_TOKEN is a gh credential, 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=andrewmrich injected gives 17 PASS / 0 FAIL.

Known follow-up, deliberately not built here

When CLAUDE_GH_TOKEN_LOGIN is unset, the production path resolves the token's identity with a gh api user call — one network round trip per gh invocation 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 andrewmrich and reverses a documented product decision in claude-wrapper/README.md.

https://claude.ai/code/session_01RBvPRMFfep4ktSDfGe9uHq

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
@claude

claude Bot commented Sep 3, 2026

Copy link
Copy Markdown

No blocking issues found. The new GH_TOKEN identity guard in _gh_wrapper_sync_identity correctly fails closed when the token resolves to a different identity than the repo owner requires. Error paths (unresolvable token login, identity mismatch) are handled explicitly. The existing test is correctly sandboxed with unset GH_TOKEN CLAUDE_GH_TOKEN_LOGIN to prevent ambient env interference, and the new test file covers the three relevant cases.

VERDICT: PASS

@claude

claude Bot commented Sep 3, 2026

Copy link
Copy Markdown

Gate Review: PR #302

Verdict: GH_TOKEN precedence check correctly added to _gh_wrapper_sync_identity(). Logic traces cleanly — token identity is resolved (from env var or gh api user call with proper error handling), compared case-insensitively against the required owner, and function fails closed on mismatch. Test changes properly sandbox the environment and exercise the new validation. No bugs, regressions, or error-handling gaps.

VERDICT: PASS

@twistedmelonman
twistedmelonman merged commit 05784d2 into main Sep 3, 2026
4 checks passed
@twistedmelonman
twistedmelonman deleted the claude/fix-gh-token-precedence-guard-1788407124 branch September 3, 2026 04:06
twistedmelonman added a commit that referenced this pull request Sep 3, 2026
…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>
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.

1 participant