Skip to content

Replace global 'gh auth switch' with owner-keyed per-invocation tokens (races; two live reproductions) #336

Description

@twistedmelonman

Summary

_gh_wrapper_sync_identity emulates per-repo GitHub identity by calling
gh auth switch, which rewrites ~/.config/gh/hosts.yml — process-global,
shared by every concurrent shell and agent on the machine. Any call can
repoint the active account for every other caller, between another caller's
check and its use.

The wrapper already acknowledges this (bash/gh-wrapper.sh:256-259):

Local-only (reads/writes gh's config file, no network), so it's cheap to run
on every invocation. Caveat: this mutates global gh state, so concurrent
shells working in different-owner repos at the same time can race each
other.

The mutation itself is at bash/gh-wrapper.sh:392.

This proposes replacing the global switch with owner-keyed per-invocation
token injection
, so no shared mutable state exists to race on.

Two live reproductions, same session (2026-09-16)

Both happened unprompted during ordinary work, not while testing for them.

1. Silent wrong answer. A PR query against beacon-biosignals returned
zero results while the active account was the personal one, which cannot see
that private org. An empty result is indistinguishable from "no open PRs" —
this nearly shipped into a status report as fact. Caught only because the
result contradicted other evidence.

$ gh api graphql -f query='... is:open author:andrewmrich ...'
{"data":{"mine":{"nodes":[]},"review":{"nodes":[]}}}   # false negative
$ gh auth status   # active account: twistedmelonman

2. Loud failure. gh pr create against twistedmelonman/dotfiles
immediately after an explicit gh auth switch --user twistedmelonman:

pull request create failed: GraphQL: must be a collaborator (createPullRequest)
$ gh api user --jq .login
andrewmrich        # flipped between the switch and the create

The account had changed in the interval. Re-switching and retrying succeeded.

The asymmetry is the danger. Which direction the flip goes decides whether
you get a hard error or a plausible-looking wrong answer. Case 2 failed safely
because the wrong account lacked write access. Case 1 failed silently because
the wrong account lacked read access, and absence of data reads as absence
of results.

Why this matters more than it used to

On a machine with the CLAUDE_GH_TOKEN_ROUTER tier, a mis-switch is bounded
by the limited token's scope. On a machine without it, agents act on
full-scope keyring logins and nothing downstream limits the damage. The work
machine is in the second category and cannot move to the first: that would
need an org-level fine-grained token on a third-party org, requiring admin
approval that is not available.

Scope correctness therefore rests entirely on the switch being right — which
is exactly what this race undermines.

Proposal: owner-keyed per-invocation tokens

Resolve the owner as today, then pass that owner's token to the single gh
invocation via the environment, instead of mutating global state:

  • No write to hosts.yml, so nothing to race.
  • Concurrent shells in different-owner repos become independent.
  • Identity is decided and used in one step, removing the check/use window.
  • The mechanism already exists in this wrapper — CLAUDE_GH_TOKEN_ROUTER
    (bash/gh-wrapper.sh:853-856) injects a token per invocation. This
    generalises that path rather than inventing one.

Folds in the held positional-args fix

smartwatermelon/dotfiles#330 has a complete, tested fix sitting on an
unpushed local branch (claude/fix-gh-wrapper-positional-owner-a1b83c19,
commit 4555c7c). It was deliberately held rather than pushed, because its
approach — a whitelist of gh subcommands documented to take a repository —
is maintenance-coupled to gh's CLI surface and goes stale silently when
GitHub ships a new subcommand.

Verified the branch resolves correctly, including the negative case:

repo view beacon-biosignals/infra  -> beacon-biosignals
api repos/beacon-biosignals/infra  -> beacon-biosignals
repo rename foo/bar                -> (empty, correct — that is a new name)

Land owner resolution as part of this redesign so the whitelist burden is
paid once, in a form that also closes the race.

Open questions

  1. Token storage. Reading per-owner tokens out of the gh keyring without
    auth switch needs a mechanism; gh auth token --user X may serve, needs
    checking against the installed version.
  2. Which default is correct. The wrapper defaults unresolved owners to
    twistedmelonman (bash/gh-wrapper.sh:250-253), while SSH defaults to the
    work key — ssh -T git@github.com answers as the work account, and
    personal work is the aliased exception. Two layers, opposite defaults. Both
    work today; the redesign should decide which is right rather than inherit
    one.
  3. Interaction with the router. If both are present, precedence must be
    explicit.
  4. Fail-closed on unresolved owner. Refusing is the correct outcome when
    identity cannot be confirmed, and is required by the consuming policy.

Related

  • smartwatermelon/dotfiles#330 — positional owner parsing, folded in here
  • smartwatermelon/dotfiles#303 — per-invocation gh api user cost; the same
    redesign likely subsumes it
  • smartwatermelon/dotfiles#315 — per-repo override for off-org draft-forcing,
    shares the owner-resolution path

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions