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…

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.
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.
ask_secret, and the field appears above your prompt. Press ctrl+x tab (or click it), paste, press Enter./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.
| OS | Store | Remove a key |
|---|---|---|
| macOS | login Keychain, service claude-code | security delete-generic-password -s claude-code -a NAME |
| Linux | Secret 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 login | delete the file |
PATH, BASH_ENV, LD_*, GIT_*, …) are refused.Want this built into Claude Code? 👍 anthropics/claude-code#29910.
claude --plugin-dir . # run it from this folder
claude plugin validate .
claude plugin test .
MIT licensed.
hooks/register.tsx 227 lines1import { 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)
227types/index.d.ts 7 lines1export type Ask = { env_var: string; reason: string; length: number }
2declare module 'claude-code' {
3 interface PluginState {
4 secrets: { asking: Ask | null }
5 }
6}
7