Skip to content

feat(zsh): pick the gh account per repo from the SSH host alias - #137

Merged
iamgerwin merged 9 commits into
mainfrom
feat/repo-specific-gh-account
Nov 13, 2026
Merged

iamgerwin merged 9 commits into
mainfrom
feat/repo-specific-gh-account

Conversation

@iamgerwin

@iamgerwin iamgerwin commented Nov 13, 2026 •

Copy link
Copy Markdown
Owner

Summary

gh <args>   (zsh wrapper in functions.zsh)
  GH_TOKEN already set?            -> command gh <args>   (explicit token wins)
  origin host in GH_HOST_ACCOUNTS?
    yes, token for that account    -> GH_TOKEN=<token> command gh <args>   (this call only)
    no / not a repo / no token     -> command gh <args>   (active account, unchanged)
  • Adds a gh wrapper that reads the origin remote's SSH host alias and runs gh with the mapped account's token, so personal clones (github.com-iamgerwin) use iamgerwin while work repos keep the active account. The global active account never changes.
  • Review fixes (two rounds, see comments): works under nounset, never traces the token, -R/--repo/GH_REPO keep the active account while text flag values and -- do not trigger that, a mapped account without a token fails closed, an unset or scalar map cannot crash gh, and host aliases match case-insensitively.
  • functions.zsh ships an empty GH_HOST_ACCOUNTS; the personal mapping lives in custom.zsh (or ~/.zshrc.local to keep it out of git); one pair per ~/.ssh/config alias. Handles scp-style and ssh:// remotes.
  • README documents the mapping and the GH_TOKEN override; CHANGELOG records it under Unreleased.

Evidence

  • Before: gh in a clone of a personal repo ran as the active work account; private personal repos returned "not found" unless the user ran gh auth switch globally.
    After: gh api user returns iamgerwin in a github.com-iamgerwin clone, and the active account in a work repo and outside any repo (checked locally on macOS zsh).
  • bash scripts/check.sh passes locally on macOS (44 Python tests, including 16 in tests/gh_wrapper_test.py, plus shell, skill and doc checks); CI green on Linux and macOS.
  • README and CHANGELOG align; VERSION unchanged (Unreleased). learnings/dump.md records the credential-wrapper lesson from the review.
  • Tests use a fake gh and throwaway repositories: mapped alias (scp and ssh://), unmapped remote, outside a repo, explicit GH_TOKEN, mapped account without a token (fails closed), no token leaking into the calling shell, setopt nounset, setopt xtrace (token never traced, caller options restored), and -R/--repo/GH_REPO skipping the mapping. The new cases fail against the first version.

Merge Danger

Door: two-way. Revert the commit or remove the function; command gh is untouched.
Blast Radius: every interactive zsh gh call. Unmapped hosts behave exactly as before; scripts calling command gh or running in bash are unaffected.

The gh wrapper reads the origin remote's SSH host alias and runs gh with
the matching account's token (GH_HOST_ACCOUNTS), so personal repos use
iamgerwin without switching the global active account. An explicit
GH_TOKEN always wins.
Mocked gh and throwaway repositories cover the mapped SSH alias (scp and
ssh:// forms), unmapped remotes, running outside a repository, an
explicit GH_TOKEN, a mapped account without a token, and that the token
stays out of the calling shell.
README explains GH_HOST_ACCOUNTS and the GH_TOKEN override; CHANGELOG
records the wrapper under Unreleased.
@iamgerwin iamgerwin self-assigned this Nov 13, 2026
@iamgerwin

Copy link
Copy Markdown
Owner Author

Review: mergeability and security

Verdict: mergeable after fixes. CI is green, there are no conflicts with main, and README/CHANGELOG/VERSION are aligned. Four should-fix issues in functions.zsh are listed below; each was reproduced with a fake gh and checked against the suggested fix.

Should-fix

1. setopt nounset breaks every gh call, including in unmapped repos
[[ -z "$GH_TOKEN" ]] fails when GH_TOKEN is unset. Repro: setopt nounset; gh api user → gh:1: GH_TOKEN: parameter not set.
Fix: use ${GH_TOKEN:-} and ${GH_HOST_ACCOUNTS[$host]:-}.

2. -R/--repo and GH_REPO are ignored, so the wrong account can be used (security, identity)
In a clone mapped to iamgerwin, gh -R other/x pr list still sends the iamgerwin token. Commands aimed at work repos from inside a personal clone would run as the personal account.
Fix: when -R, --repo or GH_REPO is present, skip the mapping.

3. xtrace prints the token (security, credential exposure)
With setopt xtrace, which is common when debugging shell config, the trace includes GH_TOKEN=<token>, so it ends up in terminal scrollback and pasted logs.
Fix: start gh() with setopt localoptions noxtrace noverbose.

4. Falling back to the active account is silent (security, identity)
When the mapped account has no token (logged out or expired), the wrapper quietly runs as the active account. A PR could be opened under the wrong identity without any warning.
Fix: print a warning on stderr in that path.

Suggested gh() with all four fixes (verified)

gh() {
    setopt localoptions noxtrace noverbose
    local arg
    for arg in "$@"; do
        [[ $arg == -R || $arg == --repo || $arg == --repo=* || $arg == -R?* ]] && { command gh "$@"; return; }
    done
    if [[ -z "${GH_TOKEN:-}${GH_REPO:-}" ]]; then
        local url host user token
        url=$(command git remote get-url origin 2>/dev/null)
        host=${url#*://}; host=${host#*@}; host=${host%%[:/]*}
        user=${GH_HOST_ACCOUNTS[$host]:-}
        if [[ -n "$user" ]]; then
            if token=$(command gh auth token -u "$user" 2>/dev/null) && [[ -n "$token" ]]; then
                GH_TOKEN="$token" command gh "$@"
                return
            fi
            print -u2 -- "gh: no token for $user (gh auth login); using the active account"
        fi
    fi
    command gh "$@"
}

Results after the fix: nounset works, -R and GH_REPO use the active account, xtrace output no longer contains the token, the mapped repo still uses its account, and a missing token warns on stderr.

Tests to add in tests/gh_wrapper_test.py: setopt nounset, -R other/x, GH_REPO, xtrace output without the token, and change test_account_without_token_falls_back so it also checks for the stderr warning.

Nits

  • tests/gh_wrapper_test.py:3: import os is unused.
  • README asks users to edit the tracked functions.zsh to add mappings. custom.zsh is sourced afterwards (.zshrc:63), so personal aliases could live there and stay out of the repo.
  • README could mention that gh auth switch has no effect inside mapped repos, because the wrapper's GH_TOKEN takes precedence.
  • CI lint runs ShellCheck only on bash files, so functions.zsh is checked only by the Python tests.

Security notes (no action needed)

  • Crafted origin: a repo whose remote matches a mapped alias can make gh act as that account, but it cannot send the token to another host. gh still talks to api.github.com, and look-alike hosts (github.com-iamgerwin.evil.example, a path containing the alias) do not match. Any process running as the user can already call gh auth token, so this adds no new capability.
  • The token stays out of argv and out of the calling shell, which an existing test covers. Child processes such as extensions and git inherit GH_TOKEN, the same exposure as setting GH_TOKEN by hand.

- Work under setopt nounset (default-expand GH_TOKEN and the map lookup).
- Skip the mapping when -R/--repo or GH_REPO targets another repository,
  so those calls keep the active account.
- Disable xtrace/verbose inside the function so the token is never traced.
- Warn on stderr when a mapped account has no token instead of silently
  using the active account.
… warning

Each new case fails against the previous wrapper. Also drops an unused
import.
README covers -R/GH_REPO, gh auth switch inside mapped repos, the
missing-token warning and keeping personal aliases in custom.zsh.
CHANGELOG reflects the hardened behavior; learnings/dump.md records why
credential-injecting wrappers must survive caller shell options.
@iamgerwin

Copy link
Copy Markdown
Owner Author

Review round 1 addressed

Pushed 059213e, 8c27edd and 4a594b8 (CI green on Linux and macOS).

Finding Fix Test
1. nounset breaks every call ${GH_TOKEN:-}, ${GH_HOST_ACCOUNTS[$host]:-} (059213e) test_works_under_nounset (mapped and unmapped)
2. -R/--repo/GH_REPO ignored Skip the mapping for -R, -R<repo>, --repo, --repo= and GH_REPO (059213e) test_explicit_repo_skips_the_mapping, test_gh_repo_skips_the_mapping
3. xtrace prints the token setopt localoptions noxtrace noverbose (059213e) test_xtrace_does_not_print_the_token, test_caller_options_are_restored
4. Silent fallback to the active account Warning on stderr (059213e) test_account_without_token_falls_back_with_a_warning, test_unmapped_remote_is_silent
Nit: unused import os Removed (8c27edd) n/a
Nit: personal aliases / gh auth switch README: custom.zsh mappings, gh auth switch precedence, -R/GH_REPO, warning, xtrace (4a594b8) n/a

All 6 new tests fail against the previous wrapper and pass with the fix. bash scripts/check.sh passes locally (40 tests). learnings/dump.md records the lesson: wrappers that inject credentials must survive caller shell options.

Not changed: the ShellCheck CI gap for zsh files (outside this PR's scope).

@iamgerwin

Copy link
Copy Markdown
Owner Author

Review round 2: mergeability and security (head 4a594b8)

Verdict: mergeable after fixes. All round 1 fixes hold: the new tests fail against 228eed0 and pass now, check.sh passes, CI and merge state are clean, and there are no conflicts with main. The round 1 -R/--repo fix introduced a new should-fix issue. Each finding below was reproduced with a fake gh, and the suggested code was checked against every case.

Should-fix

1. Flag values and arguments after -- bypass the mapping (security, identity; functions.zsh, argument loop)
The loop inspects every argument, so free text that looks like -R/--repo turns the mapping off. In a mapped clone, all of these run as the active account:

  • gh issue comment 5 --body "--repo"
  • gh pr create --title "-Rebase cleanup"
  • gh api user --jq --repo
  • gh api -- -R (after --, -R is a positional argument)

Writes then go out under the wrong identity without any warning. The token does not leak.
Fix: stop at -- and skip the values of flags that take free text (see the snippet below). This is a heuristic, so note it in a comment and extend the list as needed.

2. Design decision: a mapped account without a token still falls back to the active account
The warning helps, but gh pr create in a personal clone would still be sent as the work account. Option: fail closed (return 1) for mapped hosts. This is the author's call, so it is not a blocker.

Nits

3. Unset or scalar GH_HOST_ACCOUNTS breaks every gh call. unset GH_HOST_ACCOUNTS; gh api user gives gh:14: bad floating point constant and gh never runs. Fix: guard with [[ ${(t)GH_HOST_ACCOUNTS} == association* ]].
4. Host matching is case-sensitive. SSH host names are case-insensitive, but git@GitHub.com-IamGerwin:… falls back silently. Fix: host=${host:l}.
5. Docs and code disagree. README says to keep personal aliases in custom.zsh, but functions.zsh ships github.com-iamgerwin as the default. Either move it to custom.zsh or say in README that it is the owner's default.
6. Trust in origin: any repo whose origin uses a mapped alias gets that account's token for every gh call, including hooks and scripts that call gh. Fine for personal dotfiles; worth one README line.

Checked, not an issue

  • Real gh resolves the repo from an SSH-alias origin (gh pr view/gh pr list work in mapped clones), so the README example is correct.

Suggested argument loop and map guard (verified on 13 cases)

gh() {
    setopt localoptions noxtrace noverbose
    # Values of these flags are free text; never read them as -R/--repo (heuristic, extend as needed).
    local arg skip=0
    for arg in "$@"; do
        if (( skip )); then skip=0; continue; fi
        case $arg in
            --) break ;;
            -R|--repo|-R?*|--repo=*) command gh "$@"; return ;;
            -b|--body|-t|--title|-F|--body-file|-q|--jq|-f|--field|--raw-field|-H|--header|-X|--method|-m|--message|-l|--label|-a|--assignee|--template) skip=1 ;;
        esac
    done
    if [[ -z "${GH_TOKEN:-}${GH_REPO:-}" ]] && [[ ${(t)GH_HOST_ACCOUNTS} == association* ]]; then
        local url host user token
        url=$(command git remote get-url origin 2>/dev/null)
        host=${url#*://}; host=${host#*@}; host=${host%%[:/]*}; host=${host:l}
        # ...unchanged from here (token lookup, warning, fallback)

Do not declare local -i ... arg: an integer-typed arg breaks the string comparisons in the case.

Results: --body "--repo", --title "-Rebase…", --jq --repo and -- -R keep the mapped account; -R x, -Rx, --repo=x and GH_REPO use the active account; an unset or scalar map falls back without crashing; nounset and xtrace still pass; and a mixed-case host is mapped.

Tests to add: flag values (--body --repo, --title -Rfoo, --jq --repo), -- -R, an unset and a scalar GH_HOST_ACCOUNTS, and a mixed-case host.

- Stop the -R/--repo scan at `--` and skip values of text-taking flags,
  so `--body "--repo"` or `--title "-Rebase"` keep the mapped account.
- Fail closed (error, exit 1) when a mapped account has no token instead
  of running as the active account.
- Guard the map's type, so an unset or scalar GH_HOST_ACCOUNTS no longer
  breaks every gh call, and match host aliases case-insensitively.
- Ship an empty map in functions.zsh; the personal mapping moves to
  custom.zsh, which loads last.
Adds cases for text flag values and `--`, an unset or scalar map, a
mixed-case host, the empty default map, the custom.zsh mapping, and the
fail-closed exit. Tests now set their own mapping. Each new case fails
against the previous head.
README moves the mapping to custom.zsh (with ~/.zshrc.local for aliases
kept out of git), and documents fail-closed behavior, flag-value
handling, case-insensitive hosts and the origin trust model. CHANGELOG
and learnings/dump.md reflect the second review round.
@iamgerwin

Copy link
Copy Markdown
Owner Author

Review round 2 addressed

Pushed 8a5b4e4, 75d6f7c and ce67553.

Finding Fix Test
1. Flag values and -- bypass the mapping Stop at -- and skip values of text-taking flags (8a5b4e4) test_flag_values_and_positionals_keep_the_mapping (--body, --title, --jq, -f, --)
2. Fallback when a mapped account has no token Fail closed: print an error and exit 1, GH_TOKEN overrides (8a5b4e4) test_account_without_token_fails_closed
3. Unset or scalar map crashes Type guard in functions.zsh and custom.zsh (8a5b4e4) test_missing_or_scalar_map_falls_back_without_crashing
4. Case-sensitive host host=${host:l}; keys are lowercase (8a5b4e4) mixed-case remote in test_mapped_alias_uses_its_account
5. Docs and default map disagree functions.zsh ships an empty map; the mapping moves to custom.zsh (8a5b4e4) test_empty_map_by_default, test_custom_zsh_mapping_applies_after_functions
6. Trust in origin README "Trust" note (ce67553) n/a

Correction to round 2: custom.zsh is tracked, so it does not keep aliases out of git. README now points to ~/.zshrc.local (loaded by custom.zsh) for aliases that should stay local.

All new cases fail against 4a594b8 (9 failures) and pass now. bash scripts/check.sh passes locally (44 tests). Live check in a real interactive zsh: a mapped clone runs as iamgerwin, and a work repo uses the active account.

@iamgerwin
iamgerwin merged commit f44ae2d into main Nov 13, 2026
3 checks passed
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.

2 participants