SLOPSHOPPER

Vault Drop

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

newbandrowsguardtoaststatus
A shopper browsing a rack in a slop shop
README

Vault Drop

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.

What you get

  • Secure input box above the prompt. Shows what you type; the eye button (or Tab) masks it. Enter saves, ctrl+u clears, Cancel declines.
  • Keychain storage. Each secret is a login Keychain item claude-secret:<name> under the account claude-code. The value is written through stdin, never on a command line.
  • Reveal card in chat. The "saved" message draws as a card with the value masked and a ๐Ÿ‘ button to reveal it. The value is drawn from the plugin's memory, not from anything the model reads.
  • Output redaction. If a secret's value (4+ characters) shows up in any tool output or message, it is replaced with [REDACTED:<name>] before it is stored or sent to the model.
  • forget_secret deletes a secret from the Keychain.

Tools

ToolWhat 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

Using a secret

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:*)"] } }

Requirements and limits

  • macOS (uses the security CLI and the login Keychain).
  • Claude Code with function-hook plugins (2.1.286 or later); the input box needs the desktop app or the terminal.
  • Hiding the value from the model is not the same as hiding it from what Claude does with it: Claude can still run commands that use the secret, so only use it with tasks you trust.
  • Redaction skips values shorter than 4 characters, to avoid blanking out ordinary text.

License

MIT

Source 3 files
hooks/register.tsx 202 lines
1import { 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}
202
hooks/masked-field.tsx 59 lines
1import 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
59
types/index.d.ts 8 lines
1export 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