Hand Claude an API token without it reading the value: you type it into a field above the prompt, Claude uses it in Bash as $NAME, and it is redacted from…

A Claude Code mod for handing Claude an API token without pasting it into the chat.
When Claude needs a secret it calls request_secret with a name like GITHUB_TOKEN and a one-line reason. A field appears above the prompt. You type or paste the value there (ctrl+x tab focuses it, Enter saves it, Cancel declines). Claude only learns that the secret is set. It then writes $GITHUB_TOKEN in its Bash commands, and the mod makes the value available to that command.
$NAME or ${NAME}. The command Claude sees and the transcript never contain the value.«secret:NAME».Mods need Claude Code 2.1.287 or later.
This stops accidental exposure: a token pasted into a prompt, echoed in a log, printed by a failing curl -v or saved in the transcript. It does not stop a model that deliberately tries to get the value out, for example by base64-encoding it, sending it over the network or splitting it across outputs. The field shows what you type as plain text, so mind your screen.
A known value typed or pasted into the main prompt is replaced with «secret:NAME» as it lands in the box, and again in every row before the transcript stores it. Still, use the field above the prompt. The prompt box is the fallback, not the way in.
/secrets lists the session allowances; /secrets forget, /secrets clear and entering a new value end them.$NAME. A command that reaches the value some other way gets the normal permission check, and auto mode's classifier may still refuse it.$TMPDIR until the OS clears it./plugin marketplace add crockalet/claude-mods
/plugin install secrets@claude-mods
request_secret and the field appears above the prompt./secrets lists the names set this session and which are allowed without asking. It never shows values./secrets forget NAME removes one, and /secrets clear removes all of them.claude --plugin-dir plugins/secrets # hot-reloads on save
claude plugin validate plugins/secrets
claude plugin test plugins/secretshooks/register.tsx 415 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
3
4import type { SecretApproval, SecretAsk } from '../types'
5
6type $ = EngineInterface
7type Secret = [name: string, value: string]
8type Answer = 'set' | 'declined'
9type Approval = 'once' | 'session' | 'deny'
10
11const TOOL = 'mcp__secrets__request_secret'
12const NAME = /^[A-Z_][A-Z0-9_]*$/
13const WAIT_LIMIT_MS = 30 * 60_000
14const APPROVAL_LIMIT_MS = 10 * 60_000
15const MIN_REDACT = 4
16
17const dirRef = atom({ plugin: 'secrets', key: 'dir' } as const, null)
18const namesRef = atom({ plugin: 'secrets', key: 'names' } as const, [])
19const asksRef = atom({ plugin: 'secrets', key: 'asks' } as const, [])
20const approvalsRef = atom({ plugin: 'secrets', key: 'approvals' } as const, [])
21const allowedRef = atom({ plugin: 'secrets', key: 'allowed' } as const, [])
22
23const GUIDANCE = [
24 `When a task needs a secret (an API token, a password, a key), call ${TOOL} with an env-style name and a one-line reason`,
25 'instead of asking the person to paste it into the chat or to export it in a terminal.',
26 'Once it is set, reference it only as $NAME (or ${NAME}) inside Bash commands: the value is injected there and redacted from output.',
27 'Never echo, print, encode or write the value anywhere, and never read the file it is stored in.',
28 'If a permission check denies a command that uses a secret, report the denial instead of changing settings or permissions.',
29].join(' ')
30
31// A hot reload resets module variables; the files under the state's dir are the source of truth.
32const values = new Map<string, string>()
33
34const str = (value: unknown): string => (typeof value === 'string' ? value : '')
35
36const ensureDir = async ($: $): Promise<string> => {
37 const known = await read($, dirRef)
38 if (known) return known
39 const made = await $.process.run(['mktemp', '-d'])
40 const dir = made.stdout.trim()
41 if (made.exitCode !== 0 || !dir) throw new Error(`mktemp failed: ${made.stderr.trim()}`)
42 await update($, dirRef, () => dir)
43
44 return dir
45}
46
47const store = async ($: $, name: string, value: string) => {
48 const dir = await ensureDir($)
49 // umask before cat, so the file is never readable by others even for a moment.
50 const wrote = await $.process.run(['sh', '-c', 'umask 077 && cat > "$1"', 'sh', `${dir}/${name}`], { stdin: value })
51 if (wrote.exitCode !== 0) throw new Error(`could not save ${name}: ${wrote.stderr.trim()}`)
52 values.set(name, value)
53 await update($, namesRef, list => (list.includes(name) ? list : [...list, name]))
54 await update($, allowedRef, list => list.filter(n => n !== name))
55 for (const run of runAllowed.values()) run.delete(name)
56}
57
58const forget = async ($: $, names: string[]) => {
59 const dir = await read($, dirRef)
60 if (dir && names.length > 0) await $.process.run(['rm', '-f', ...names.map(n => `${dir}/${n}`)])
61 for (const n of names) values.delete(n)
62 await update($, namesRef, list => list.filter(n => !names.includes(n)))
63 await update($, allowedRef, list => list.filter(n => !names.includes(n)))
64 for (const run of runAllowed.values()) for (const n of names) run.delete(n)
65}
66
67const secrets = async ($: $): Promise<Secret[]> => {
68 const dir = await read($, dirRef)
69 if (!dir) return []
70 const found: Secret[] = []
71 for (const name of await read($, namesRef)) {
72 if (!values.has(name)) {
73 const got = await $.process.run(['cat', `${dir}/${name}`])
74 if (got.exitCode === 0) values.set(name, got.stdout)
75 }
76 const value = values.get(name)
77 if (value !== undefined) found.push([name, value])
78 }
79
80 return found
81}
82
83const scrub = (v: unknown, list: Secret[], hits: { n: number }): unknown => {
84 if (typeof v === 'string') {
85 let out = v
86 for (const [name, value] of list) {
87 if (!out.includes(value)) continue
88 hits.n += 1
89 out = out.split(value).join(`«secret:${name}»`)
90 }
91 return out
92 }
93 if (Array.isArray(v)) return v.map(x => scrub(x, list, hits))
94 if (v && typeof v === 'object') return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, scrub(x, list, hits)]))
95
96 return v
97}
98
99// Longest first, so a value that contains another is replaced whole.
100const redactable = (list: Secret[]) => list.filter(([, v]) => v.length >= MIN_REDACT).sort((a, b) => b[1].length - a[1].length)
101
102const redact = (r: ToolCallResult, list: Secret[]): ToolCallResult => {
103 if (r.deny !== undefined || list.length === 0) return r
104 const hits = { n: 0 }
105 const result = scrub(r.result, list, hits)
106 const text = r.text === undefined ? undefined : (scrub(r.text, list, hits) as string)
107 const context = r.context?.map(c => scrub(c, list, hits) as string)
108 if (hits.n === 0) return r
109 if (r.isError) return { deny: text ?? (typeof result === 'string' ? result : JSON.stringify(result)) }
110
111 // Without ref, so core maps the scrubbed result instead of reusing its own messages.
112 return context ? { result, context } : { result }
113}
114
115const mentions = (command: string, name: string) => new RegExp(`\\$\\{?${name}(?![A-Za-z0-9_])`).test(command)
116
117const INJECTED = /^(export [A-Z_][A-Z0-9_]*="\$\(cat '[^']*'\)"; )+/
118
119// tool.check may see the command before or after tool.call added the export prefix.
120const uses = (command: string, name: string) => mentions(command, name) || command.includes(`export ${name}="$(cat '`)
121
122// A state read inside the waiting hook does not see the press's write, so the answer travels through the module.
123const answers = new Map<string, Answer>()
124
125const decide = async ($: $, id: string, answer: Answer) => {
126 answers.set(id, answer)
127 await update($, asksRef, list => list.filter(a => a.id !== id))
128}
129
130const submit = async ($: $, ask: SecretAsk, typed: string) => {
131 const value = typed.trim()
132 if (!value) {
133 $.ui.toast(`Type a value for ${ask.name} first, or press Cancel`)
134 return
135 }
136 await store($, ask.name, value)
137 await decide($, ask.id, 'set')
138}
139
140const approvalAnswers = new Map<string, Approval>()
141// Allow once for a subagent covers the rest of its run, until its turn.complete.
142const runAllowed = new Map<string, Set<string>>()
143
144const coveredByRun = (agentId: string | null, names: string[]) =>
145 agentId !== null && names.every(n => runAllowed.get(agentId)?.has(n))
146
147const answerApproval = async ($: $, approval: SecretApproval, answer: Approval) => {
148 approvalAnswers.set(approval.id, answer)
149 if (answer === 'session') await update($, allowedRef, list => [...new Set([...list, ...approval.names])])
150 if (answer === 'once' && approval.agentId) {
151 runAllowed.set(approval.agentId, new Set([...(runAllowed.get(approval.agentId) ?? []), ...approval.names]))
152 }
153 const allowed = await read($, allowedRef)
154 // Parallel calls from the same run queue several approvals; one answer settles those it now covers.
155 const settled = (await read($, approvalsRef)).filter(
156 a => a.id === approval.id || (answer !== 'deny' && (a.names.every(n => allowed.includes(n)) || coveredByRun(a.agentId, a.names))),
157 )
158 for (const a of settled) if (a.id !== approval.id) approvalAnswers.set(a.id, answer === 'session' ? 'session' : 'once')
159 await update($, approvalsRef, list => list.filter(a => !settled.some(s => s.id === a.id)))
160}
161
162const vars = (names: string[]) => names.map(n => `$${n}`).join(', ')
163
164// The person, not the mode's decider, settles an ask on a command that uses a secret.
165const approve = async ($: $, names: string[], command: string, agentId: string | null, signal: AbortSignal) => {
166 const now = await $.clock.now()
167 const id = `${now}-${Math.random().toString(36).slice(2, 7)}`
168 const shown = command.replace(INJECTED, '')
169 await update($, approvalsRef, list => [...list, { id, names, command: shown.length > 200 ? `${shown.slice(0, 199)}…` : shown, agentId, at: now }])
170 $.ui.toast(`${agentId ? 'A subagent' : 'Claude'} wants to use ${vars(names)}: answer above the prompt`)
171
172 let answer: Approval | undefined
173 while (answer === undefined && !signal.aborted && (await $.clock.now()) - now < APPROVAL_LIMIT_MS) {
174 await $.process.run(['sleep', '1'])
175 answer = approvalAnswers.get(id)
176 }
177 approvalAnswers.delete(id)
178 await update($, approvalsRef, list => list.filter(a => a.id !== id))
179
180 if (answer === 'once' || answer === 'session') return { decision: 'allow' as const, reason: `The person allowed ${vars(names)} for this command.` }
181 if (answer === 'deny') return { decision: 'deny' as const, reason: `The person denied using ${vars(names)} for this command.` }
182
183 return { decision: 'deny' as const, reason: `No answer from the person about using ${vars(names)}${signal.aborted ? '' : ' within 10 minutes'}.` }
184}
185
186const requestSecret = async ($: $, e: Record<string, unknown>, signal: AbortSignal) => {
187 const name = str(e.name).trim()
188 if (!NAME.test(name)) return { deny: 'name must be an env-style name such as GITHUB_TOKEN: A-Z, 0-9 and _, not starting with a digit.' }
189 const why = str(e.why).replace(/\s+/g, ' ').trim().slice(0, 200)
190 const now = await $.clock.now()
191 const id = `${now}-${Math.random().toString(36).slice(2, 7)}`
192 await update($, asksRef, list => [...list, { id, name, why, at: now }])
193 $.ui.toast(`Claude needs ${name}: enter it above the prompt`)
194
195 // Waiting inside the hook counts against its budget; time inside a $ call does not.
196 let answer: Answer | undefined
197 while (answer === undefined && !signal.aborted && (await $.clock.now()) - now < WAIT_LIMIT_MS) {
198 await $.process.run(['sleep', '1'])
199 answer = answers.get(id)
200 }
201 answers.delete(id)
202 await update($, asksRef, list => list.filter(a => a.id !== id))
203
204 if (answer === 'set') return { result: `${name} is set. Use $${name} in Bash commands; its value is injected and redacted from output. Never try to print or read it.` }
205 if (answer === 'declined') return { result: `The person declined to provide ${name}. Continue without it or ask them how to proceed; do not ask for it to be pasted.` }
206
207 return { result: signal.aborted ? `Interrupted before the person entered ${name}.` : `No value for ${name} after 30 minutes.` }
208}
209
210export const register: Register = on => {
211 on('session.start', async ($, e, next) => {
212 const started = await next(e)
213 await $.tool.register({
214 name: 'request_secret',
215 description:
216 'Ask the person for a secret (API token, password, key) through a private field in their UI. Blocks until they enter it or decline. The value never reaches you: afterwards use it as $NAME in Bash commands.',
217 inputSchema: {
218 type: 'object',
219 properties: {
220 name: { type: 'string', description: 'Env-style variable name, e.g. GITHUB_TOKEN' },
221 why: { type: 'string', description: 'One line shown to the person: what it is for' },
222 },
223 required: ['name', 'why'],
224 },
225 })
226 await $.command.register({
227 name: 'secrets',
228 description: 'List the secrets set this session; `/secrets forget NAME` or `/secrets clear` removes them',
229 argumentHint: '[forget NAME | clear]',
230 })
231
232 return started
233 })
234
235 on('session.end', async ($, e, next) => {
236 const dir = await read($, dirRef)
237 if (dir) await $.process.run(['rm', '-rf', dir])
238 values.clear()
239 await update($, dirRef, () => null)
240 await update($, namesRef, () => [])
241 await update($, allowedRef, () => [])
242 runAllowed.clear()
243 await update($, approvalsRef, () => [])
244
245 return next(e)
246 })
247
248 on('prompt.compose', async ($, e, next) => {
249 const composed = await next(e)
250
251 return { sections: [...composed.sections, { id: 'secrets:guidance', text: GUIDANCE, scope: 'session' }] }
252 })
253
254 // prompt.compose carries no agentId, so nothing shows a subagent's system prompt gets the section; its task prompt does.
255 on('agent.spawn', async ($, e, next) => {
256 const names = await read($, namesRef)
257 const provided = names.length > 0 ? ` The person provided ${names.join(', ')} through the secrets mod this session; use ${names.map(n => `$${n}`).join(', ')} in Bash.` : ''
258
259 return next({ ...e, prompt: `${e.prompt}\n\n${GUIDANCE}${provided}` })
260 })
261
262 // The earliest point: a queued prompt's raw text reaches the transcript file before prompt.submit can scrub it.
263 on('prompt.edit', async ($, e, next) => {
264 const list = redactable(await secrets($))
265 if (list.length === 0) return next(e)
266 const hits = { n: 0 }
267 const inputText = scrub(e.inputText, list, hits) as string
268 const r = await next(hits.n > 0 ? { ...e, inputText } : e)
269 // A value typed key by key only shows up whole in the draft it completes.
270 const text = scrub(r.text, list, hits) as string
271 if (hits.n === 0) return r
272 $.ui.toast('secrets: replaced a secret value in your prompt with its name', { timeoutMs: 8000 })
273 if (text === r.text) return r
274 const cursor = Math.max(0, Math.min(text.length, text.length - (r.text.length - r.cursor)))
275
276 // Decorations were laid over the old text's offsets.
277 const { decorations: _, ...box } = r
278
279 return { ...box, text, cursor }
280 })
281
282 on('session.append', async ($, e, next) => {
283 const list = redactable(await secrets($))
284 const hits = { n: 0 }
285 const content = list.length > 0 ? (scrub(e.message.content, list, hits) as typeof e.message.content) : e.message.content
286
287 return next(hits.n > 0 ? { ...e, message: { ...e.message, content } } : e)
288 })
289
290 on('prompt.submit', async ($, e, next) => {
291 const hits = { n: 0 }
292 const text = scrub(e.text, redactable(await secrets($)), hits) as string
293 if (hits.n === 0) return next(e)
294 $.ui.toast('secrets: replaced a secret value in your prompt with its name', { timeoutMs: 8000 })
295
296 return next({ ...e, text })
297 })
298
299 // A subagent's call to a plugin tool is answered only by a hook matched on that tool.
300 on('tool.call', { tool: TOOL }, async ($, e, next) => requestSecret($, e as Record<string, unknown>, next.signal))
301
302 on('tool.call', async ($, e, next) => {
303 if (String(e.tool) === TOOL) return next(e)
304 const dir = await read($, dirRef)
305 if (!dir) return next(e)
306 const tag = dir.split('/').filter(Boolean).pop() ?? dir
307 if (JSON.stringify(e).includes(tag)) {
308 return { deny: 'secrets: that path holds secret values. Reference a secret as $NAME in a Bash command instead of reading its file.' }
309 }
310 const list = await secrets($)
311 if (e.tool !== 'Bash') return redact(await next(e), redactable(list))
312 const used = list.filter(([name]) => mentions(e.command, name))
313 const prefix = used.map(([name]) => `export ${name}="$(cat '${dir}/${name}')"; `).join('')
314
315 return redact(await next(prefix ? { ...e, command: prefix + e.command } : e), redactable(list))
316 }).catch(($, e, next) => (next.called ? next(e) : { deny: 'secrets: its guard failed, so the call was refused.' }))
317
318 on('turn.complete', async ($, e, next) => {
319 if (e.agentId !== undefined) runAllowed.delete(e.agentId)
320
321 return next(e)
322 })
323
324 on('tool.check', { tool: 'Bash' }, async ($, e, next) => {
325 const verdict = await next(e)
326 // An organization's ceiling below allow is not ours to lift.
327 if (verdict.decision !== 'ask' || (e.ceiling !== undefined && e.ceiling !== 'allow')) return verdict
328 const command = str((e.input as { command?: unknown } | null)?.command)
329 const names = (await read($, namesRef)).filter(n => uses(command, n))
330 if (names.length === 0) return verdict
331 const allowed = await read($, allowedRef)
332 if (names.every(n => allowed.includes(n))) return { decision: 'allow', reason: `The person allowed ${vars(names)} for this session.` }
333 if (coveredByRun(e.agentId ?? null, names)) return { decision: 'allow', reason: `The person allowed ${vars(names)} for this subagent's run.` }
334
335 return approve($, names, command, e.agentId ?? null, next.signal)
336 }).catch(() => ({ decision: 'deny', reason: 'secrets: the approval check failed, so the call was refused.' }))
337
338 on('command.run', { command: 'secrets' }, async ($, e) => {
339 const [verb = '', arg = ''] = e.args.trim().split(/\s+/)
340 const names = await read($, namesRef)
341 if (verb === 'clear') {
342 await forget($, names)
343 return { text: names.length > 0 ? `Removed ${names.join(', ')}.` : 'No secrets were set.' }
344 }
345 if (verb === 'forget') {
346 if (!names.includes(arg)) return { text: `No secret named ${arg || '(none given)'}. Set: ${names.join(', ') || 'none'}.` }
347 await forget($, [arg])
348 return { text: `Removed ${arg}.` }
349 }
350 if (names.length === 0) return { text: 'No secrets set this session. Claude asks for one with request_secret.' }
351
352 const allowed = (await read($, allowedRef)).filter(n => names.includes(n))
353 const always = allowed.length > 0 ? ` Allowed without asking this session: ${allowed.join(', ')}.` : ''
354
355 return { text: `Secrets set this session: ${names.join(', ')}. Values are never shown.${always}` }
356 })
357
358 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
359 const waiting = await read($, asksRef)
360 const approvals = await read($, approvalsRef)
361 const ask = waiting[0]
362 const approval = approvals[0]
363 const elements = $.ui.resolve(e)
364 if ((!ask && !approval) || e.props.hasSurvey || !('Input' in elements)) return next(e)
365 const { Box, Text, Button, Input } = elements
366 const queued = waiting.length + approvals.length - 1
367 const more = queued > 0 ? ` ${queued} more waiting.` : ''
368
369 if (!ask && approval) {
370 const who = approval.agentId ? 'A subagent' : 'Claude'
371 const hint = e.surface === 'terminal' ? 'ctrl+x tab to choose. ' : ''
372 return (
373 <Box flexDirection="column">
374 <Text>
375 <Text color="warning">● </Text>
376 {who} wants to use <Text bold>{vars(approval.names)}</Text>
377 </Text>
378 <Text dimColor wrap="truncate-end">
379 {' $ '}
380 {approval.command}
381 </Text>
382 <Text dimColor>
383 {hint}A session allowance lets any command using it run unchecked.{more}
384 </Text>
385 <Box columnGap={1} flexWrap="wrap">
386 <Button key={`approve-${approval.id}-once`} hotkey="1" variant="primary" label={approval.agentId ? 'Allow for this subagent' : 'Allow once'} onPress={() => answerApproval($, approval, 'once')} />
387 <Button key={`approve-${approval.id}-session`} hotkey="2" label={`Allow ${vars(approval.names)} this session`} onPress={() => answerApproval($, approval, 'session')} />
388 <Button key={`approve-${approval.id}-deny`} hotkey="3" label="Deny" onPress={() => answerApproval($, approval, 'deny')} />
389 </Box>
390 </Box>
391 )
392 }
393 if (!ask) return next(e)
394 const isSet = (await read($, namesRef)).includes(ask.name)
395 const hint = e.surface === 'terminal' ? 'ctrl+x tab to type here, Enter to save' : 'Enter to save'
396
397 return (
398 <Box flexDirection="column">
399 <Text>
400 <Text color="warning">● </Text>Claude needs <Text bold>{ask.name}</Text>
401 {ask.why ? ` · ${ask.why}` : ''}
402 </Text>
403 <Text dimColor>
404 {hint}. Shown here as plain text; Claude never sees it.{isSet ? ' Replaces the current value.' : ''}
405 {more}
406 </Text>
407 <Input key={`secret-${ask.id}`} label={`${ask.name}=`} placeholder="paste the value…" submitLabel="save" autoFocus onSubmit={v => submit($, ask, v)} />
408 <Box>
409 <Button key={`secret-${ask.id}-cancel`} label="Cancel" onPress={() => decide($, ask.id, 'declined')} />
410 </Box>
411 </Box>
412 )
413 })
414}
415types/index.d.ts 18 lines1export type SecretAsk = { id: string; name: string; why: string; at: number }
2
3export type SecretApproval = { id: string; names: string[]; command: string; agentId: string | null; at: number }
4
5declare module 'claude-code' {
6 interface PluginState {
7 secrets: {
8 // Values live only in files under dir; state keeps what a hot reload needs to find them.
9 dir: string | null
10 names: string[]
11 asks: SecretAsk[]
12 approvals: SecretApproval[]
13 // Names whose Bash commands skip the approval band until forgotten, replaced or the session ends.
14 allowed: string[]
15 }
16 }
17}
18