Take passwords and tokens in a private input pane instead of the chat: the model never sees the value, which goes only into the commands that run.

A Claude Code mod. It takes passwords, tokens and API keys in a private input pane instead of the chat. Claude (the model) never sees the value: it is put into a Bash or PowerShell command only when that command runs.
At the prompt of a Claude Code terminal session:
/plugin install secret-input --marketplace YouHyuksoo/secret-input
Answer y to Add marketplace?, then pick a scope.
From a shell:
claude plugin install secret-input --marketplace YouHyuksoo/secret-input
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with YouHyuksoo/secret-input, then Add plugin.
request_secret tool. To open it yourself, run /secret DB_PW.'{{secret:DB_PW}}' (inside single quotes) in place of the value. The real value goes in when the command runs.[secret:DB_PW].| Command | What it does |
|---|---|
/secret NAME | Open the input pane for NAME |
/secret | List the saved names (never the values) |
/secret --clear | Remove every saved value |
The pane, toasts and messages are in English by default. To switch to Korean, set the language option to ko: on the options screen at install, or in the /config menu. Changing it reloads the mod, which clears the saved values.
****): the value is visible in the pane until you press Enter.settings.json (PreToolUse and the like) may receive the command with the value in it./secret NAME yourself.MIT. See LICENSE.
hooks/register.tsx 285 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { SecretRequest } from '../types'
5
6const PANE = 'secret-input'
7const TOOL = 'request_secret'
8const OWN_TOOL = 'mcp__secret-input__request_secret'
9const NAME = /^[A-Za-z][A-Za-z0-9_]{0,39}$/
10const PLACEHOLDER = /\{\{secret:([A-Za-z][A-Za-z0-9_]{0,39})\}\}/g
11
12const names = atom({ plugin: 'secret-input', key: 'names' } as const, [] as string[], {
13 shape: 'names-1',
14})
15const request = atom(
16 { plugin: 'secret-input', key: 'request' } as const,
17 null as SecretRequest | null,
18 { shape: 'request-1' },
19)
20
21// Values live in module memory only: any plugin can read $.state, and $.store
22// is plain JSON on disk. So a reload or a restart drops them.
23const secrets = new Map<string, string>()
24
25// The model reads this one; it stays in English whatever the language option.
26const TOOL_DESCRIPTION = [
27 'Ask the user for a secret (password, token, API key) through a private input pane,',
28 'so the value never enters the chat. Use this instead of asking the user to paste a secret in chat.',
29 'It returns at once: end your turn and wait; a message arrives when the user has saved it.',
30 "Then write '{{secret:NAME}}' in a Bash or PowerShell command, always inside single quotes;",
31 'the value is put in when the command runs and masked as [secret:NAME] in the output.',
32 'You never see the value: do not try to print, save or read it back.',
33].join(' ')
34
35const TEXT = {
36 en: {
37 commandDescription: 'Enter a secret in a private pane instead of the chat (/secret NAME, /secret, /secret --clear)',
38 argumentHint: '[NAME|--clear]',
39 paneTitle: (name: string) => `Secret: ${name}`,
40 noneSaved: 'No secrets saved. Run /secret NAME to add one.',
41 savedList: (list: string) => `Saved: ${list}`,
42 cleared: 'All saved secrets were cleared.',
43 badName: (name: string) => `A name starts with a letter and uses only letters, digits and _: ${name}`,
44 opened: (name: string) => `Opened the input pane for ${name}. The value stays out of the chat.`,
45 empty: 'An empty value is not saved.',
46 savedToast: (name: string, length: number) => `${name} saved (${length} characters)`,
47 savedNote: (name: string) =>
48 `[secret-input] ${name} has been entered. Write '{{secret:${name}}}' in a command; the value stays hidden.`,
49 cancelNote: (name: string) => `[secret-input] The user cancelled entering ${name}.`,
50 mobile: 'This surface has no input field. Run /secret NAME in the terminal.',
51 idle: 'No secret was requested. Run /secret NAME to open one.',
52 inputLabel: 'Value ',
53 placeholder: 'Paste here, then Enter',
54 submitLabel: 'save',
55 warning: 'Not sent to the chat or the model. Visible in this pane until Enter.',
56 cancel: 'Cancel',
57 },
58 ko: {
59 commandDescription: '비밀값을 채팅 대신 전용 입력창으로 넣기 (/secret 이름, /secret, /secret --clear)',
60 argumentHint: '[이름|--clear]',
61 paneTitle: (name: string) => `비밀값 입력: ${name}`,
62 noneSaved: '저장된 비밀값이 없어요. /secret 이름 으로 넣으세요.',
63 savedList: (list: string) => `저장됨: ${list}`,
64 cleared: '저장된 비밀값을 모두 지웠어요.',
65 badName: (name: string) => `이름은 영문으로 시작하고 영문·숫자·_ 만 써요: ${name}`,
66 opened: (name: string) => `${name} 입력창을 열었어요. 값은 채팅에 남지 않아요.`,
67 empty: '빈 값은 저장하지 않아요.',
68 savedToast: (name: string, length: number) => `${name} 저장됨 (${length}자)`,
69 savedNote: (name: string) =>
70 `[secret-input] ${name} 입력이 끝났습니다. 명령에 '{{secret:${name}}}' 로 쓰세요. 값은 보이지 않습니다.`,
71 cancelNote: (name: string) => `[secret-input] 사용자가 ${name} 입력을 취소했습니다.`,
72 mobile: '이 화면에는 입력창이 없어요. 터미널에서 /secret 이름 으로 넣으세요.',
73 idle: '요청된 비밀값이 없어요. /secret 이름 으로 여세요.',
74 inputLabel: '값 ',
75 placeholder: '여기에 붙여넣고 Enter',
76 submitLabel: '저장',
77 warning: '채팅·모델에는 보내지 않아요. Enter 전까지 이 창에는 보여요.',
78 cancel: '취소',
79 },
80} as const
81
82// The model writes the placeholder inside single quotes: escape the value per shell.
83const quoteFor = (tool: string, value: string) =>
84 tool === 'PowerShell'
85 ? value.replace(/['\u2018\u2019\u201A\u201B]/g, quote => quote + quote)
86 : value.replace(/'/g, `'\\''`)
87
88// Mask the value and its escaped forms (an error message may repeat the command).
89// Longest first, so a value that holds another one wins.
90const masks = () =>
91 [...secrets]
92 .flatMap(([name, value]) =>
93 [...new Set([value, quoteFor('Bash', value), quoteFor('PowerShell', value)])].map(
94 form => [name, form] as const,
95 ),
96 )
97 .sort((a, b) => b[1].length - a[1].length)
98
99const redactText = (text: string) =>
100 masks().reduce((out, [name, form]) => out.split(form).join(`[secret:${name}]`), text)
101
102const hasSecret = (value: unknown): boolean => {
103 if (typeof value === 'string') return masks().some(([, form]) => value.includes(form))
104 if (Array.isArray(value)) return value.some(hasSecret)
105 if (value !== null && typeof value === 'object') return Object.values(value).some(hasSecret)
106 return false
107}
108
109const redactDeep = (value: unknown): unknown => {
110 if (typeof value === 'string') return redactText(value)
111 if (Array.isArray(value)) return value.map(redactDeep)
112 if (value !== null && typeof value === 'object') {
113 return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redactDeep(item)]))
114 }
115 return value
116}
117
118type Words = (typeof TEXT)[keyof typeof TEXT]
119
120const openPane = ($: EngineInterface, t: Words, name: string) =>
121 $.ui.open({ id: PANE, title: t.paneTitle(name), focus: true, closeOnEscape: true, rows: 7 })
122
123const finish = async ($: EngineInterface, req: SecretRequest, note: string) => {
124 await update($, request, () => null)
125 await $.ui.close({ id: PANE })
126 if (req.isFromModel) await $.prompt.submit({ text: note })
127}
128
129const save = async ($: EngineInterface, t: Words, req: SecretRequest, typed: string) => {
130 const value = typed.replace(/[\r\n]+$/, '')
131 if (value === '') {
132 $.ui.toast(t.empty)
133 return
134 }
135 secrets.set(req.name, value)
136 await update($, names, list => [...new Set([...list, req.name])])
137 $.ui.toast(t.savedToast(req.name, value.length))
138 await finish($, req, t.savedNote(req.name))
139}
140
141const cancel = ($: EngineInterface, t: Words, req: SecretRequest) => finish($, req, t.cancelNote(req.name))
142
143export const register: Register = (on, options) => {
144 const t = options.language === 'ko' ? TEXT.ko : TEXT.en
145
146 on('session.start', async ($, e, next) => {
147 // A reload empties the values, so empty the list of names too.
148 await update($, names, () => [])
149 await $.tool.register({
150 name: TOOL,
151 description: TOOL_DESCRIPTION,
152 isDeferred: false,
153 inputSchema: {
154 type: 'object',
155 properties: {
156 name: { type: 'string', description: 'Letters, digits, _; starts with a letter (DB_PASSWORD)' },
157 reason: { type: 'string', description: 'One line telling the user what it is for' },
158 },
159 required: ['name'],
160 },
161 })
162 await $.command.register({
163 name: 'secret',
164 description: t.commandDescription,
165 argumentHint: t.argumentHint,
166 })
167
168 return next(e)
169 })
170
171 on('command.run', { command: 'secret' }, async ($, e) => {
172 const arg = e.args.trim()
173 if (arg === '') {
174 const list = [...secrets.keys()]
175 return { text: list.length === 0 ? t.noneSaved : t.savedList(list.join(', ')) }
176 }
177 if (arg === '--clear') {
178 secrets.clear()
179 await update($, names, () => [])
180 return { text: t.cleared }
181 }
182 if (!NAME.test(arg)) return { text: t.badName(arg) }
183 // Take over a request the model made for the same name, so it hears back.
184 await update($, request, pending =>
185 pending !== null && pending.name === arg ? pending : { name: arg, reason: '', isFromModel: false },
186 )
187 await openPane($, t, arg)
188
189 return { text: t.opened(arg) }
190 })
191
192 on('tool.call', async ($, e, next) => {
193 if (e.tool === OWN_TOOL) {
194 const name = String(e.name ?? '')
195 const reason = typeof e.reason === 'string' ? e.reason : ''
196 if (!NAME.test(name)) return { deny: `Invalid name "${name}": letters, digits, _; starts with a letter.` }
197 await update($, request, () => ({ name, reason, isFromModel: true }))
198 const opened = await openPane($, t, name)
199 const text = opened.isPlaced
200 ? `The input pane for ${name} is open. End your turn now and wait for the user.`
201 : `The pane could not be shown (${opened.reason}). Ask the user to run /secret ${name}, then wait.`
202 return { result: text }
203 }
204
205 const isShell = e.tool === 'Bash' || e.tool === 'PowerShell'
206 let input = e
207 if (isShell && typeof e.command === 'string') {
208 const used = [...e.command.matchAll(PLACEHOLDER)].map(match => match[1] ?? '')
209 const missing = [...new Set(used.filter(name => !secrets.has(name)))]
210 if (missing.length > 0) {
211 return {
212 deny: `No secret saved for ${missing.join(', ')}. Call ${OWN_TOOL} first, or ask the user to run /secret NAME.`,
213 }
214 }
215 if (used.length > 0) {
216 const command = e.command.replace(PLACEHOLDER, (_, name: string) =>
217 quoteFor(e.tool, secrets.get(name) ?? ''),
218 )
219 input = { ...e, command }
220 }
221 }
222 if (secrets.size === 0) return next(input)
223
224 const ran = await next(input)
225 if (!hasSecret(ran)) return ran
226 if (ran.deny !== undefined) return { deny: redactText(ran.deny) }
227 if (ran.isError) return { deny: redactText(ran.text ?? 'The command failed.') }
228
229 return { result: redactDeep(ran.result) as typeof ran.result }
230 }).catch(($, e, next) =>
231 // When masking fails, withhold the result rather than pass it on.
232 next.called ? { deny: 'secret-input: the result was withheld (masking failed).' } : next(e),
233 )
234
235 // Mask saved values once more in every row the model or the transcript keeps.
236 on('session.append', ($, e, next) => {
237 if (secrets.size === 0 || !hasSecret(e.message.content)) return next(e)
238 return next({ ...e, message: { ...e.message, content: redactDeep(e.message.content) as typeof e.message.content } })
239 }).catch(($, e, next) => next({ ...e, message: { ...e.message, content: [] } }))
240
241 on('ui.close', { id: PANE }, async ($, e, next) => {
242 const req = await read($, request)
243 if (req !== null && e.origin.kind === 'person') {
244 await update($, request, () => null)
245 if (req.isFromModel) await $.prompt.submit({ text: t.cancelNote(req.name) })
246 }
247 return next(e)
248 })
249
250 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
251 const req = await read($, request)
252 const saved = await read($, names)
253 if (e.surface === 'mobile') {
254 const { Text } = $.ui.resolve(e)
255 return <Text dimColor>{t.mobile}</Text>
256 }
257 const { Box, Text, Input, Button } = $.ui.resolve(e)
258 if (req === null) {
259 return (
260 <Box flexDirection="column">
261 <Text dimColor>{t.idle}</Text>
262 {saved.length > 0 && <Text dimColor>{t.savedList(saved.join(', '))}</Text>}
263 </Box>
264 )
265 }
266
267 return (
268 <Box flexDirection="column">
269 <Text bold>{req.name}</Text>
270 {req.reason !== '' && <Text dimColor>{req.reason}</Text>}
271 <Input
272 key="value"
273 label={t.inputLabel}
274 placeholder={t.placeholder}
275 submitLabel={t.submitLabel}
276 autoFocus
277 onSubmit={typed => void save($, t, req, typed)}
278 />
279 <Text dimColor>{t.warning}</Text>
280 <Button key="cancel" label={t.cancel} role="dismiss" onPress={() => void cancel($, t, req)} />
281 </Box>
282 )
283 })
284}
285types/index.d.ts 17 lines1export type SecretRequest = {
2 name: string
3 reason: string
4 /** True when the model asked through request_secret: it hears back once saved */
5 isFromModel: boolean
6}
7
8declare module 'claude-code' {
9 interface PluginState {
10 'secret-input': {
11 /** The names of the saved secrets only; a value is never kept here */
12 names: Shaped<string[]>
13 request: Shaped<SecretRequest | null>
14 }
15 }
16}
17