SLOPSHOPPER

secrets

Claude asks for API keys in a masked field above the prompt, saves them to the macOS Keychain, Linux keyring or Windows DPAPI, and uses them in Bash without…

newbandguardcommandtoasttool
★ 1v0.3.1MITupdated 2026-10-07falkoro/claude-code-secrets
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · secrets
› 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(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /secret ⎿ secrets: No secrets saved. Use /secret NAME. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

secrets: stop pasting API keys into Claude Code

A Claude Code mod. When Claude needs a key, it asks in a masked field above your prompt. The key goes to your OS keychain, every Bash command gets it as $NAME, and if it ever shows up in command output Claude sees [secret:NAME] instead.

The masked field above the prompt

▶ Watch the 30 s demo

Install

At the prompt of a Claude Code session:

/plugin install secrets --marketplace falkoro/claude-code-secrets

Answer y to add the marketplace, then pick a scope.

Use

  • Let Claude ask. Say "use my OpenAI key". Claude calls ask_secret, and the field appears above your prompt. Press ctrl+x tab (or click it), paste, press Enter.
  • Or add one yourself: /secret OPENAI_API_KEY. Plain /secret lists the saved names.

From then on, every Bash command Claude runs starts with export OPENAI_API_KEY="$(<keychain lookup>)". The key never appears in the chat, the transcript, the tool call or the command line.

Where keys go

OSStoreRemove a key
macOSlogin Keychain, service claude-codesecurity delete-generic-password -s claude-code -a NAME
LinuxSecret Service (GNOME Keyring, KWallet) via secret-tool (libsecret-tools on Debian/Ubuntu, libsecret on Arch)secret-tool clear service claude-code name NAME
Windows%APPDATA%\claude-code\secrets\NAME, encrypted with DPAPI to your Windows logindelete the file

Limits

  • Redaction hides the exact key in every tool's output (Bash, Read, Grep, …). A transformed key (base64, say) isn't caught.
  • Keys shorter than 8 characters aren't saved (too short to redact), and names that steer the shell (PATH, BASH_ENV, LD_*, GIT_*, …) are refused.
  • Claude Code's text field has no mask option yet, so each keystroke shows for about 15 ms before it becomes a bullet.
  • A mod can't take the keyboard by itself: the field takes keys after ctrl+x tab or a click.
  • I've tested it live on Linux. The macOS and Windows paths are covered by unit tests only so far; issues and PRs welcome.
  • It needs a Claude Code build with mods (function hooks). Built on 2.1.292.

Want this built into Claude Code? 👍 anthropics/claude-code#29910.

Develop

claude --plugin-dir .         # run it from this folder
claude plugin validate .
claude plugin test .

MIT licensed.

Source 2 files
hooks/register.tsx 227 lines
1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register } from 'claude-code'
3
4import type { Ask } from '../types'
5
6const SERVICE = 'claude-code'
7const BULLET = '•'
8// Names end up in a shell line, so only plain env var names get in, and none that steer the shell,
9// the loader or an interpreter: a secret must not become $PATH or $BASH_ENV. Case-blind for Windows.
10const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/
11const RESERVED =
12  /^(PATH|HOME|SHELL|USER|IFS|ENV|PWD|OLDPWD|CDPATH|TERM|TMPDIR|PS[0-4]|PROMPT_COMMAND|SHELLOPTS|BASHOPTS|GLOBIGNORE|NODE_OPTIONS|RUBYOPT|(BASH|LD|DYLD|GIT|LC)_\w*|PYTHON\w*|PERL\w*)$/i
13export const validName = (name: string) => ENV_NAME.test(name) && !RESERVED.test(name)
14// Shorter values can't be redacted without mangling ordinary output.
15const MIN_LENGTH = 8
16
17const asking = atom({ plugin: 'secrets', key: 'asking' } as const, null as Ask | null)
18
19// The typed value and the saved ones live in module memory only:
20// never $.state, the store, the transcript or a tool result.
21let typed = ''
22let answer: 'submit' | 'cancel' | undefined
23let loaded = false
24const values = new Map<string, string>()
25
26type OS = 'linux' | 'mac' | 'windows'
27let os: OS | undefined
28
29const detect = async ($: EngineInterface): Promise<OS> => {
30  if (os) return os
31  if ((await $.env.get('OS')) === 'Windows_NT') return (os = 'windows')
32  return (os = (await $.process.run(['uname'])).stdout.trim() === 'Darwin' ? 'mac' : 'linux')
33}
34
35// Windows has no built-in CLI that reads a credential back, so each secret is a file
36// under %APPDATA% encrypted with DPAPI to the Windows login (what Credential Manager uses).
37const PS = ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command']
38const winFile = (name: string) => `$env:APPDATA\\claude-code\\secrets\\${name}`
39
40// The command that prints a saved secret.
41export const lookup = (os: OS, name: string): string[] =>
42  ({
43    linux: ['secret-tool', 'lookup', 'service', SERVICE, 'name', name],
44    mac: ['security', 'find-generic-password', '-s', SERVICE, '-a', name, '-w'],
45    windows: [...PS, `[Console]::Write([Net.NetworkCredential]::new($null, (Get-Content ${winFile(name)} | ConvertTo-SecureString)).Password)`],
46  })[os]
47
48// The command that saves a secret, and its stdin; the secret never goes on the command line.
49export const store = (os: OS, name: string, secret: string): [string[], string] =>
50  ({
51    linux: [['secret-tool', 'store', `--label=Claude Code: ${name}`, 'service', SERVICE, 'name', name], secret],
52    // ponytail: `security -i` reads the command from stdin, keeping the key out of `ps`; a key holding `"` or `\` relies on its quoting.
53    mac: [['security', '-i'], `add-generic-password -U -s ${SERVICE} -a ${name} -w "${secret.replace(/[\\"]/g, '\\$&')}"\n`],
54    windows: [
55      [...PS, `New-Item -Force ${winFile(name)} | Out-Null; [Console]::In.ReadToEnd() | ConvertTo-SecureString -AsPlainText -Force | ConvertFrom-SecureString | Set-Content ${winFile(name)}`],
56      secret,
57    ],
58  })[os] as [string[], string]
59
60const sh = (arg: string) => (/^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`)
61
62const readSecret = async ($: EngineInterface, name: string) => {
63  const { exitCode, stdout } = await $.process.run(lookup(await detect($), name))
64  return exitCode === 0 ? stdout.replace(/\r?\n$/, '') : ''
65}
66
67// The field shows bullets; recover what the person typed from the edit.
68export const unmask = (real: string, shown: string): string => {
69  let head = 0
70  while (head < shown.length && shown[head] === BULLET) head++
71  let tail = 0
72  while (tail < shown.length - head && shown[shown.length - 1 - tail] === BULLET) tail++
73  // ponytail: a deletion mid-field reads as one at the end; fine for paste-and-enter.
74  const keepHead = Math.min(head, real.length)
75  const keepTail = Math.min(tail, real.length - keepHead)
76  return real.slice(0, keepHead) + shown.slice(head, shown.length - tail) + real.slice(real.length - keepTail)
77}
78
79const redact = (text: string) => {
80  for (const [name, value] of values) if (value.length >= MIN_LENGTH) text = text.split(value).join(`[secret:${name}]`)
81  return text
82}
83
84const scrub = (v: unknown): unknown =>
85  typeof v === 'string' ? redact(v)
86  : Array.isArray(v) ? v.map(scrub)
87  : v && typeof v === 'object' ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, scrub(x)]))
88  : v
89
90const savedNames = async ($: EngineInterface) => ((await $.store.get('names')) as string[] | undefined) ?? []
91
92const load = async ($: EngineInterface) => {
93  if (loaded) return
94  loaded = true
95  for (const name of (await savedNames($)).filter(validName)) {
96    const value = await readSecret($, name)
97    if (value) values.set(name, value)
98  }
99}
100
101const ask = async ($: EngineInterface, env_var: string, reason: string, signal?: AbortSignal): Promise<string> => {
102  typed = ''
103  answer = undefined
104  await update($, asking, () => ({ env_var, reason, length: 0 }))
105  // ponytail: polls with a host `sleep` so the wait stays off the hook's 10 s budget; use a host-side wait if the API grows one.
106  while (!answer && !signal?.aborted) await $.process.run(['sleep', '0.2'])
107  const secret = answer === 'submit' ? typed : ''
108  typed = ''
109  await update($, asking, () => null)
110  if (!secret) return `The user cancelled; ${env_var} was not saved. Do not ask them to paste it into the chat.`
111  if (secret.length < MIN_LENGTH) return `${env_var} was not saved: under ${MIN_LENGTH} characters is too short to hide from output.`
112
113  const [argv, stdin] = store(await detect($), env_var, secret)
114  const stored = await $.process.run(argv, { stdin })
115  // Read it back: `security -i` exits 0 even when its command failed.
116  if ((await readSecret($, env_var)) !== secret) return `Could not save ${env_var} to the keychain (exit ${stored.exitCode}).`
117  values.set(env_var, secret)
118  const names = await savedNames($)
119  if (!names.includes(env_var)) await $.store.set('names', [...names, env_var])
120  $.ui.toast(`🔒 ${env_var} saved to keychain`)
121  return `Saved ${env_var} to the system keychain. Every Bash command now has it as $${env_var}; its value is redacted from Bash output. Never print it.`
122}
123
124export const register: Register = on => {
125  on('session.start', async ($, e, next) => {
126    await $.tool.register({
127      name: 'ask_secret',
128      description:
129        'Ask the user for a secret (API key, token, password) in a masked field and save it to the system keychain. ' +
130        'Use this instead of asking the user to paste a secret into the chat. You never see the value: once saved it is ' +
131        'exported as $<env_var> in every Bash command and redacted from Bash output. Returns at once if already saved.',
132      inputSchema: {
133        type: 'object',
134        properties: {
135          env_var: { type: 'string', description: 'Environment variable name, e.g. OPENAI_API_KEY' },
136          reason: { type: 'string', description: 'One short line telling the user what it is for' },
137          replace: { type: 'boolean', description: 'Ask again even when it is already saved' },
138        },
139        required: ['env_var'],
140      },
141    })
142    await $.command.register({
143      name: 'secret',
144      description: 'Save a secret to the keychain; Bash gets it as $NAME, Claude never sees it',
145      argumentHint: '[NAME]',
146    })
147    return next(e)
148  })
149
150  on('tool.call', { tool: 'mcp__secrets__ask_secret' }, async ($, e, next) => {
151    const { env_var, reason = '', replace = false } = e as unknown as { env_var: string; reason?: string; replace?: boolean }
152    if (!validName(env_var)) return { result: `${env_var} is not a name a secret can take.` }
153    await load($)
154    if (values.has(env_var) && !replace) return { result: `${env_var} is already saved; use $${env_var} in Bash.` }
155    return { result: await ask($, env_var, reason, next.signal) }
156  })
157
158  on('command.run', { command: 'secret' }, async ($, e, next) => {
159    const env_var = e.args.trim()
160    await load($)
161    if (!env_var) {
162      const names = [...values.keys()]
163      return { text: names.length ? `Saved secrets: ${names.map(n => `$${n}`).join(', ')}` : 'No secrets saved. Use /secret NAME.' }
164    }
165    if (!validName(env_var)) return { text: `${env_var} is not a name a secret can take.` }
166    return { text: await ask($, env_var, 'Added with /secret', next.signal) }
167  })
168
169  // Bash gets the keys; every tool's result (Bash, Read, Grep, background output) is scrubbed of them.
170  on('tool.call', async ($, e, next) => {
171    await load($)
172    if (values.size === 0) return next(e)
173    const current = await detect($)
174    const exports = [...values.keys()].map(n => `export ${n}="$(${lookup(current, n).map(sh).join(' ')})"`).join('\n')
175    const ran = await next(e.tool === 'Bash' ? { ...e, command: `${exports}\n${e.command}` } : e)
176    if (ran.deny !== undefined) return redact(ran.deny) === ran.deny ? ran : { deny: redact(ran.deny) }
177    if (JSON.stringify(scrub(ran)) === JSON.stringify(ran)) return ran
178    // Another hook's notes can't be rewritten, only withheld whole.
179    if (ran.context?.some(note => redact(note) !== note)) return { deny: 'secrets: output withheld, a note on it held a saved secret.' }
180    // Core's `text` and `ref` carry the unredacted output: answer with a fresh result, never the object `next` gave.
181    const result = scrub(ran.result)
182    return ran.isError ? { isError: true, result, text: redact(ran.text ?? ''), context: ran.context } : { result, context: ran.context }
183  }).catch(($, e, next) => (next.called ? { deny: 'secrets: redaction failed, output withheld.' } : next(e)))
184
185  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
186    if (e.surface === 'mobile') return next(e)
187    const want = await read($, asking)
188    if (!want) return next(e)
189    return form($, $.ui.resolve(e), want)
190  })
191}
192
193// ponytail: the newest keystroke (or a paste) shows ~1 frame before the bullets redraw; a Client with onKey
194// would never draw it but needs a click for focus. Revisit if Input grows a mask prop.
195const form = (
196  $: EngineInterface,
197  { Box, Text, Input, Button }: Pick<Elements['terminal'], 'Box' | 'Text' | 'Input' | 'Button'>,
198  want: Ask,
199) => (
200  <Box flexDirection="column">
201    <Text bold>
202      🔒 Claude needs {want.env_var}
203      {want.reason ? <Text dimColor> · {want.reason}</Text> : null}
204    </Text>
205    <Box flexDirection="row" gap={2}>
206      <Input
207        key="secret"
208        label={want.env_var}
209        placeholder="paste or type, it stays hidden"
210        value={BULLET.repeat(want.length)}
211        submitLabel="save to keychain"
212        autoFocus
213        onInput={value => {
214          typed = unmask(typed, value)
215          void update($, asking, a => a && { ...a, length: typed.length })
216        }}
217        onSubmit={value => {
218          typed = unmask(typed, value)
219          if (typed) answer = 'submit'
220        }}
221      />
222      <Button key="cancel" label="cancel" onPress={() => void (answer = 'cancel')} />
223    </Box>
224    <Text dimColor>ctrl+x tab or click to type · goes to your system keychain, never to the chat</Text>
225  </Box>
226)
227
types/index.d.ts 7 lines
1export type Ask = { env_var: string; reason: string; length: number }
2declare module 'claude-code' {
3  interface PluginState {
4    secrets: { asking: Ask | null }
5  }
6}
7