SLOPSHOPPER

gh-account-guard

Asks before a gh or git push call runs on a GitHub account other than the one the repo's owner needs, and offers to switch.

newguardtoastprocess
★ 1v0.1.0MITupdated 2026-10-09jsnkle/ai-native-sdlc/mods/gh-account-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · gh-account-guard
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(rm -rf build && git push --force origin main) ⎿ Denied by gh-account-guard: gh-account-guard did not run this call: the user chose Cancel. gh-account-guar ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

gh-account-guard

A Claude Code mod that stops a gh or git push call from running on the wrong GitHub account by accident. gh auth switch changes the account for every repo at once, and on the wrong account gh doesn't say "wrong account": it says the repo doesn't exist, and a push fails or lands under the wrong name. This mod catches that before the call runs.

What this shows

When Claude runs a shell command (the Bash or Monitor tool) that contains gh or git push, the mod reads every gh and git push in it. For each one it works out which repo owner it targets and which account it will use, and checks them against rules you set.

  • All of them match: the call runs. Nothing is shown.
  • Any of them doesn't: Claude Code's own question dialog asks what to do:
  • Switch to <account> and run runs gh auth switch --user <account>, then runs the call unchanged.
  • Run anyway runs the call as it is.
  • Cancel refuses the call. Claude is told both account names and the gh auth switch --user … to run, and is asked to run it only if you agree.
  • Switch isn't offered in these cases:
  • no rule names an account for the owner;
  • no gh account is active;
  • the call runs with a token;
  • the command switches accounts itself;
  • parts of the command need different accounts.
  • Anything other than the three answers refuses the call: a typed answer (Claude sees it), a dismissed dialog, or a session with nobody to ask (claude -p).

When the mod can't tell, it asks. A false question costs one click; a silent pass is what the mod exists to prevent. It asks when:

  • a remote can't be read, or gives an empty or unreadable URL;
  • git has no push destination for the branch;
  • the command changes remotes, or overrides git config that decides where or as whom it pushes;
  • a call names two owners;
  • the command sets GH_HOST, GH_CONFIG_DIR or an enterprise token;
  • a gh command writes without naming an owner (gh repo create name, gh gist create, gh ssh-key add), or is one it doesn't know;
  • gh api uses graphql, writes without naming an owner, or sends its own Authorization header;
  • gh or git push runs inside bash -c, eval, xargs, find -exec, ssh, $(...) or backticks.

Shell commands without gh or git in them aren't looked at. Commands that never touch an account's data pass: gh auth status, gh --version, gh help, gh config, gh search, gh status, gh repo list, gh api user, a plain gh api GET with no owner, and anything with --help. A mention in echo, grep, a comment or a quoted PR body passes too.

Which repo owner. For each gh or git push in the command:

  1. For gh:
  2. -R owner/repo or --repo owner/repo;
  3. a GitHub URL given as an argument (https://github.com/owner/repo/pull/1);
  4. GH_REPO=owner/repo set on the command or in Claude Code's environment;
  5. gh repo create owner/name, --org on gh secret, gh variable and gh repo fork;
  6. the repos/OWNER, orgs/OWNER and users/OWNER endpoint of gh api.

A URL that ends up as a flag's value is compared with the repo the command runs in, and if they differ the mod asks.

  1. Otherwise, the repo the command runs in:
  2. for gh, git remote get-url origin;
  3. for git push <remote>, every push URL of that remote (remote get-url --push --all);
  4. for a bare git push, the remote git itself would push to (@{push}: the branch's pushRemote, then remote.pushDefault, then the upstream).

A cd dir && earlier in the command, git -C dir, --git-dir and GIT_DIR= are followed.

An SSH host whose name starts with github (git@github-work:owner/repo, the usual alias for a second key) counts as GitHub.

Which account the call will use. In this order:

  1. A token: GH_TOKEN or GITHUB_TOKEN, from Claude Code's environment, set before the command word, or exported earlier in the command. An empty value, env -u and unset take it away. The call runs if the owner is in your tokenOwners list.
  2. For git push only: a clone that resets the credential helpers and brings its own. The repo's local config (or a -c on the command) must have an empty credential.helper entry followed by a helper; then that helper picks the account, not gh, and the mod stands down. A helper added without the reset doesn't count, because git tries the global gh auth git-credential first. Helper settings it can't read make it ask.
  3. An earlier gh auth switch --user X in the same command: later parts are judged as X, but only when they surely run after it (joined by ; or &&). After ||, | or &, after a switch that may itself be skipped (false && gh auth switch …; git push), or after gh auth login/logout, the account is unknown and the mod asks.
  4. The active account in the gh keyring, read with gh config get -h github.com user each time a command is checked. That reads the local gh config, with no network call. It's the value gh auth switch rewrites, so a switch made in another terminal is seen at once.

A git push to an SSH remote isn't checked: it uses your SSH key, not the gh account.

The patterns it demonstrates:

  • Asking the person from inside a tool.call hook with $.ui.ask, the engine's own question dialog. Time spent waiting there doesn't count against the hook's budget, and the call rejects when there is nobody to ask.
  • A guard registered with .catch, so an error inside it refuses the call instead of letting it through.
  • Plugin options from userConfig.

What this is not

A seat belt against accidents, not a security boundary. The risk it covers is Claude running an ordinary gh or git push command while a stale account is active. It does not try to stop a command written to get past it. A text reader can't follow every shell trick: quoting a command word apart (g'h' pr merge), shell functions and aliases, scripts and sourced files, variables used as commands, and heredoc bodies. For a hard block, use permission rules.

Demo

No screenshot yet. The question reads like this (made-up names):

The repo owner some-org needs the GitHub account work-user, but this call would run as my-user. Run git push origin main?

  1. Switch to work-user and run
  2. Run anyway
  3. Cancel

How it was built

  • Model: built with Claude in Claude Code (Claude Opus 5.5). The mod itself doesn't call a model.
  • Prompt(s): a brief from a lead agent: hold any gh … or git push … call, work out which account the target repo needs, compare it with the account the call will use, and on a mismatch offer Switch, Run anyway and Cancel; never slow down ordinary shell calls.
  • Transcript: not shared.
  • Iterations:
  • Reading the active account. The first version used gh auth status and cached the answer, because that command checks each token over the network (about half a second). A cache goes stale when another session switches the global account, which is exactly the case this mod is for. gh config get -h github.com user reads the same answer from the local config in about a tenth of a second, so it's read on every checked command and there's no cache.
  • Asking. The first version drew its own pane and held the call with a sleep loop, as the Blast Radius sample does. That needed a 144-column terminal, a fallback for narrow ones, a switch for headless sessions, and care when the pane was closed by hand. The engine's question dialog ($.ui.ask) does all of that, so the pane is gone.
  • First review (Claude). An independent review found the first version let calls through when it couldn't tell:
  • a failed remote lookup;
  • a gh auth word anywhere in the text;
  • only the first target judged;
  • URLs in flag values;
  • gh api flags before the path;
  • a credential helper that git adds to the global one rather than replacing it.

Each of these now asks.

  • Second review (GPT-6 Astra). A cross-model review found more of the same kind:
  • ownerless writes such as gh repo create;
  • a Switch that would make an earlier part of the command wrong;
  • empty remote output read as "not GitHub";
  • a bare git push assumed to go to origin;
  • -c remote.* overrides;
  • GH_REPO and GH_HOST;
  • a nested bash -c beside a readable target;
  • a quoted gh auth switch read as a real one;
  • a slow pattern on long commands.

The command is now read in one pass that respects quotes, and each finding has a test. The review also argued that no text reader can be safe against a command built to evade it. That is true, and out of scope (see What this is not).

  • Tests. Run with claude plugin test against stand-ins for git, gh and the question dialog (50 tests). Not yet tried in a live session.

Run it

Requirements:

  • Claude Code 2.1.295 or later (the build it was written and tested on).
  • gh and git on your PATH.

Steps:

  1. Install it, at the prompt of a Claude Code terminal session:
   /plugin install gh-account-guard --marketplace jsnkle/ai-native-sdlc

Answer y to add the marketplace, then pick a scope (user scope first). The install screen asks for the options below.

  1. Set the rules. Either through /config, or by hand in ~/.claude/settings.json (plugin options aren't read from a project's .claude/settings.json):
   {
     "pluginConfigs": {
       "gh-account-guard@jsnkle": {
         "options": {
           "rules": "my-user=my-user,my-side-org; work-user=Work-Org",
           "tokenOwners": "my-side-org"
         }
       }
     }
   }

For a copy loaded with claude --plugin-dir, the key is gh-account-guard instead.

  1. Ask Claude to do something with gh or git push in a repo whose owner needs a different account from the active one.

Options:

OptionWhat it holdsDefault
rulesWhich account each repo owner needs: account=owner,owner; account=owner. Rules are separated by ; or a new line, owners by commas. Owners are GitHub users or orgs and are matched without regard to case. Your own user name is an owner too: list it if its repos should run as that account. If an owner is listed twice, the first rule wins.empty
tokenOwnersOwners that a GH_TOKEN or GITHUB_TOKEN may reach, comma-separated. A token call to any other owner is held.empty

Notes / limitations

  • See What this is not: it reads the command text, and it guards against accidents.
  • When several parts of a command have problems, the question describes the first and counts the rest.
  • gh flags are told apart from their values by a short list of flags that take none. An unlisted one takes the next word as its value. A URL taken that way is still compared with the repo the command runs in.
  • Only github.com (and SSH hosts named github…) is checked. Other hosts pass through, and --hostname or GH_HOST for another host makes it ask.
  • Each checked command runs git up to four times and gh config get once, which adds a fraction of a second. Shell commands without gh or git cost nothing.
  • Switch changes the active gh account for every session and every repo, as gh auth switch always does.

Dependencies

NameVersionLicense (SPDX)Source
None

The mod has no packages to install. It calls gh and git, which are already on the machine.

Third-party notices

GitHub is a trademark of GitHub, Inc. Git is a trademark of Software Freedom Conservancy. OpenAI and GPT are trademarks of OpenAI. Use of these names here is descriptive and implies no endorsement.


Shared as-is. No support or maintenance is implied. See the repository's LICENSE.

Source 1 files
hooks/register.tsx 649 lines
1// gh-account-guard: asks before a `gh` or `git push` shell call runs on a
2// GitHub account other than the one the repo's owner needs.
3//
4// tool.call (Bash, Monitor): read every `gh` and `git push` in the command,
5// find the repo owner each one targets and the account it will use (a token,
6// the clone's own credential helper, an earlier `gh auth switch` in the same
7// command, or the active gh keyring account), and compare with the rules from
8// userConfig. On a mismatch, ask in the engine's own question dialog
9// (`$.ui.ask`). The rule throughout: when the mod cannot tell, it asks.
10// It guards against accidents, not against a command written to evade it.
11
12import type { EngineInterface, Register } from 'claude-code'
13
14const RUN = 'Run anyway'
15const CANCEL = 'Cancel'
16const FAILED = 'gh-account-guard: its check failed, so the call was refused. Ask the user how to go on.'
17const HELPER_KEY = '^credential\\.(https://github\\.com/?\\.)?helper$'
18const ENDPOINT = /^\/?(?:repos|orgs|users)\/([^/\s?]+)/
19const OTHER_URL = /^([a-z][a-z0-9+.-]*:\/\/|[^\s/:]+:|\.{0,2}\/|~)/i
20const TOKEN_NAMES = ['GH_TOKEN', 'GITHUB_TOKEN']
21const GH_UNSAFE_ENV = /^(GH_HOST|GH_CONFIG_DIR|GH_ENTERPRISE_TOKEN|GITHUB_ENTERPRISE_TOKEN|GIT_CONFIG.*)$/
22const set = (words: string) => new Set(words.split(' '))
23// gh commands that act on the working folder's repo when no -R is given; ones that never touch an account's data.
24const REPO_SCOPED = set('pr issue repo run workflow release secret variable label cache browse ruleset attestation')
25const GH_FREE = set('auth config completion help version search status')
26// gh flags that take no value, so the next word is not read as theirs; gh api flags that do take one.
27const GH_BOOL = set(
28  '--web -w --fill --draft -d --squash -s --merge -m --rebase -r --admin --auto --delete-branch --yes -y --force ' +
29    '--private --public --internal --clone --push --watch --exit-status --user -u --delete-last --remote',
30)
31const API_VALUE = set('-X --method -H --header -f -F --field --raw-field --input -q --jq -t --template --cache -p --preview --hostname')
32// Words that can come before the real command, options of theirs that take a value, and commands that run another.
33const PREFIXES = set('command exec env nohup time if then elif else while until do ! timeout sudo nice caffeinate')
34const PREFIX_VALUE = set('-u -g -n -s -k -C -D -p -r -t -T -U -S')
35const WRAPPERS = set('bash sh zsh dash ksh fish eval xargs find parallel watch su ssh script')
36const GIT_VALUE = set('-C -c --git-dir --work-tree --namespace --super-prefix --config-env')
37const PUSH_VALUE = set('-o --push-option --receive-pack --exec')
38const XARGS_VALUE = set('-I -n -P -L -d -E -s -a')
39
40/** One gh or git push in the command. `as`: set by an earlier `gh auth switch` (null: to an unknown account). */
41type Target = {
42  kind: 'gh' | 'push'; owner?: string; mention?: string; url?: string; remote?: string; unclear?: string
43  dir: string | null; gitDir?: string; opts: string[]; token: boolean; as?: string | null; remotesChanged: boolean
44}
45type GhTarget = Pick<Target, 'owner' | 'mention' | 'remote' | 'unclear'>
46type Dest = { owner: string; ssh: boolean }
47type Problem =
48  | { kind: 'unclear'; why: string }
49  | { kind: 'token'; owner: string }
50  | { kind: 'account'; owner: string; needs: string | null; uses: string | null; canSwitch: boolean }
51type Config = { rules: Map<string, string>; tokenOwners: Set<string> }
52
53export const register: Register = (on, options) => {
54  const config: Config = {
55    rules: parseRules(String(options.rules ?? '')),
56    tokenOwners: new Set(splitList(String(options.tokenOwners ?? ''))),
57  }
58
59  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
60    const deny = await judge($, e.command, config)
61    return deny === null ? next(e) : { deny }
62  }).catch(($, e, next) => (next.called ? next(e) : { deny: FAILED }))
63
64  on('tool.call', { tool: 'Monitor' }, async ($, e, next) => {
65    const deny = await judge($, e.command ?? '', config)
66    return deny === null ? next(e) : { deny }
67  }).catch(($, e, next) => (next.called ? next(e) : { deny: FAILED }))
68}
69
70// ---- Deciding --------------------------------------------------------------
71
72/** null to run the call, or the reason it was refused. */
73async function judge($: EngineInterface, command: string, config: Config): Promise<string | null> {
74  if ((!command.includes('gh') && !command.includes('git')) || parse(command, new Map()).length === 0) return null
75  const session = new Map<string, string>([
76    ['GH_TOKEN', (await $.env.get('GH_TOKEN')) ?? ''],
77    ['GITHUB_TOKEN', (await $.env.get('GITHUB_TOKEN')) ?? ''],
78    ['GH_REPO', (await $.env.get('GH_REPO')) ?? ''],
79  ])
80  const targets = parse(command, session)
81  const cwd = await $.session.cwd()
82  const home = (await $.env.get('HOME')) ?? '~'
83  const expand = (path: string) => path.replace(/^~(?=\/|$)/, home)
84  let active: string | null | undefined
85  const problems: Problem[] = []
86  const accounts = new Set<string>()
87
88  for (const t of targets) {
89    if (t.unclear !== undefined) {
90      problems.push({ kind: 'unclear', why: t.unclear })
91      continue
92    }
93    const where = ['-C', cwd, ...(t.dir === null ? [] : ['-C', expand(t.dir)]), ...(t.gitDir === undefined ? [] : ['--git-dir', expand(t.gitDir)])]
94    const dests =
95      t.owner !== undefined ? [{ owner: t.owner, ssh: false }]
96      : t.url !== undefined ? destsOf([t.url], t.url)
97      : t.remotesChanged ? 'an earlier part of the command changes the git remotes or their config'
98      : await lookup($, where, t)
99    if (typeof dests === 'string') {
100      problems.push({ kind: 'unclear', why: dests })
101      continue
102    }
103    const other = dests.find(d => t.mention !== undefined && d.owner !== t.mention)
104    if (other !== undefined) {
105      problems.push({ kind: 'unclear', why: `it names the owner ${t.mention} but runs in a repo of ${other.owner}` })
106      continue
107    }
108    let helper: 'own' | 'none' | 'bad' | undefined
109    for (const d of dests) {
110      if (t.kind === 'push' && d.ssh) continue // an SSH push uses an SSH key, not the gh account
111      if (t.token) {
112        if (!config.tokenOwners.has(d.owner)) problems.push({ kind: 'token', owner: d.owner })
113        continue
114      }
115      if (t.kind === 'push') {
116        helper ??= await ownHelper($, where, t.opts)
117        if (helper === 'own') continue // the clone resets the helpers and brings its own
118        if (helper === 'bad') {
119          problems.push({ kind: 'unclear', why: 'could not read the credential helpers git would use' })
120          break
121        }
122      }
123      if (t.as === null) {
124        problems.push({ kind: 'unclear', why: 'an earlier part of the command may change the gh account, to one it cannot name' })
125        continue
126      }
127      if (t.as === undefined && active === undefined) active = await activeAccount($)
128      const uses = t.as ?? active ?? null
129      const needs = config.rules.get(d.owner) ?? null
130      if (needs !== null) accounts.add(needs)
131      if (uses === null || needs === null || needs.toLowerCase() !== uses.toLowerCase()) {
132        problems.push({ kind: 'account', owner: d.owner, needs, uses, canSwitch: needs !== null && uses !== null && t.as === undefined })
133      }
134    }
135  }
136  return problems.length === 0 ? null : decide($, command, problems, accounts)
137}
138
139/** Where a push or a repo-scoped gh call goes, read from the repo it runs in; a string says why it could not tell. */
140async function lookup($: EngineInterface, where: string[], t: Target): Promise<Dest[] | string> {
141  let remote = t.remote
142  if (remote === undefined) {
143    // A bare `git push`: where git itself sends the branch (pushRemote, pushDefault, upstream).
144    const run = await $.process.run(['git', ...where, 'rev-parse', '--abbrev-ref', '@{push}'], { timeoutMs: 10000 })
145    remote = run.exitCode === 0 && !run.isStdoutTruncated ? /^([^/\s]+)\/\S+$/.exec(run.stdout.trim())?.[1] : undefined
146    if (remote === undefined) return 'could not tell where git push sends the current branch'
147  }
148  const argv = ['git', ...where, 'remote', 'get-url', ...(t.kind === 'push' ? ['--push', '--all'] : []), remote]
149  const run = await $.process.run(argv, { timeoutMs: 10000 })
150  if (run.exitCode !== 0 || run.isStdoutTruncated) return `could not read the remote ${remote} of the repo it runs in`
151  return destsOf(run.stdout.split('\n'), remote)
152}
153
154/** The GitHub owners among URLs; a string when one cannot be read or none is given. */
155function destsOf(urls: string[], name: string): Dest[] | string {
156  const lines = urls.map(u => u.trim()).filter(u => u !== '')
157  if (lines.length === 0) return `the remote ${name} gave no URL`
158  const dests: Dest[] = []
159  for (const url of lines) {
160    const owner = urlOwner(url)
161    if (owner !== undefined) {
162      dests.push({ owner, ssh: /^(ssh:|git\+ssh:|[^/:]+:(?!\/\/))/.test(url) })
163    } else if (/github/i.test(url) || !OTHER_URL.test(url)) {
164      return `could not read the remote URL ${url.slice(0, 200)}`
165    }
166  }
167  return dests
168}
169
170/** Asks the user about the first problem; null to run the call, or the reason it was refused. */
171async function decide($: EngineInterface, command: string, problems: Problem[], accounts: Set<string>): Promise<string | null> {
172  const [first] = problems
173  if (first === undefined) return null
174  const others = problems.slice(1)
175  const target = first.kind === 'account' && first.canSwitch ? first.needs : null
176  const same = target !== null && others.every(p => p.kind === 'account' && p.canSwitch && p.needs === target)
177  const account = same && accounts.size <= 1 ? target : null
178  const switchLabel = `Switch to ${account} and run`
179  const labels = account === null ? [RUN, CANCEL] : [switchLabel, RUN, CANCEL]
180  const { what, todo } = explain(first)
181  const more =
182    (others.length === 0 ? '' : ` It also has ${others.length} more ${others.length === 1 ? 'problem' : 'problems'} like this.`) +
183    (accounts.size > 1 ? ` Its parts need different accounts (${[...accounts].join(', ')}), so no one switch makes it all right.` : '')
184  const shown = command.length > 120 ? `${command.slice(0, 117)}...` : command
185
186  let answer = ''
187  try {
188    answer = await $.ui.ask(`${what}${more} Run \`${shown}\`?`, { options: labels, header: 'GitHub' })
189  } catch {
190    answer = '' // dismissed, or nobody to ask (a -p run)
191  }
192  if (answer === RUN) return null
193  if (account !== null && answer === switchLabel) {
194    const run = await $.process.run(['gh', 'auth', 'switch', '--hostname', 'github.com', '--user', account], { timeoutMs: 15000 })
195    if (run.exitCode === 0) {
196      $.ui.toast(`gh-account-guard: switched to ${account}`)
197      return null
198    }
199    return `gh-account-guard did not run this call: switching to ${account} failed (${run.stderr.trim().slice(0, 300)}). ${what} ${todo}`
200  }
201  const outcome =
202    answer === CANCEL ? 'the user chose Cancel' : answer === '' ? 'the question was dismissed or could not be asked' : `the user answered "${answer.slice(0, 200)}"`
203  return `gh-account-guard did not run this call: ${outcome}. ${what}${more} ${todo}`
204}
205
206/** What is wrong, and what the model should do about it. */
207function explain(p: Problem): { what: string; todo: string } {
208  if (p.kind === 'unclear') {
209    return { what: `gh-account-guard could not tell which repo or account this call uses: ${p.why}.`, todo: 'Ask the user how to go on.' }
210  }
211  if (p.kind === 'token') {
212    return {
213      what: `This call runs with a GH_TOKEN/GITHUB_TOKEN token, and the repo owner ${p.owner} is not in the tokenOwners setting.`,
214      todo: 'Run it without the token, or ask the user.',
215    }
216  }
217  if (p.uses === null) {
218    return { what: `No gh account is active, and this call targets the repo owner ${p.owner}.`, todo: 'Ask the user to log in with `gh auth login`.' }
219  }
220  if (p.needs === null) {
221    return { what: `No rule names an account for the repo owner ${p.owner}; this call would run as ${p.uses}.`, todo: 'Ask the user which account to use.' }
222  }
223  return {
224    what: `The repo owner ${p.owner} needs the GitHub account ${p.needs}, but this call would run as ${p.uses}.`,
225    todo: `If the user agrees, run \`gh auth switch --user ${p.needs}\` first, then retry.`,
226  }
227}
228
229/** 'own' when the clone's config (or the command's -c) resets the helpers and then adds its own; 'bad' when unreadable. */
230async function ownHelper($: EngineInterface, where: string[], opts: string[]): Promise<'own' | 'none' | 'bad'> {
231  const extra = opts.flatMap(o => ['-c', o])
232  const run = await $.process.run(['git', ...where, ...extra, 'config', '--show-scope', '--get-regexp', HELPER_KEY], { timeoutMs: 10000 })
233  if (run.exitCode === 1 && run.stdout.trim() === '') return 'none' // no helper configured at all
234  if (run.exitCode !== 0 || run.isStdoutTruncated) return 'bad'
235  let resetBy = ''
236  let after = 0
237  for (const line of run.stdout.split('\n').filter(l => l !== '')) {
238    const m = /^(\w+)\t\S+ ?(.*)$/.exec(line)
239    if (m === null) return 'bad'
240    if ((m[2] ?? '').trim() === '') {
241      resetBy = m[1] ?? ''
242      after = 0
243    } else {
244      after += 1
245    }
246  }
247  return ['local', 'worktree', 'command'].includes(resetBy) && after > 0 ? 'own' : 'none'
248}
249
250/** The active keyring account for github.com: the `user` key `gh auth switch` rewrites; no network. */
251async function activeAccount($: EngineInterface): Promise<string | null> {
252  const run = await $.process.run(['gh', 'config', 'get', '-h', 'github.com', 'user'], { timeoutMs: 10000 })
253  const account = run.exitCode === 0 ? run.stdout.trim() : ''
254  return /^[A-Za-z0-9-]+$/.test(account) ? account : null
255}
256
257// ---- Reading the command ---------------------------------------------------
258
259/** Every gh and git push in the command, in order. `session`: GH_TOKEN, GITHUB_TOKEN and GH_REPO as Claude Code has them. */
260function parse(command: string, session: Map<string, string>): Target[] {
261  const { segments, spans } = lex(command)
262  const targets: Target[] = []
263  const exported = new Map(session)
264  const base = { dir: null, opts: [], token: false, remotesChanged: false }
265  if (spans.some(runsGhIn)) {
266    targets.push({ kind: 'gh', ...base, unclear: 'it runs gh or git push inside $(...) or backticks' })
267  }
268  let dir: string | null = null
269  let as: string | null | undefined
270  let asIsConditional = false
271  let remotesChanged = false
272  for (const { sep, words } of segments) {
273    // A switch holds for later parts only when they surely run after it: joined by ; or &&, never || | or &.
274    if (as !== undefined && (['||', '|', '&'].includes(sep) || (asIsConditional && (sep === ';' || sep === '\n')))) as = null
275    const env = new Map(exported)
276    const i = skipPrefixes(words, env)
277    const isCall = i >= 0 // `command -v gh` only describes it
278    const cmd = baseName(words[i] ?? '')
279    const args = words.slice(i + 1)
280    const unsafe = [...env.keys()].find(name => GH_UNSAFE_ENV.test(name) && env.get(name) !== '')
281    const scope = {
282      dir, as, remotesChanged, opts: [] as string[],
283      token: TOKEN_NAMES.some(name => (env.get(name) ?? '') !== ''),
284      ...(unsafe === undefined ? {} : { unclear: `it sets ${unsafe}` }),
285    }
286    if (!isCall) continue
287    if (cmd === 'cd') {
288      dir = joinDir(dir, args[0])
289    } else if (cmd === 'export' || cmd === 'unset') {
290      for (const a of args) {
291        const m = /^([A-Za-z_]\w*)(?:=([^]*))?$/.exec(a)
292        if (m !== null && (cmd === 'unset' || m[2] !== undefined)) exported.set(m[1] ?? '', cmd === 'unset' ? '' : m[2] ?? '')
293      }
294    } else if (WRAPPERS.has(cmd)) {
295      if (wraps(cmd, args)) targets.push({ kind: 'gh', ...scope, unclear: `it runs gh or git push through ${cmd}` })
296    } else if (cmd === 'gh' && args[0] === 'auth' && ['switch', 'login', 'logout'].includes(args[1] ?? '')) {
297      const user = args[1] === 'switch' ? flagValue(args, '--user', '-u') ?? null : null
298      as = sep === '' || sep === ';' || sep === '\n' || sep === '&&' ? user : null
299      asIsConditional = sep === '&&'
300    } else if (cmd === 'gh') {
301      const target = ghTarget(args, env.get('GH_REPO') ?? '')
302      if (target !== null) targets.push({ kind: 'gh', ...scope, ...target })
303    } else if (cmd === 'git') {
304      const git = gitCall(args, dir, env.get('GIT_DIR'))
305      const sub = git.args[0]
306      if ((sub === 'remote' && ['add', 'set-url', 'rename', 'remove', 'rm'].includes(git.args[1] ?? '')) ||
307          (sub === 'config' && git.args.some(a => /^(remote|url|branch)\./i.test(a)))) {
308        remotesChanged = true
309      } else if (sub === 'push') {
310        const target = pushTarget(git.args.slice(1))
311        if (target !== null) targets.push({ ...scope, unclear: git.unclear ?? scope.unclear, ...target, dir: git.dir, gitDir: git.gitDir, opts: git.opts })
312      }
313    }
314  }
315  return targets
316}
317
318/** What a gh call targets, or null when it touches no account's data. */
319function ghTarget(args: string[], ghRepo: string): GhTarget | null {
320  const sub = args[0]
321  if (sub === undefined || sub.startsWith('-') || GH_FREE.has(sub) || args.includes('--help') || args.includes('-h')) return null
322  const decisive = new Set<string>()
323  const mentioned = new Set<string>()
324  const positional: string[] = []
325  let method = ''
326  let fields = false
327  let org: string | undefined
328  let unclear: string | undefined
329  const addRepo = (value: string, what: string) => {
330    const owner = repoOwner(value)
331    if (owner !== undefined) decisive.add(owner)
332    else unclear ??= `${what} ${value} is not a github.com repo it can read`
333  }
334  for (let i = 1; i < args.length; i += 1) {
335    const a = args[i] ?? ''
336    if (!a.startsWith('-')) {
337      positional.push(a)
338      const owner = urlOwner(a)
339      if (owner !== undefined) decisive.add(owner)
340      continue
341    }
342    const long = a.startsWith('--')
343    const eq = a.indexOf('=')
344    const flag = long ? (eq < 0 ? a : a.slice(0, eq)) : a.slice(0, 2)
345    let value = long ? (eq < 0 ? undefined : a.slice(eq + 1)) : a.length > 2 ? a.slice(2) : undefined
346    if (value === undefined && (sub === 'api' ? API_VALUE.has(flag) : !GH_BOOL.has(flag))) value = args[(i += 1)]
347    if (value === undefined) continue
348    if (flag === '-R' || flag === '--repo') addRepo(value, '-R')
349    else if (flag === '--hostname' && value.toLowerCase() !== 'github.com') unclear ??= `it targets the host ${value}`
350    else if (flag === '-X' || flag === '--method') method = value.toUpperCase()
351    else if (['-f', '-F', '--field', '--raw-field'].includes(flag)) fields = true
352    else if ((flag === '-H' || flag === '--header') && /^\s*authorization\s*:/i.test(value)) unclear ??= 'it sends its own Authorization header'
353    else if (flag === '-o' || flag === '--org') org = value
354    const owner = flag === '--input' ? undefined : urlOwner(value)
355    if (owner !== undefined) mentioned.add(owner)
356  }
357  const [action, first] = positional
358  if (sub === 'repo' && first?.includes('/') === true) addRepo(first, 'the repo')
359  if ((sub === 'secret' || sub === 'variable' || (sub === 'repo' && action === 'fork')) && org !== undefined) decisive.add(org.toLowerCase())
360  let fromRemote = false
361  if (sub === 'api') {
362    for (const word of positional) {
363      const owner = ENDPOINT.exec(word)?.[1]
364      if (owner === '{owner}') fromRemote = true
365      else if (owner !== undefined) decisive.add(owner.toLowerCase())
366    }
367  }
368  if (unclear !== undefined) return { unclear }
369  const all = new Set([...decisive, ...mentioned])
370  if (decisive.size > 1 || all.size > 1) return { unclear: `it names more than one owner (${[...all].join(', ')})` }
371  const [owner] = decisive
372  if (owner !== undefined) return { owner }
373  const mention = [...mentioned][0]
374  const fromEnv = repoOwner(ghRepo)
375  const remote: GhTarget =
376    ghRepo === '' ? { remote: 'origin', mention }
377    : fromEnv !== undefined && (mention === undefined || mention === fromEnv) ? { owner: fromEnv }
378    : { unclear: `GH_REPO ${ghRepo} is not a github.com repo it can read, or names another owner than the command` }
379  if (sub === 'api') {
380    if (fromRemote) return remote
381    if (action === 'graphql') return { unclear: 'a gh api graphql call names no repo' }
382    if (method === 'GET') return null // an explicit GET: fields are query parameters
383    return method !== '' || fields ? { unclear: 'a gh api call that writes names no repo' } : null
384  }
385  if (sub === 'repo' && (action === 'list' || action === 'clone')) return null
386  if (sub === 'repo' && (action === 'create' || action === 'fork')) return { unclear: `gh repo ${action} names no owner, so it would use whichever account is active` }
387  if (sub === 'gist') return action === 'list' || action === 'view' ? null : { unclear: 'gists belong to whichever account is active' }
388  if ((sub === 'secret' || sub === 'variable') && (args.includes('-u') || args.includes('--user'))) {
389    return { unclear: `a user-level ${sub} belongs to whichever account is active` }
390  }
391  return REPO_SCOPED.has(sub) ? remote : { unclear: `gh ${sub} acts for whichever account is active` }
392}
393
394/** git's own options before the subcommand: where it runs, and config that could change the push. */
395function gitCall(args: string[], start: string | null, gitDirEnv: string | undefined) {
396  let dir = start
397  let gitDir = gitDirEnv
398  let unclear: string | undefined
399  const opts: string[] = []
400  let i = 0
401  for (; i < args.length && (args[i] ?? '').startsWith('-'); i += 1) {
402    const a = args[i] ?? ''
403    const eq = a.startsWith('--') ? a.indexOf('=') : -1
404    const name = eq < 0 ? a : a.slice(0, eq)
405    const value = eq >= 0 ? a.slice(eq + 1) : GIT_VALUE.has(name) ? args[(i += 1)] ?? '' : ''
406    if (name === '-C') dir = joinDir(dir, value)
407    if (name === '--git-dir') gitDir = value
408    const key = (value.split('=')[0] ?? '').toLowerCase()
409    if (name === '-c' && key.startsWith('credential.')) opts.push(value) // modelled by ownHelper
410    else if ((name === '-c' || name === '--config-env') && /^(remote|url|branch|credential)\.|pushurl/.test(key)) {
411      unclear = `it overrides the git config ${key}`
412    }
413  }
414  return { args: args.slice(i), dir, gitDir, opts, unclear }
415}
416
417function pushTarget(args: string[]): Pick<Target, 'kind' | 'url' | 'remote' | 'unclear'> | null {
418  let positional: string | undefined
419  let repoFlag: string | undefined
420  for (let j = 0; j < args.length; j += 1) {
421    const a = args[j] ?? ''
422    if (a === '--repo') repoFlag = args[(j += 1)]
423    else if (a.startsWith('--repo=')) repoFlag = a.slice(7)
424    else if (PUSH_VALUE.has(a)) j += 1
425    else if (!a.startsWith('-') && positional === undefined) positional = a
426  }
427  const remote = positional ?? repoFlag
428  if (remote === undefined || /^[\w.-]+$/.test(remote)) return { kind: 'push', remote } // a remote name, or git's own choice
429  if (urlOwner(remote) !== undefined) return { kind: 'push', url: remote }
430  return /github/i.test(remote) || !OTHER_URL.test(remote) ? { kind: 'push', unclear: `could not read the push destination ${remote}` } : null
431}
432
433function flagValue(args: string[], long: string, short: string): string | undefined {
434  for (let i = 0; i < args.length; i += 1) {
435    const a = args[i] ?? ''
436    if (a === long || a === short) return args[i + 1]
437    if (a.startsWith(`${long}=`)) return a.slice(long.length + 1)
438  }
439  return undefined
440}
441
442/** True when a command that runs another (a shell, eval, xargs, find -exec, ssh) would run gh or git push. */
443function wraps(cmd: string, args: string[]): boolean {
444  if (cmd === 'xargs') {
445    let i = 0
446    while ((args[i] ?? '').startsWith('-')) i += XARGS_VALUE.has(args[i] ?? '') ? 2 : 1
447    return runsGh(args.slice(i))
448  }
449  if (cmd === 'find') return args.some((a, i) => /^-(exec|execdir|ok|okdir)$/.test(a) && runsGh(args.slice(i + 1)))
450  // A shell string: read it as a command line of its own.
451  const script = args.findIndex(a => /^-[a-z]*c[a-z]*$/i.test(a))
452  if (['bash', 'sh', 'zsh', 'dash', 'ksh', 'fish', 'su'].includes(cmd) && script >= 0) return runsGhIn(args[script + 1] ?? '')
453  let i = 0
454  while ((args[i] ?? '').startsWith('-')) i += PREFIX_VALUE.has(args[i] ?? '') ? 2 : 1
455  return runsGhIn(args.slice(cmd === 'ssh' ? i + 1 : i).join(' '))
456}
457
458/**
459 * The index of a segment's command word, past VAR=value words and prefixes like sudo, env or timeout
460 * and their options; -1 for `command -v`. Assignments and `env -u`/`-i` are written to `env`.
461 */
462function skipPrefixes(words: string[], env = new Map<string, string>()): number {
463  let i = 0
464  while (i < words.length) {
465    const word = words[i] ?? ''
466    const assign = /^([A-Za-z_]\w*)=([^]*)$/.exec(word)
467    if (assign !== null) {
468      env.set(assign[1] ?? '', assign[2] ?? '')
469      i += 1
470      continue
471    }
472    const prefix = baseName(word)
473    if (!PREFIXES.has(prefix)) break
474    i += 1
475    if (prefix === 'command' && /^-[vV]$/.test(words[i] ?? '')) return -1
476    while ((words[i] ?? '').startsWith('-')) {
477      const flag = words[i] ?? ''
478      if (prefix === 'env' && flag === '-i') TOKEN_NAMES.forEach(name => env.set(name, ''))
479      if (prefix === 'env' && flag === '-u') env.set(words[i + 1] ?? '', '')
480      i += prefix !== 'command' && PREFIX_VALUE.has(flag) ? 2 : 1 // command's own flags take no value
481    }
482    if (prefix === 'timeout') i += 1 // the duration
483  }
484  return i
485}
486
487/** True when a command line runs gh or git push anywhere it can be read, $(...) included. */
488function runsGhIn(text: string): boolean {
489  const { segments, spans } = lex(text)
490  return segments.some(s => runsGh(s.words.slice(Math.max(0, skipPrefixes(s.words))))) || spans.some(runsGhIn)
491}
492
493function runsGh(words: string[]): boolean {
494  const cmd = baseName(words[0] ?? '')
495  return cmd === 'gh' || (cmd === 'git' && words.includes('push')) || (WRAPPERS.has(cmd) && wraps(cmd, words.slice(1)))
496}
497
498function baseName(word: string): string {
499  return word.replace(/^\\/, '').split('/').pop() ?? ''
500}
501
502/** `OWNER/REPO`, `github.com/OWNER/REPO` or a URL to the owner, lower case. */
503function repoOwner(value: string): string | undefined {
504  const parts = value.split('/')
505  if (parts.length === 2 && /^[A-Za-z0-9-]+$/.test(parts[0] ?? '')) return parts[0]?.toLowerCase()
506  if (parts.length === 3 && parts[0]?.toLowerCase() === 'github.com') return parts[1]?.toLowerCase()
507  return urlOwner(value)
508}
509
510/** The owner in a github.com https URL, or an ssh/scp URL whose host starts with `github` (alias hosts too). */
511function urlOwner(value: string): string | undefined {
512  const m =
513    /^https?:\/\/(?:[^@/]+@)?github\.com\/([A-Za-z0-9-]+)\/[\w.-]+/i.exec(value) ??
514    /^(?:git\+)?ssh:\/\/(?:[^@/]+@)?github[\w.-]*(?::\d+)?\/([A-Za-z0-9-]+)\/[\w.-]+/i.exec(value) ??
515    /^(?:[^@/:]+@)?github[\w.-]*:([A-Za-z0-9-]+)\/[\w.-]+/i.exec(value)
516  return m?.[1]?.toLowerCase()
517}
518
519function joinDir(dir: string | null, arg: string | undefined): string | null {
520  if (arg === undefined || arg === '~' || arg.startsWith('/') || arg.startsWith('~/')) return arg ?? '~'
521  return dir === null ? arg : `${dir}/${arg}`
522}
523
524/**
525 * Splits a command into segments of words in one pass: quotes and escapes join a
526 * word, `;` `&&` `||` `|` `&` and new lines end a segment (`sep` is the one before
527 * it), `#` starts a comment, redirections are dropped, and the text of every
528 * `$(...)` and backtick span is kept apart in `spans`.
529 */
530function lex(text: string): { segments: { sep: string; words: string[] }[]; spans: string[] } {
531  const segments: { sep: string; words: string[] }[] = []
532  const spans: string[] = []
533  let words: string[] = []
534  let word = ''
535  let inWord = false
536  let skipWord = false
537  let sep = ''
538  const endWord = () => {
539    if (inWord && !skipWord) words.push(word)
540    if (inWord) skipWord = false
541    word = ''
542    inWord = false
543  }
544  const endSegment = (next: string) => {
545    endWord()
546    const kept = words.filter(w => w !== '{' && w !== '}')
547    if (kept.length > 0) segments.push({ sep, words: kept })
548    if (kept.length > 0 || next !== '\n') sep = next
549    words = []
550  }
551  /** The index just past the span that opens at `start` (`$(` or a backtick). */
552  const span = (start: number): number => {
553    const tick = text[start] === '`'
554    let depth = 0
555    let i = tick ? start + 1 : start + 2
556    for (; i < text.length; i += 1) {
557      const c = text[i]
558      if (c === '\\') i += 1
559      else if (tick && c === '`') break
560      else if (!tick && c === '(') depth += 1
561      else if (!tick && c === ')' && depth-- === 0) break
562    }
563    spans.push(text.slice(tick ? start + 1 : start + 2, i))
564    return i + 1
565  }
566  for (let i = 0; i < text.length; ) {
567    const c = text[i] ?? ''
568    const next = text[i + 1] ?? ''
569    if (c === '\\') {
570      if (next !== '\n') {
571        word += next
572        inWord = true
573      }
574      i += 2
575    } else if (c === "'") {
576      const end = text.indexOf("'", i + 1)
577      word += text.slice(i + 1, end < 0 ? text.length : end)
578      inWord = true
579      i = end < 0 ? text.length : end + 1
580    } else if (c === '"') {
581      inWord = true
582      for (i += 1; i < text.length && text[i] !== '"'; ) {
583        if (text[i] === '\\' && /["\\$`]/.test(text[i + 1] ?? '')) {
584          word += text[i + 1]
585          i += 2
586        } else if ((text[i] === '$' && text[i + 1] === '(') || text[i] === '`') {
587          const end = span(i)
588          word += text.slice(i, end)
589          i = end
590        } else {
591          word += text[i]
592          i += 1
593        }
594      }
595      i += 1
596    } else if ((c === '$' && next === '(') || c === '`') {
597      const end = span(i)
598      word += text.slice(i, end)
599      inWord = true
600      i = end
601    } else if (c === '#' && !inWord) {
602      const end = text.indexOf('\n', i)
603      i = end < 0 ? text.length : end
604    } else if (c === '>' || c === '<' || (c === '&' && next === '>')) {
605      if (/^\d+$/.test(word)) inWord = false // a file descriptor: 2>
606      endWord()
607      while (/[<>&|]/.test(text[i] ?? '')) i += 1
608      skipWord = true // the file or descriptor it redirects to
609    } else if (c === ';' || c === '\n' || c === '&' || c === '|') {
610      const two = c + next
611      const op = two === '&&' || two === '||' ? two : two === '|&' ? '|' : c
612      endSegment(op)
613      i += two === '&&' || two === '||' || two === '|&' ? 2 : 1
614    } else if (c === ' ' || c === '\t' || c === '(' || c === ')') {
615      endWord()
616      i += 1
617    } else {
618      word += c
619      inWord = true
620      i += 1
621    }
622  }
623  endSegment('')
624  return { segments, spans }
625}
626
627// ---- Options ---------------------------------------------------------------
628
629/** `account=owner,owner; account=owner` to a map of owner (lower case) to account. */
630function parseRules(text: string): Map<string, string> {
631  const rules = new Map<string, string>()
632  for (const part of text.split(/[;\n]/)) {
633    const [account, owners] = part.split('=')
634    const name = (account ?? '').trim()
635    if (name === '' || owners === undefined) continue
636    for (const owner of splitList(owners)) {
637      if (!rules.has(owner)) rules.set(owner, name)
638    }
639  }
640  return rules
641}
642
643function splitList(text: string): string[] {
644  return text
645    .split(/[,\s]+/)
646    .map(s => s.trim().toLowerCase())
647    .filter(s => s !== '')
648}
649