SLOPSHOPPER

auth-guard

Spots an expired CLI login in tool output and offers a Login button that signs in and retries.

newbandguardtoastprocesstimer
v0.1.0MITupdated 2026-10-02DominickGiordano/claude-mods/auth-guard
A shopper browsing a rack in a slop shop
README

auth-guard

When a Bash call fails on an expired CLI login, auth-guard asks before Claude sees the error:

AWS SSO session expired (profile prod). Log in and retry? [Log in and retry] [Return the error]

It fires only when the command itself runs that provider's CLI and the message is in an errored result or the last 15 lines of output. Reading a file that mentions these errors doesn't trip it.

The login opens your browser (5 minute limit). What happens after depends on the command:

  • Read-only (one simple command such as aws s3 ls, aws ec2 describe-*, gcloud ... list, gh pr view, gh api with GET): it re-runs once and Claude gets the new result.
  • Anything else (chains, pipes, redirects, writes): it logs in and does not re-run. Claude is told the command was not re-run because it may repeat side effects.

If the login fails, Claude gets the original error with the failure appended. Two expired calls at once share one login.

It tells you what to run instead of running it when:

  • the login is infisical login, an interactive TUI;
  • the command names several AWS profiles, or a profile held in a variable;
  • gh got a 401 while GH_TOKEN or GITHUB_TOKEN is set, since that token wins over any login.

With no local terminal or desktop attached (-p, the SDK, mobile), nobody is asked. Claude gets a one-line hint to ask you to run the login.

ProviderSpotted byLogin
AWS SSOToken has expired and refresh failed, Error loading SSO Token, The SSO session associated with this profile has expiredaws sso login [--profile p]
AWS console loginPlease reauthenticate using 'aws login'aws login [--profile p]
gcloud (gcloud, gsutil, bq)Reauthentication required, There was a problem refreshing your current auth tokensgcloud auth login
Infisicalrun [infisical login]you run infisical login
ghHTTP 401: Bad credentials, Try authenticating with: gh auth logingh auth login --web --clipboard

The profile comes from --profile or AWS_PROFILE= in the failed command.

Preflight

Set preflight to a list of providers (aws, gcloud, gh, infisical) to check them when a session starts and every 10 minutes after. Rows show only for providers with a problem:

auth  ✗ aws expired 3m ago [Log in]  ? gh check failed just now [Why]  ? gcloud not installed just now
Source 2 files
hooks/register.tsx 314 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ProcessRunResult, Register, ToolCallResult } from 'claude-code'
3
4import type { Problem } from '../types'
5
6type Signature = {
7  provider: string
8  // The command words that count as invoking this provider.
9  clis: readonly string[]
10  label: string
11  pattern: RegExp
12  login: readonly string[]
13  // infisical login is an interactive TUI; process.run has no terminal to give it.
14  isManual?: true
15}
16
17// "Confirmed" means seen in a real Bash result in ~/.claude/projects transcripts.
18// The rest come from the CLI binaries' own strings and have not been seen firing.
19const SIGNATURES: readonly Signature[] = [
20  // Confirmed: the first and third. Unconfirmed: "The SSO session associated...".
21  { provider: 'aws', clis: ['aws'], label: 'AWS SSO session expired', login: ['aws', 'sso', 'login'],
22    pattern: /Token has expired and refresh failed|The SSO session associated with this profile has expired|Error loading SSO Token/ },
23  // Confirmed, and the most common AWS one: console-credential sessions from `aws login`.
24  { provider: 'aws', clis: ['aws'], label: 'AWS login session expired', login: ['aws', 'login'],
25    pattern: /Please reauthenticate using 'aws login'/ },
26  // Confirmed, both.
27  { provider: 'gcloud', clis: ['gcloud', 'gsutil', 'bq'], label: 'gcloud auth expired', login: ['gcloud', 'auth', 'login'],
28    pattern: /Reauthentication required|There was a problem refreshing your current auth tokens/ },
29  // Unconfirmed: "Your login session has expired. Please run [infisical login]" and
30  // "To login, run [infisical login]" are from the infisical binary.
31  { provider: 'infisical', clis: ['infisical'], label: 'Infisical login expired', login: ['infisical', 'login'], isManual: true,
32    pattern: /run(?:ning)? \[infisical login\]/ },
33  // Unconfirmed: from the gh binary. --clipboard because process.run captures
34  // the device code gh prints, so the user would never see it.
35  { provider: 'gh', clis: ['gh'], label: 'GitHub CLI login invalid', login: ['gh', 'auth', 'login', '--web', '--clipboard'],
36    pattern: /HTTP 401: Bad credentials|(?:authenticating with|please run|log in, run|re-authenticate, run|Re-authenticate with):?\s+gh auth login/ },
37]
38
39const CHECKS: Record<string, readonly string[]> = {
40  aws: ['aws', 'sts', 'get-caller-identity'],
41  gcloud: ['gcloud', 'auth', 'print-access-token'],
42  gh: ['gh', 'auth', 'status'],
43  // A real subcommand (its --help says so); its exit code on an expired session is unconfirmed.
44  infisical: ['infisical', 'login', 'status'],
45}
46
47const READ_ONLY: Record<string, (words: string[], args: string[]) => boolean> = {
48  aws: ([service, op = '']) =>
49    /^(describe|list|get)-/.test(op) || (service === 's3' && op === 'ls') || (service === 'sts' && op === 'get-caller-identity'),
50  gcloud: words => words.some(word => word === 'list' || word === 'describe' || word === 'read'),
51  gh: (words, args) =>
52    words[0] === 'api' ? isGetApi(args) : words[0] === 'status' || ['list', 'view', 'status'].includes(words[1] ?? ''),
53  infisical: ([first, second]) => first === 'export' || (first === 'secrets' && second === 'get'),
54}
55
56// Logins open a browser and wait for the person.
57const LOGIN_TIMEOUT_MS = 5 * 60 * 1000
58const RECHECK_MS = 10 * 60 * 1000
59const RETRY = 'Log in and retry'
60const LOGIN = 'Log in'
61const PREFIXES = new Set(['sudo', 'env', 'time', 'command', 'exec', 'nohup'])
62
63const problems = atom({ plugin: 'auth-guard', key: 'problems' } as const, [] as readonly Problem[])
64const inflight = new Map<string, Promise<ProcessRunResult>>()
65
66type Parsed = { segments: string[][]; isSimple: boolean }
67
68// Words per simple command, quotes removed. Not a shell parser: it only has to
69// tell a real `aws ...` from one quoted inside a grep pattern.
70export function parse(command: string): Parsed {
71  const segments: string[][] = [[]]
72  for (const [token] of command.matchAll(/(?:[^\s'";&|]|'[^']*'|"[^"]*")+|[;&|\n]/g)) {
73    if (/^[;&|\n]$/.test(token)) segments.push([])
74    else segments[segments.length - 1]!.push(token.replace(/'([^']*)'|"([^"]*)"/g, '$1$2'))
75  }
76  // Any operator, substitution or redirect makes it more than one simple command.
77  const isSimple = segments.length === 1 && !/`|\$\(|[<>]/.test(command.replace(/'[^']*'/g, ''))
78  return { segments: segments.filter(words => words.length > 0), isSimple }
79}
80
81function argsOf(words: string[]): string[] {
82  let i = 0
83  while (i < words.length && (PREFIXES.has(words[i]!) || /^\w+=/.test(words[i]!))) i++
84  return words.slice(i)
85}
86
87// Arguments after the CLI that aren't flags. `--flag value` drops its value, so a
88// boolean long flag drops the next word too: that can only hide a read-only verb.
89function positionals(args: string[]): string[] {
90  const out: string[] = []
91  for (let i = 1; i < args.length; i++) {
92    const word = args[i]!
93    if (!word.startsWith('-')) out.push(word)
94    else if (word.startsWith('--') && !word.includes('=')) i++
95  }
96  return out
97}
98
99function isGetApi(args: string[]): boolean {
100  return args.every((word, i) => {
101    // Fields switch `gh api` to POST unless a method says otherwise; treat them as writes.
102    if (/^(-f|-F|--field|--raw-field|--input)/.test(word)) return false
103    const method = word === '-X' || word === '--method' ? args[i + 1] : /^(?:-X|--method=)(.+)$/.exec(word)?.[1]
104    return method === undefined || method.toUpperCase() === 'GET'
105  })
106}
107
108function profileOf(parsed: Parsed): { profile?: string; unsure?: string } {
109  const found = new Set<string>()
110  for (const words of parsed.segments) {
111    words.forEach((word, i) => {
112      const value =
113        /^AWS_PROFILE=(.*)$/.exec(word)?.[1] ?? /^--profile=(.*)$/.exec(word)?.[1] ?? (word === '--profile' ? words[i + 1] : undefined)
114      if (value !== undefined) found.add(value)
115    })
116  }
117  const [profile, ...rest] = [...found]
118  if (profile === undefined) return {}
119  if (rest.length > 0) return { unsure: `the command names several profiles (${[...found].join(', ')})` }
120  if (!/^\w[\w.@+-]*$/.test(profile)) return { unsure: `the profile is ${profile}, not a name` }
121  return { profile }
122}
123
124export function detect(command: string, output: string, isError: boolean) {
125  const parsed = parse(command)
126  // A clean exit only counts when the message is near the end, where a CLI's own error lands.
127  const text = isError ? output : output.split('\n').slice(-15).join('\n')
128  for (const sig of SIGNATURES) {
129    const calls = parsed.segments.map(argsOf).filter(args => sig.clis.includes(args[0]?.split('/').pop() ?? ''))
130    if (calls.length === 0 || !sig.pattern.test(text)) continue
131    const { profile, unsure } = sig.provider === 'aws' ? profileOf(parsed) : {}
132    const call = calls[0]!
133    return {
134      sig,
135      profile,
136      unsure,
137      label: profile ? `${sig.label} (profile ${profile})` : sig.label,
138      argv: profile ? [...sig.login, '--profile', profile] : [...sig.login],
139      isReadOnly: parsed.isSimple && calls.length === 1 && READ_ONLY[sig.provider]!(positionals(call), call),
140    }
141  }
142  return undefined
143}
144
145type Found = NonNullable<ReturnType<typeof detect>>
146type Answered = Exclude<ToolCallResult, { deny: string }>
147
148function withContext(result: Answered, line: string): Answered {
149  return { ...result, context: [...(result.context ?? []), line] }
150}
151
152function tail(text: string): string {
153  return text.trim().split('\n').slice(-3).join(' | ').slice(0, 300)
154}
155
156function ago(ms: number): string {
157  const minutes = Math.floor(ms / 60_000)
158  return minutes < 1 ? 'just now' : minutes < 60 ? `${minutes}m ago` : `${Math.floor(minutes / 60)}h ago`
159}
160
161// Logins open a browser on this machine; only a local terminal or desktop has one.
162async function canOffer($: EngineInterface): Promise<boolean> {
163  return (await $.session.surfaces()).some(surface => surface === 'terminal' || surface === 'desktop')
164}
165
166async function run($: EngineInterface, argv: readonly string[], timeoutMs: number): Promise<ProcessRunResult> {
167  // A missing binary or a timeout rejects; report it as exit -1 with the reason.
168  return $.process.run(argv, { timeoutMs }).catch((err: unknown) => {
169    const stderr = err instanceof Error ? err.message : String(err)
170    return { exitCode: -1, stdout: '', stderr, isStdoutTruncated: false, isStderrTruncated: false }
171  })
172}
173
174// One login per provider at a time; a second expired call waits on the first's.
175function login($: EngineInterface, provider: string, argv: readonly string[]): Promise<ProcessRunResult> {
176  const pending = inflight.get(provider)
177  if (pending) return pending
178  $.ui.toast(provider === 'gh' ? 'auth-guard: opening browser — gh code copied to clipboard' : 'auth-guard: waiting for browser login (up to 5 min)')
179  const started = run($, argv, LOGIN_TIMEOUT_MS).finally(() => inflight.delete(provider))
180  inflight.set(provider, started)
181  return started
182}
183
184// Why auth-guard won't run the login itself, as a sentence for the user and Claude.
185async function manualReason($: EngineInterface, found: Found): Promise<string | undefined> {
186  if (found.unsure) return `${found.unsure}, so it won't guess: run \`${found.sig.login.join(' ')} --profile <name>\` in a terminal`
187  if (found.sig.isManual) return `Run \`${found.argv.join(' ')}\` in a terminal`
188  if (found.sig.provider !== 'gh') return undefined
189  const name = (await $.env.get('GH_TOKEN')) ? 'GH_TOKEN' : (await $.env.get('GITHUB_TOKEN')) ? 'GITHUB_TOKEN' : undefined
190  return name && `${name} is set and wins over any gh login, so logging in won't help: fix or unset it`
191}
192
193async function check($: EngineInterface, provider: string): Promise<Problem | null> {
194  const argv = CHECKS[provider]
195  const checkedAt = await $.clock.now()
196  if (!argv) return { provider, state: 'unknown provider', checkedAt }
197  const result = await run($, argv, 20_000)
198  if (result.exitCode === 0) return null
199  if (result.exitCode === -1 && /ENOENT|not found|no such file/i.test(result.stderr)) return { provider, state: 'not installed', checkedAt }
200  const found = detect(argv.join(' '), `${result.stdout}\n${result.stderr}`, true)
201  if (found?.sig.provider !== provider) {
202    return { provider, state: 'check failed', checkedAt, detail: `exit ${result.exitCode}: ${tail(result.stderr || result.stdout)}` }
203  }
204  if (found.sig.isManual) return { provider, state: 'expired', checkedAt, detail: `run \`${found.argv.join(' ')}\` in a terminal` }
205  return { provider, state: 'expired', checkedAt, login: found.argv }
206}
207
208async function refresh($: EngineInterface, providers: readonly string[]) {
209  if (providers.length === 0 || !(await canOffer($))) return
210  const rows = await Promise.all(providers.map(provider => check($, provider)))
211  await update($, problems, old => [
212    ...old.filter(row => !providers.includes(row.provider)),
213    ...rows.filter(row => row !== null),
214  ])
215}
216
217async function loginFromBand($: EngineInterface, problem: Problem) {
218  if (!problem.login) return
219  const setState = (state: Problem['state']) =>
220    update($, problems, rows => rows.map(row => (row.provider === problem.provider ? { ...row, state } : row)))
221  await setState('logging in')
222  const result = await login($, problem.provider, problem.login)
223  if (result.exitCode !== 0) {
224    $.ui.toast(`auth-guard: ${problem.login.join(' ')} failed: ${tail(result.stderr || result.stdout)}`)
225    await setState('login failed')
226    return
227  }
228  await refresh($, [problem.provider])
229}
230
231export const register: Register = (on, options) => {
232  const providers = (options.preflight ?? []) as readonly string[]
233
234  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
235    // The vendored types declare no built-in tool inputs, so `e.command` is unknown.
236    const command = typeof e.command === 'string' ? e.command : ''
237    const first = await next(e)
238    if (first.deny !== undefined) return first
239    const found = detect(command, first.text ?? '', first.isError === true)
240    if (!found) return first
241
242    const cmd = found.argv.join(' ')
243    if (!(await canOffer($))) {
244      return withContext(first, `auth-guard: ${found.label}; ask the user to run \`${cmd}\` (it is interactive, don't run it yourself)`)
245    }
246    const manual = await manualReason($, found)
247    if (manual) {
248      await $.ui.ask(`${found.label}. ${manual}.`, { header: 'auth', options: ['OK', 'Dismiss'] }).catch(() => undefined)
249      return withContext(first, `auth-guard: ${found.label}. The user was told: ${manual}`)
250    }
251
252    const retry = first.isReadOnly === true || found.isReadOnly
253    if (!inflight.has(found.sig.provider)) {
254      const offer = retry ? RETRY : LOGIN
255      // Rejects when dismissed; that is a decline.
256      const answer = await $.ui
257        .ask(`${found.label}. ${offer}?`, { header: 'auth', options: [offer, 'Return the error'] })
258        .catch(() => undefined)
259      if (answer !== offer) return first
260    }
261    const result = await login($, found.sig.provider, found.argv)
262    if (result.exitCode !== 0) {
263      return withContext(first, `auth-guard: ${found.label}; \`${cmd}\` failed (exit ${result.exitCode}): ${tail(result.stderr || result.stdout)}`)
264    }
265    if (providers.includes(found.sig.provider)) await refresh($, [found.sig.provider])
266    if (!retry) {
267      return withContext(first, `auth-guard: logged in via \`${cmd}\`; command NOT re-run because it may repeat side effects — re-run it if safe`)
268    }
269    const retried = await next(e)
270    if (retried.deny !== undefined) return retried
271    return withContext(retried, `auth-guard: the first run failed (${found.label}); logged in via \`${cmd}\` and ran it again`)
272  })
273
274  on('session.start', async ($, e, next) => {
275    const result = await next(e)
276    if (providers.length === 0) return result
277    // Checks take seconds; timers keep them off the session's start.
278    $.clock.after(0, () => void refresh($, providers))
279    $.clock.every(RECHECK_MS, () => void refresh($, providers))
280    return result
281  })
282
283  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
284    const below = await next(e)
285    const rows = await read($, problems)
286    if (rows.length === 0 || e.props.hasSurvey) return below
287
288    const now = await $.clock.now()
289    const { Box, Text, Button } = $.ui.resolve(e)
290    return (
291      <Box flexDirection="column">
292        <Box>
293          <Text dimColor>auth </Text>
294          {rows.map(row => (
295            <Box key={row.provider}>
296              <Text color={row.state === 'logging in' ? undefined : 'red'}>
297                {' '}
298                {row.state === 'logging in' ? '…' : row.state === 'expired' ? '✗' : '?'} {row.provider} {row.state}{' '}
299              </Text>
300              <Text dimColor>{ago(now - row.checkedAt)} </Text>
301              {row.login && row.state !== 'logging in' ? (
302                <Button key={`login-${row.provider}`} label="Log in" onPress={() => loginFromBand($, row)} />
303              ) : row.detail ? (
304                <Button key={`why-${row.provider}`} label="Why" onPress={() => $.ui.toast(`auth-guard: ${row.provider} ${row.detail}`)} />
305              ) : null}
306            </Box>
307          ))}
308        </Box>
309        {below}
310      </Box>
311    )
312  })
313}
314
types/index.d.ts 16 lines
1export type Problem = {
2  provider: string
3  state: 'expired' | 'check failed' | 'not installed' | 'logging in' | 'login failed' | 'unknown provider'
4  checkedAt: number
5  // A login the band may run; absent when the user has to run it in a terminal.
6  login?: readonly string[]
7  // Shown on press: why the check failed, or what to run.
8  detail?: string
9}
10
11declare module 'claude-code' {
12  interface PluginState {
13    'auth-guard': { problems: readonly Problem[] }
14  }
15}
16