Lets Claude ask for a password through a masked field in the Claude Code UI; the value goes to the macOS Keychain, never to the model

Give Claude Code a password without pasting it into the chat.
When a task needs a secret (an API token, a database password), Claude calls request_secret. A box opens above your prompt with a password field. You type the value, press Enter, and it goes straight into your macOS Keychain. Claude only receives a shell snippet that reads the secret inline when a command needs it. The value itself never enters the conversation.
claude-secret:<name> under the account claude-code. The value is written through stdin, never on a command line.[REDACTED:<name>] before it is stored or sent to the model.forget_secret deletes a secret from the Keychain.| Tool | What it does |
|---|---|
mcp__vault-drop__request_secret | { name, reason, overwrite? }: shows the input box; reuses a stored secret unless overwrite is true |
mcp__vault-drop__forget_secret | { name }: deletes the Keychain item |
Claude reads it inline, for example:
SECRET="$(security find-generic-password -a claude-code -s claude-secret:npm_token -w)" npm publish --//registry.npmjs.org/:_authToken="$SECRET"
In auto mode, allow these reads with a permission rule in ~/.claude/settings.json:
{ "permissions": { "allow": ["Bash(security find-generic-password -a claude-code:*)"] } }
security CLI and the login Keychain).MIT
hooks/register.tsx 202 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { SecretRequest } from '../types'
5
6// Secrets live in the macOS login Keychain under this account, one item per name.
7const ACCOUNT = 'claude-code'
8const SERVICE = (name: string) => `claude-secret:${name}`
9const NAME = /^[A-Za-z0-9_.-]{1,64}$/
10const NAMES_KEY = 'names'
11
12const PENDING = { plugin: 'vault-drop', key: 'pending' } as const
13const pending = atom(PENDING, null as SecretRequest | null)
14const REVEALED = { plugin: 'vault-drop', key: 'revealed' } as const
15const revealed = atom(REVEALED, [] as string[])
16const SAVED = /^I saved the secret "([A-Za-z0-9_.-]{1,64})"\./
17
18const usage = (name: string) =>
19 `Stored in the macOS Keychain as "${SERVICE(name)}". You never see the value. ` +
20 `Read it inline where it is needed, never echo, log or write it to a file, e.g.\n` +
21 ` SECRET="$(security find-generic-password -a ${ACCOUNT} -s ${SERVICE(name)} -w)" some-command --password "$SECRET"\n` +
22 `Any tool output containing the value is redacted before you read it.`
23
24async function names($: { store: { get: (key: string) => Promise<unknown> } }) {
25 return ((await $.store.get(NAMES_KEY)) as string[] | undefined) ?? []
26}
27
28export const register: Register = on => {
29 // value -> name, for redaction. Rebuilt from the Keychain on every (re)load.
30 const secrets = new Map<string, string>()
31 // name -> value, only for the reveal button in the chat card; never sent to the model.
32 const values = new Map<string, string>()
33
34 const scrub = (v: unknown): unknown => {
35 if (typeof v === 'string') {
36 let out = v
37 for (const [value, name] of secrets) out = out.split(value).join(`[REDACTED:${name}]`)
38 return out
39 }
40 if (Array.isArray(v)) return v.map(scrub)
41 if (v && typeof v === 'object')
42 return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, scrub(x)]))
43 return v
44 }
45
46 on('session.start', async ($, e, next) => {
47 await $.tool.register({
48 name: 'request_secret',
49 description:
50 'Ask the user for a password, API key or other secret through a masked field in the Claude Code UI. ' +
51 'The value is saved to the macOS Keychain and is NEVER returned to you. This returns at once; ' +
52 'end your turn and wait. When the user saves, you get a message with a shell snippet that reads ' +
53 'the secret inline. Use this instead of asking the user to paste secrets in chat. ' +
54 'Reuses an existing secret of the same name unless overwrite is true.',
55 inputSchema: {
56 type: 'object',
57 properties: {
58 name: { type: 'string', description: 'Short id, e.g. github_token (letters, digits, _ . -)' },
59 reason: { type: 'string', description: 'Shown to the user: what the secret is for' },
60 overwrite: { type: 'boolean', description: 'Ask again even if already stored' },
61 },
62 required: ['name', 'reason'],
63 },
64 })
65 await $.tool.register({
66 name: 'forget_secret',
67 description: 'Delete a secret previously stored with request_secret from the Keychain.',
68 inputSchema: {
69 type: 'object',
70 properties: { name: { type: 'string' } },
71 required: ['name'],
72 },
73 })
74
75 for (const name of await names($)) {
76 const got = await $.process.run(['security', 'find-generic-password', '-a', ACCOUNT, '-s', SERVICE(name), '-w'])
77 const value = got.stdout.replace(/\n$/, '')
78 if (got.exitCode !== 0 || !value) continue
79 values.set(name, value)
80 if (value.length >= 4) secrets.set(value, name)
81 }
82
83 return next(e)
84 })
85
86 on('tool.call', { tool: 'mcp__vault-drop__request_secret' }, async ($, e) => {
87 const { name, reason, overwrite } = e as unknown as { name: string; reason: string; overwrite?: boolean }
88 if (!NAME.test(name)) return { deny: 'name must be 1-64 of letters, digits, _ . -' }
89 if ((await names($)).includes(name) && !overwrite) return { result: usage(name) }
90
91 await $.state.set(PENDING, { name, reason })
92 $.ui.status(`๐ waiting for "${name}"`)
93
94 return {
95 result:
96 `A secure input box is showing above the prompt, asking the user for "${name}". End your turn now and wait: ` +
97 `a message arrives when they save or cancel. Do not ask them to paste it in chat.`,
98 }
99 })
100
101 on('tool.call', { tool: 'mcp__vault-drop__forget_secret' }, async ($, e) => {
102 const { name } = e as unknown as { name: string }
103 if (!NAME.test(name)) return { deny: 'invalid name' }
104 await $.process.run(['security', 'delete-generic-password', '-a', ACCOUNT, '-s', SERVICE(name)])
105 await $.store.set(NAMES_KEY, (await names($)).filter(n => n !== name))
106 for (const [value, n] of secrets) if (n === name) secrets.delete(value)
107 values.delete(name)
108
109 return { result: `Deleted "${name}".` }
110 })
111
112 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
113 const req = await read($, pending)
114 if (!req || (e.surface !== 'desktop' && e.surface !== 'terminal')) return next(e)
115 const { Box, Text, Button, Client } = $.ui.resolve(e)
116
117 const cancel = async () => {
118 await $.state.set(PENDING, null)
119 $.ui.status(undefined)
120 await $.prompt.submit({ text: `I cancelled the secure input for "${req.name}".` })
121 }
122
123 return (
124 <Box flexDirection="column" borderStyle="round" paddingX={2} paddingY={1} gap={1} width="100%">
125 <Box justifyContent="space-between" alignItems="center">
126 <Text bold>๐ Claude needs "{req.name}"</Text>
127 <Button key="cancel" label="Cancel" role="dismiss" onPress={cancel} />
128 </Box>
129 <Text dimColor>{req.reason}</Text>
130 <Client key={`field-${req.name}`} module="./masked-field.tsx" props={{ name: req.name }} />
131 <Text dimColor>Click the field to type ยท ๐ or Tab hides it ยท ctrl+u clears ยท goes straight to your macOS Keychain, Claude never sees it</Text>
132 </Box>
133 )
134 })
135
136 on('ui.message', { component: 'AbovePrompt' }, async ($, e) => {
137 const data = e.data as { type?: string; name?: string; value?: string }
138 const req = await read($, pending)
139 if (data.type !== 'submit' || !req || data.name !== req.name || typeof data.value !== 'string' || !data.value) return {}
140 const { name } = req
141 const value = data.value
142
143 // Value goes in via stdin (twice: enter + retype), never on argv.
144 const saved = await $.process.run(
145 ['security', 'add-generic-password', '-U', '-a', ACCOUNT, '-s', SERVICE(name), '-l', `Claude Code: ${name}`, '-w'],
146 { stdin: `${value}\n${value}\n` },
147 )
148 if (saved.exitCode !== 0) {
149 $.ui.toast(`Keychain refused to store "${name}" (exit ${saved.exitCode})`)
150 return {}
151 }
152
153 values.set(name, value)
154 if (value.length >= 4) secrets.set(value, name)
155 await update($, revealed, list => list.filter(n => n !== name))
156 const known = await names($)
157 if (!known.includes(name)) await $.store.set(NAMES_KEY, [...known, name])
158 await $.state.set(PENDING, null)
159 $.ui.status(undefined)
160 $.ui.toast(`๐ "${name}" saved to Keychain`)
161 await $.prompt.submit({ text: `I saved the secret "${name}". ${usage(name)}` })
162
163 return {}
164 })
165
166 // The "I saved the secret" row draws as a card: masked value, eye to reveal.
167 // The value comes from this module, never from the message the model reads.
168 on('ui.render', { component: 'UserMessage', props: { origin: { kind: 'plugin' } } }, async ($, e, next) => {
169 const name = SAVED.exec(e.props.text)?.[1]
170 if (!name || e.props.isExpanded || (e.surface !== 'desktop' && e.surface !== 'terminal')) return next(e)
171 const { Box, Text, Button } = $.ui.resolve(e)
172 const value = values.get(name)
173 const isShown = value !== undefined && (await read($, revealed)).includes(name)
174 const toggle = () =>
175 update($, revealed, list => (list.includes(name) ? list.filter(n => n !== name) : [...list, name]))
176
177 return (
178 <Box flexDirection="column" borderStyle="round" paddingX={2} paddingY={1} gap={1}>
179 <Text bold>๐ Saved "{name}" to your Keychain</Text>
180 {value === undefined ? (
181 <Text dimColor>No longer stored.</Text>
182 ) : (
183 <Box gap={2} alignItems="center">
184 <Text>{isShown ? value : 'โข'.repeat(Math.min([...value].length, 32))}</Text>
185 <Button key={`eye-${name}`} label={isShown ? '๐ Hide' : '๐ Click to reveal'} plain onPress={toggle} />
186 </Box>
187 )}
188 <Text dimColor>Claude only got a reference to it, never the value.</Text>
189 </Box>
190 )
191 })
192
193 // Last line of defence: no stored row (tool output, prompt, anything) carries a secret's value.
194 on('session.append', ($, e, next) => {
195 if (secrets.size === 0) return next(e)
196 const before = JSON.stringify(e.message.content)
197 const content = scrub(e.message.content) as typeof e.message.content
198
199 return JSON.stringify(content) === before ? next(e) : next({ ...e, message: { ...e.message, content } })
200 })
201}
202hooks/masked-field.tsx 59 lines1import type { ClientModule } from 'claude-code'
2
3// Runs on the drawing thread: keystrokes stay here. Shown in full while typing;
4// the eye button (or Tab) masks it. The value leaves only on Enter, posted
5// straight to the hooks module.
6type State = { value: string; isHidden: boolean }
7
8const MaskedField: ClientModule<{ name: string }, State> = ({ name }, surface) => {
9 const { Box, Text, Button } = surface.elements
10 const set = (patch: Partial<State>) =>
11 surface.setState({ value: '', isHidden: false, ...surface.state, ...patch })
12 const toggle = () => set({ isHidden: !surface.state?.isHidden })
13
14 if (surface.state === undefined) {
15 set({})
16 surface.onKey(k => {
17 const value = surface.state?.value ?? ''
18 if (k.key === 'return') {
19 if (value) surface.post({ type: 'submit', name, value })
20 } else if (k.key === 'tab') {
21 toggle()
22 } else if (k.key === 'backspace' || k.key === 'delete') {
23 set({ value: value.slice(0, -1) })
24 } else if (k.ctrl && k.key === 'u') {
25 set({ value: '' })
26 } else if (!k.ctrl && !k.meta && [...k.key].length === 1) {
27 set({ value: value + k.key })
28 }
29 })
30 }
31
32 const { value = '', isHidden = false } = surface.state ?? {}
33 const length = [...value].length
34 const shown = isHidden ? 'โข'.repeat(length) : value
35
36 return (
37 <Box borderStyle="round" paddingX={1} justifyContent="space-between" alignItems="center">
38 {length ? (
39 <Text>
40 {shown.length > 48 ? `โฆ${shown.slice(-47)}` : shown}
41 <Text dimColor>โ</Text>
42 </Text>
43 ) : (
44 <Text>
45 <Text dimColor>โ</Text>
46 <Text dimColor italic>Password</Text>
47 </Text>
48 )}
49 <Box gap={1} alignItems="center">
50 {length > 0 && <Text dimColor>{length}</Text>}
51 <Button key="eye" label={isHidden ? '๐' : '๐'} plain onPress={toggle} />
52 <Text dimColor>โต save</Text>
53 </Box>
54 </Box>
55 )
56}
57
58export default MaskedField
59types/index.d.ts 8 lines1export type SecretRequest = { name: string; reason: string }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'vault-drop': { pending: SecretRequest | null; revealed: string[] }
6 }
7}
8