Pops up when Claude needs an API key or token for a terminal command and injects it without Claude ever seeing it

A Claude Code mod. Claude writes {{secret:NAME}} in a terminal command instead of a key, token or password. The log pops up, you enter the value privately and approve the exact command, the value is injected as $NAME for that one command only, and every output is redacted. Claude never sees the value.
Clone it anywhere, then point Claude Code at the folder.
git clone https://github.com/cesarroger/secret-broker.git
Add an env block to your user settings, ~/.claude/settings.json (Windows: C:\Users\<you>\.claude\settings.json), using the folder's absolute path:
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/Users/you/mods/secret-broker",
"CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
}
}
CLAUDE_CODE_PLUGIN_DIRS loads the mod in every session. On Windows, escape backslashes: "D:\\Mods\\secret-broker". Several mods: separate the paths with : (macOS/Linux) or ; (Windows).CLAUDE_CODE_ENABLE_FUNCTION_HOOKS turns mods on where Claude Code runs in the background, as the desktop app does; without it the mod may not load there.CLAUDE_CODE_PLUGIN_DIR_WATCH for everyday use. It reloads the mod whenever anything in its folder changes (an indexer, a sync client, git), and a reload wipes the mod's memory, including a pending approval and remembered secrets. Use it only while editing the mod.Settings are read when a session starts: open a new session (quit and reopen the app if needed). To turn the mod off, remove those lines.
claude --plugin-dir "/path/to/secret-broker"
In a new session, send /secrets (press Enter; the slash menu may not list it while you type). It should reply secret-broker: nothing remembered this session.
Then try a dummy secret: ask Claude Use the secret broker to run: printf 'the value is %s\n' "{{secret:TEST_KEY}}", enter any value in the pane, and approve. The output should read the value is [redacted:TEST_KEY].
For real use, ask for something that needs a credential, e.g. "log in to the GitHub CLI with my token". The pane titled Secret needed opens in the Claude Code view.
/secrets lists what is remembered; /secrets forget clears it.printenv, bare env / set, export -p, reading /proc/*/environ, referencing $NAME directly, and touching the mod's temp folder are refused./bin/sh with umask 077 (full paths, so a Finder-launched app with a minimal PATH works).sh is not needed on the PATH. Values are written to %USERPROFILE%\.claude\secret-broker\tmp (private to your account), and the command runs in Git Bash, the shell Claude Code's Bash tool uses. Leftover files are blanked, then deleted. A {{secret:…}} sent to the PowerShell tool is refused and redirected to the Bash tool.claude plugin test . runs the tests; claude plugin validate . checks the manifest and hooks.hooks/register.tsx 694 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { SecretRequest } from '../types'
5
6/*
7 * secret-broker
8 *
9 * Claude writes `{{secret:NAME}}` in a Bash command instead of a real key.
10 * This mod pauses the command, asks you for the value in a private pane,
11 * writes it to a 0600 temp file that the command reads into $NAME and deletes
12 * before your command runs, and redacts the value from every tool result
13 * afterwards. Values live only in this module's memory: never in the
14 * transcript, the command text, the session's environment, or $.state.
15 *
16 * macOS and Linux stage through /bin/sh with umask 077. Windows has no sh on
17 * the PATH, so it stages through $.fs into the user's profile folder; the
18 * rewritten command still runs in Git Bash, the Bash tool's shell there.
19 */
20
21const PANE = 'secret-broker'
22const PLACEHOLDER = /\{\{\s*secret:([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g
23const MIN_SCRUB_LENGTH = 4
24
25const request = atom({ plugin: 'secret-broker', key: 'request' } as const, null as SecretRequest | null)
26const nudge = atom({ plugin: 'secret-broker', key: 'nudge' } as const, false)
27
28/** Values remembered for this session, by name. Module memory only. */
29const held = new Map<string, string>()
30/** Values staged for the request on screen. */
31const staged = new Map<string, string>()
32/** Every value used this session, so later outputs can be redacted. */
33const scrubList = new Map<string, string>()
34
35let pending: ((values: Map<string, string> | null) => void) | null = null
36let queue: Promise<unknown> = Promise.resolve()
37
38const RULES = `## Secret broker (installed by the user)
39This session has the secret-broker plugin. It exists so that a terminal command needing an API key, token or password can run without the value entering this conversation. How it works: you write {{secret:NAME}} in the Bash command where the value goes (NAME in env-var style, e.g. OPENAI_API_KEY). Before the command runs, the user sees the exact command in a private pop-up, enters the value there, and approves or declines. The value is loaded into $NAME for that single command only, then removed, and every tool output is redacted ([redacted:NAME]). You never receive the value, and the transcript never contains it.
40The user set this up because they prefer you to run these commands rather than being asked to run them in their own terminal or to paste keys into the chat.
41Writing the command:
42- Use the Bash tool for these commands, on Windows too (it runs Git Bash there). The PowerShell tool does not take the placeholder.
43- Placeholder unquoted or in double quotes, never in single quotes.
44- Token on stdin: printf '%s' "{{secret:GH_TOKEN}}" | gh auth login --with-token
45- Argument or env var: OPENAI_API_KEY="{{secret:OPENAI_API_KEY}}" npm run dev, or some-cli login --token "{{secret:SOME_TOKEN}}"
46- Interactive prompts: pipe the value in, or use the tool's --with-token / --password-stdin / non-interactive flag.
47- Into a .env file when the user wants that: printf 'KEY=%s\\n' "{{secret:KEY}}" >> .env
48- The variable is gone after the command, so a later $NAME is empty: use the placeholder again.
49- If the user declines the pop-up, ask how they want to proceed.
50A step that cannot run non-interactively (a browser OAuth flow, a hardware key tap) is still the user's to do; say so for that step only.`
51
52const BASH_NOTE =
53 '\n\nSecret broker: when the command needs an API key, token or password, write {{secret:NAME}} in its place. The user enters the value in a private pop-up and approves the exact command; it is injected as $NAME for that one command and never enters the conversation. So run such commands here rather than asking the user to run them.'
54
55const POWERSHELL_NOTE =
56 '\n\nSecret broker: a command that needs an API key, token or password goes through the Bash tool instead, with {{secret:NAME}} where the value goes. This tool does not take the placeholder.'
57
58const NUDGE =
59 "Please use the secret broker for that: rewrite the command with {{secret:NAME}} where the key or token goes and run it. I'll enter the value in the pop-up."
60
61const CREDENTIAL_TALK = /\b(api[ _-]?keys?|tokens?|secrets?|passwords?|credentials?|auth|login|log in|\.env)\b/i
62
63const HAND_BACK: RegExp[] = [
64 /\b(run|paste|enter|type|execute|set)\b[^.\n]{0,60}\byourself\b/i,
65 /\byou(?:'ll| will)? (?:need|have|want) to (?:run|paste|enter|type|execute|export|set)\b/i,
66 /\bpaste (?:your|the|it)\b[^.\n]{0,40}\b(?:here|chat|terminal)\b/i,
67 /\b(?:in|from|open) (?:your|a|the) (?:own )?terminal\b/i,
68 /\bexport [A-Z][A-Z0-9_]*=(?:["']?<|["']?your|["']?\.\.\.|["']?xxx)/i,
69]
70
71/** True when an answer hands a credential step back to the user. */
72export function handsBack(answer: string): boolean {
73 return CREDENTIAL_TALK.test(answer) && HAND_BACK.some(re => re.test(answer))
74}
75
76function quote(text: string): string {
77 return `'${text.replaceAll("'", `'\\''`)}'`
78}
79
80function namesIn(command: string): string[] {
81 return [...new Set([...command.matchAll(PLACEHOLDER)].map(m => m[1] ?? ''))].filter(Boolean)
82}
83
84/** Things that would dump the environment or reach the broker's files. */
85function suspicious(command: string, names: string[]): string | undefined {
86 if (/secret-broker[\\/]+tmp/.test(command)) return "reads the secret broker's private files"
87 if (/\/proc\/[^\s]*\/environ/.test(command)) return 'reads a process environment from /proc'
88 // Quoted text is data, not a command, unless something evaluates it.
89 const evaluates = /(^|[\s;&|(`])(eval|(ba|z|da)?sh\s+-[a-z]*c)\b/.test(command)
90 const view = evaluates ? command : command.replace(/'[^']*'|"(?:\\.|[^"\\])*"/g, ' ')
91 if (/(^|[\s;&|(`])(printenv|export\s+-p|declare\s+-[a-zA-Z]*[px]|compgen\s+-[ev])(\s|$|[;&|)`])/.test(view))
92 return 'dumps environment variables'
93 if (/(^|[;&\n(`]|&&|\|\|)\s*(env|set)\s*($|[;&|\n)`])/.test(view)) return 'dumps environment variables'
94 const managed = new Set([...names, ...held.keys(), ...scrubList.keys()])
95 const stripped = command.replace(PLACEHOLDER, '')
96 for (const name of managed) {
97 if (new RegExp(`\\$\\{?${name}\\b`).test(stripped))
98 return `references $${name} directly; use {{secret:${name}}} instead`
99 }
100 return undefined
101}
102
103function scrubText(text: string): string {
104 let out = text
105 for (const [name, value] of scrubList) {
106 if (value.length >= MIN_SCRUB_LENGTH) out = out.split(value).join(`[redacted:${name}]`)
107 }
108 return out
109}
110
111function scrubDeep(value: unknown): unknown {
112 if (typeof value === 'string') return scrubText(value)
113 if (Array.isArray(value)) return value.map(scrubDeep)
114 if (value !== null && typeof value === 'object') {
115 const out: Record<string, unknown> = {}
116 for (const [k, v] of Object.entries(value)) out[k] = scrubDeep(v)
117 return out
118 }
119 return value
120}
121
122function leaks(value: unknown): boolean {
123 if (scrubList.size === 0) return false
124 const text = typeof value === 'string' ? value : JSON.stringify(value) ?? ''
125 for (const secret of scrubList.values()) {
126 if (secret.length >= MIN_SCRUB_LENGTH && text.includes(secret)) return true
127 }
128 return false
129}
130
131type Host = { windows: boolean; tmp: string }
132
133/**
134 * Where staging files go, per platform. Paths use forward slashes: Windows file
135 * calls and Git Bash both read `C:/Users/...`, and the rewritten command runs in
136 * Git Bash there. Windows prefers USERPROFILE, since a HOME inherited from Git
137 * Bash can read `/c/Users/...`, which the mod's own file calls cannot open.
138 */
139async function hostInfo($: EngineInterface): Promise<Host> {
140 const windows = (await $.env.get('OS')) === 'Windows_NT'
141 const home = windows
142 ? ((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')))
143 : ((await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')))
144 const root = (home ?? '.').replaceAll('\\', '/').replace(/\/+$/, '')
145 return { windows, tmp: `${root}/.claude/secret-broker/tmp` }
146}
147
148async function readClipboard($: EngineInterface): Promise<string | undefined> {
149 const { windows } = await hostInfo($)
150 const powershell = ['powershell.exe', '-NoProfile', '-Command', 'Get-Clipboard -Raw']
151 const unix: string[][] = [['pbpaste'], ['wl-paste', '-n'], ['xclip', '-selection', 'clipboard', '-o']]
152 const tries = windows ? [powershell, ...unix] : [...unix, powershell]
153 for (const argv of tries) {
154 try {
155 const { exitCode, stdout } = await $.process.run(argv, { timeoutMs: 5000 })
156 if (exitCode === 0 && stdout.trim()) return stdout.trim()
157 } catch {
158 // that clipboard tool is not on this machine; try the next
159 }
160 }
161 return undefined
162}
163
164async function refresh($: EngineInterface, patch: Partial<SecretRequest>): Promise<void> {
165 await update($, request, current =>
166 current === null
167 ? null
168 : { ...current, ready: [...staged.keys()], remembered: [...held.keys()], ...patch },
169 )
170}
171
172async function finish($: EngineInterface, values: Map<string, string> | null): Promise<void> {
173 const resolve = pending
174 pending = null
175 staged.clear()
176 resolve?.(values)
177 await update($, request, () => null)
178 try {
179 await $.ui.close({ id: PANE })
180 } catch {
181 // already closed
182 }
183}
184
185/** Shows the pane and waits for the person to approve or decline. */
186async function ask($: EngineInterface, command: string, names: string[]): Promise<Map<string, string> | null> {
187 staged.clear()
188 await update($, request, () => ({
189 id: crypto.randomUUID(),
190 command,
191 names,
192 ready: [],
193 remembered: [...held.keys()],
194 remember: true,
195 }))
196 const answer = new Promise<Map<string, string> | null>(resolve => {
197 pending = resolve
198 })
199 let opened
200 try {
201 opened = await $.ui.open({ id: PANE, title: 'Secret needed', focus: true, closeOnEscape: true })
202 } catch {
203 pending = null
204 await update($, request, () => null)
205 return null
206 }
207 if (!opened.isPlaced) $.ui.toast('secret-broker: a command needs a secret; widen the window to see the pane')
208 $.ui.status(`secret-broker: waiting for ${names.join(', ')}`)
209 const values = await answer
210 $.ui.status(undefined)
211 return values
212}
213
214/**
215 * Writes each value to a private temp file; the command reads and deletes it.
216 * Pushes into `files` as it goes, so a failure partway still cleans up.
217 */
218async function stage(
219 $: EngineInterface,
220 host: Host,
221 values: Map<string, string>,
222 files: { name: string; path: string }[],
223): Promise<void> {
224 for (const [name, value] of values) {
225 const path = `${host.tmp}/${crypto.randomUUID()}`
226 files.push({ name, path })
227 if (host.windows) {
228 // No `sh` on the Windows PATH. The profile folder is the user's alone by
229 // its ACL, and the engine writes the file without a shell or argv.
230 try {
231 await $.fs.write(path, value)
232 } catch (error) {
233 throw new Error(`could not stage ${name}: ${String(error)}`)
234 }
235 continue
236 }
237 // The value travels on stdin, never in argv where `ps` could see it.
238 // /bin/sh by full path: a GUI-launched app on macOS has a minimal PATH.
239 const { exitCode, stderr } = await $.process.run(
240 ['/bin/sh', '-c', 'umask 077 && mkdir -p "$1" && cat > "$2"', 'sh', host.tmp, path],
241 { stdin: value, timeoutMs: 10000 },
242 )
243 if (exitCode !== 0) throw new Error(`could not stage ${name}: ${stderr.trim()}`)
244 }
245}
246
247function rewrite(command: string, files: { name: string; path: string }[]): string {
248 const load = files
249 .map(f => `${f.name}="$(cat ${quote(f.path)})"; rm -f ${quote(f.path)}; export ${f.name}`)
250 .join('; ')
251 const body = command.replace(PLACEHOLDER, (_, name: string) => `\${${name}}`)
252 const unload = `__sb_rc=$?; unset ${files.map(f => f.name).join(' ')}; (exit $__sb_rc)`
253 return `${load}\n${body}\n${unload}`
254}
255
256/** Removes staging files the command did not already remove. */
257async function removeFiles($: EngineInterface, host: Host, paths: string[]): Promise<void> {
258 if (paths.length === 0) return
259 if (!host.windows) {
260 try {
261 await $.process.run(['/bin/rm', '-f', ...paths], { timeoutMs: 5000 })
262 } catch {
263 // the command already removed them
264 }
265 return
266 }
267 for (const path of paths) {
268 try {
269 if (!(await $.fs.exists(path))) continue
270 // Blank it first, so the value is gone even if the delete fails.
271 await $.fs.write(path, '')
272 await $.process.run(['cmd.exe', '/d', '/c', 'del', '/f', '/q', path.replaceAll('/', '\\')], { timeoutMs: 5000 })
273 } catch {
274 // the command already removed it, or it is blank now
275 }
276 }
277}
278
279async function cleanup($: EngineInterface, host: Host, files: { path: string }[]): Promise<void> {
280 await removeFiles($, host, files.map(f => f.path))
281}
282
283/** Clears files a crash or reload left behind before their command ran. */
284async function sweep($: EngineInterface): Promise<void> {
285 const host = await hostInfo($)
286 try {
287 if (!(await $.fs.exists(host.tmp))) return
288 // Older than a minute only: a reload mid-command must not take its file.
289 const cutoff = (await $.clock.now()) - 60_000
290 const left = (await $.fs.list(host.tmp))
291 .filter(f => f.kind === 'file' && f.mtimeMs < cutoff)
292 .map(f => `${host.tmp}/${f.name}`)
293 await removeFiles($, host, left)
294 } catch {
295 // nothing to sweep
296 }
297}
298
299/** How long the tool.call hook waits inline before handing the wait to the pane. */
300const INLINE_WAIT_MS = 7000
301
302/**
303 * Values the person approved for one exact command after the hook had already
304 * answered. Claude is asked to issue that command again; the hook then finds
305 * the approval here and runs it inline, under the usual permission check.
306 */
307const preapproved = new Map<string, Map<string, string>>()
308
309function shorten(command: string): string {
310 return command.length > 300 ? `${command.slice(0, 300)}…` : command
311}
312
313/** The slow path's ending: tell Claude what the person decided. */
314function reportDecision($: EngineInterface, command: string, names: string[], values: Map<string, string> | null): void {
315 const list = names.join(', ')
316 if (values === null) {
317 void $.prompt.submit({
318 text: `secret-broker: the user declined to provide ${list} for this command, so it did not run:\n\`\`\`\n${shorten(command)}\n\`\`\`\nAsk them how they want to proceed.`,
319 })
320 return
321 }
322 preapproved.clear()
323 preapproved.set(command, values)
324 void $.prompt.submit({
325 text: `secret-broker: the user approved ${list}. Run this exact command again now, character for character; it will go through without asking:\n\`\`\`\n${command}\n\`\`\``,
326 })
327}
328
329// ---- look: the log, wood and embers ----------------------------------------
330
331const WOOD = '#d9822b'
332const EMBER = '#ff5e1a'
333const BARK = '#3b1f0e'
334const CREAM = '#ffe9c7'
335const INK = '#120804'
336
337/** The mascot PNG, base64, read once from the plugin's assets. */
338let mascotPng: string | undefined
339
340async function loadMascot($: EngineInterface): Promise<void> {
341 try {
342 const { base64 } = await $.fs.read(`${$.plugin.root}/assets/log.png`, { as: 'bytes' })
343 mascotPng = base64
344 } catch {
345 mascotPng = undefined
346 }
347}
348
349const MASCOT_ALT = 'a smiling wooden log with huge eyes, staring'
350
351/** Text-only stand-in for surfaces that cannot draw the picture. */
352const MASCOT_TEXT = [' ▄▄▄▄ ', ' █ ◉ ◉ █', ' █ ◡◡◡ █', ' ▀████▀ ']
353
354type Surface = ReturnType<EngineInterface['ui']['resolve']>
355
356function Mascot({ ui, size }: { ui: Surface; size: 'small' | 'large' }) {
357 const { Box, Text } = ui
358 const px = size === 'large' ? 112 : 56
359 if ('Svg' in ui && mascotPng) {
360 const { Svg } = ui
361 const source = `<svg xmlns="http://www.w3.org/2000/svg" width="${px}" height="${px}" viewBox="0 0 112 112"><image href="data:image/png;base64,${mascotPng}" width="112" height="112"/></svg>`
362 return <Svg source={source} alt={MASCOT_ALT} width={px} height={px} />
363 }
364 if ('Image' in ui && mascotPng) {
365 const { Image } = ui
366 const columns = size === 'large' ? 16 : 8
367 return <Image source={{ png: mascotPng }} columns={columns} rows={columns / 2} alt={MASCOT_ALT} />
368 }
369 return (
370 <Box flexDirection="column">
371 {MASCOT_TEXT.map((line, i) => (
372 <Text key={`m${i}`} color={WOOD} bold>
373 {line}
374 </Text>
375 ))}
376 </Box>
377 )
378}
379
380const LOG_LINES = [
381 'the log has seen your command.',
382 'the log does not blink.',
383 'the log keeps no receipts.',
384 'the log forgets nothing, and tells no one.',
385 'feed the log. the log is patient.',
386]
387
388function logLine(seed: string): string {
389 let h = 0
390 for (const ch of seed) h = (h * 31 + ch.charCodeAt(0)) >>> 0
391 return LOG_LINES[h % LOG_LINES.length] ?? LOG_LINES[0]!
392}
393
394export const register: Register = on => {
395 on('session.start', async ($, e, next) => {
396 await loadMascot($)
397 // A reload drops the pane's waiter and every value in module memory; don't
398 // leave a pane nobody can answer, and say so rather than fail silently.
399 if (pending === null && (await read($, request)) !== null) {
400 await update($, request, () => null)
401 try {
402 await $.ui.close({ id: PANE })
403 } catch {
404 // not open
405 }
406 $.ui.toast('secret-broker reloaded and dropped the pending secret request; ask Claude to run the command again')
407 }
408 await sweep($)
409 await $.command.register({
410 name: 'secrets',
411 description: 'List secrets remembered this session, or `/secrets forget` to drop them',
412 argumentHint: '[forget]',
413 })
414 return next(e)
415 })
416
417 on('command.run', { command: 'secrets' }, async ($, e) => {
418 if (e.args.trim() === 'forget') {
419 held.clear()
420 return { text: 'secret-broker: forgot every remembered secret (outputs are still redacted).' }
421 }
422 const names = [...held.keys()]
423 return {
424 text: names.length
425 ? `secret-broker remembers: ${names.join(', ')} (values hidden). /secrets forget drops them.`
426 : 'secret-broker: nothing remembered this session.',
427 }
428 })
429
430 on('prompt.compose', async ($, e, next) => {
431 const composed = await next(e)
432 return {
433 sections: [...composed.sections, { id: 'secret-broker:rules', text: RULES, scope: 'session' }],
434 }
435 })
436
437 on('tool.describe', { tool: 'Bash' }, async ($, e, next) => {
438 const described = await next(e)
439 return { ...described, description: described.description + BASH_NOTE }
440 })
441
442 on('tool.describe', { tool: 'PowerShell' }, async ($, e, next) => {
443 const described = await next(e)
444 return { ...described, description: described.description + POWERSHELL_NOTE }
445 })
446
447 on('prompt.submit', async ($, e, next) => {
448 await update($, nudge, () => false)
449 return next(e)
450 })
451
452 on('turn.complete', async ($, e, next) => {
453 const done = await next(e)
454 if (e.agentId === undefined && e.reason === 'answer' && handsBack(e.answer)) {
455 await update($, nudge, () => true)
456 }
457 return done
458 })
459
460 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
461 if (e.props.hasSurvey || !(await read($, nudge))) return next(e)
462 const ui = $.ui.resolve(e)
463 const { Box, Button, Text } = ui
464 return (
465 <Box flexDirection="row" borderStyle="round" borderColor={EMBER} backgroundColor={INK} paddingX={1} alignItems="center">
466 <Mascot ui={ui} size="small" />
467 <Box flexDirection="column" paddingX={1} flexGrow={1}>
468 <Text color={WOOD} bold>
469 THE LOG HAS NOTICED.
470 </Text>
471 <Text color={CREAM}>Claude just asked you to handle a key step yourself. The log can take it from here.</Text>
472 </Box>
473 <Button
474 key="nudge-go"
475 variant="primary"
476 hotkey="l"
477 label="Feed the log"
478 onPress={async () => {
479 await update($, nudge, () => false)
480 void $.prompt.submit({ text: NUDGE })
481 }}
482 />
483 <Text> </Text>
484 <Button key="nudge-hide" dimColor label="Dismiss" onPress={async () => update($, nudge, () => false)} />
485 </Box>
486 )
487 })
488
489 on('tool.call', async ($, e, next) => {
490 if (e.tool === 'PowerShell' && namesIn(e.command).length > 0) {
491 return {
492 deny: 'secret-broker: {{secret:NAME}} works in the Bash tool only (Git Bash on Windows). Run this command with the Bash tool instead, in bash syntax, keeping the placeholder.',
493 }
494 }
495 if (e.tool !== 'Bash') return scrubbed(await next(e))
496
497 const names = namesIn(e.command)
498 const warning = suspicious(e.command, names)
499 if (warning) return { deny: `secret-broker blocked this command: it ${warning}.` }
500 if (names.length === 0) return scrubbed(await next(e))
501
502 let values: Map<string, string> | null
503 const approved = preapproved.get(e.command)
504 if (approved) {
505 preapproved.delete(e.command)
506 values = approved
507 } else {
508 // One approval pane at a time, and never past this hook's 10 s budget:
509 // past INLINE_WAIT_MS the pane keeps waiting and reports back on its own.
510 const command = e.command
511 const turn = queue.then(() => ask($, command, names))
512 queue = turn.catch(() => undefined)
513 const timer = $.clock.sleep(INLINE_WAIT_MS).then(() => 'timeout' as const)
514 const outcome = await Promise.race([turn, timer])
515 if (outcome === 'timeout') {
516 $.ui.status(`secret-broker: Claude is waiting on the pop-up for ${names.join(', ')}; take your time`)
517 void turn.then(decided => reportDecision($, command, names, decided))
518 return {
519 deny: `secret-broker: the user is entering ${names.join(', ')} in a private pop-up. End your turn now and wait; do not retry, change the command or work around it. Once they decide, you will get a message telling you to run this exact command again (or that they declined).`,
520 }
521 }
522 values = outcome
523 if (values === null) {
524 return { deny: `The user declined to provide ${names.join(', ')}. Ask them how they want to proceed.` }
525 }
526 }
527 for (const [name, value] of values) scrubList.set(name, value)
528
529 const host = await hostInfo($)
530 const files: { name: string; path: string }[] = []
531 try {
532 await stage($, host, values, files)
533 return scrubbed(await next({ ...e, command: rewrite(e.command, files) }))
534 } catch (error) {
535 return { deny: `secret-broker could not run the command: ${scrubText(String(error))}` }
536 } finally {
537 await cleanup($, host, files)
538 }
539 })
540
541 on('ui.close', async ($, e, next) => {
542 const done = await next(e)
543 if (e.id === PANE && pending) await finish($, null)
544 return done
545 })
546
547 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
548 const ui = $.ui.resolve(e)
549 const { Box, Text, Button } = ui
550 const Input = 'Input' in ui ? ui.Input : undefined
551 const req = await read($, request)
552 if (req === null) return <Text dimColor>No secret requested.</Text>
553
554 const missing = req.names.filter(n => !req.ready.includes(n))
555 const remember = req.remember
556
557 const setValue = async (name: string, value: string | undefined) => {
558 if (!value) {
559 await refresh($, { note: `Nothing to use for ${name}.` })
560 return
561 }
562 staged.set(name, value)
563 await refresh($, { note: undefined })
564 }
565
566 const fed = missing.length === 0
567
568 return (
569 <Box flexDirection="column" backgroundColor={INK} paddingX={1}>
570 <Box flexDirection="row" alignItems="center">
571 <Mascot ui={ui} size="large" />
572 <Box flexDirection="column" paddingX={1}>
573 <Text color={EMBER} bold>
574 ▌ THE LOG REQUIRES {req.names.length === 1 ? 'A SECRET' : `${req.names.length} SECRETS`}
575 </Text>
576 <Text color={WOOD} italic>
577 {logLine(req.id)}
578 </Text>
579 <Text color={CREAM} dimColor>
580 Claude never sees the value. It goes to this one command, then it is gone.
581 </Text>
582 </Box>
583 </Box>
584
585 <Box flexDirection="column" borderStyle="round" borderColor={BARK} paddingX={1}>
586 <Text color={WOOD} bold>
587 the command, exactly as it will run
588 </Text>
589 <Text color={CREAM} wrap="wrap">
590 {req.command}
591 </Text>
592 </Box>
593
594 {req.names.map(name => (
595 <Box
596 flexDirection="column"
597 key={`row-${name}`}
598 borderStyle="round"
599 borderColor={req.ready.includes(name) ? WOOD : EMBER}
600 paddingX={1}
601 >
602 {req.ready.includes(name) ? (
603 <Box flexDirection="row">
604 <Text color={WOOD} bold>
605 ◉ {name}
606 </Text>
607 <Text color={CREAM}> fed to the log (hidden) </Text>
608 <Button key={`clear-${name}`} dimColor onPress={async () => {
609 staged.delete(name)
610 await refresh($, {})
611 }}>
612 change
613 </Button>
614 </Box>
615 ) : (
616 <Box flexDirection="column">
617 <Text color={EMBER} bold>
618 ◯ {name}
619 </Text>
620 {Input && (
621 <Input
622 key={`input-${name}`}
623 label=" ▶ "
624 placeholder="paste it here, press Enter. the log is watching."
625 autoFocus={name === missing[0] ? true : undefined}
626 submitLabel="feed"
627 onSubmit={value => void setValue(name, value.trim())}
628 />
629 )}
630 <Box flexDirection="row">
631 <Button key={`clip-${name}`} onPress={async () => setValue(name, await readClipboard($))}>
632 From clipboard
633 </Button>
634 <Text> </Text>
635 {req.remembered.includes(name) && (
636 <Button key={`held-${name}`} onPress={async () => setValue(name, held.get(name))}>
637 Use saved
638 </Button>
639 )}
640 </Box>
641 </Box>
642 )}
643 </Box>
644 ))}
645
646 {req.note && (
647 <Text color={EMBER} bold>
648 ! {req.note}
649 </Text>
650 )}
651 <Button
652 key="remember"
653 plain
654 dimColor
655 label={`${remember ? '◉' : '◯'} the log remembers these for this session`}
656 onPress={async () => refresh($, { remember: !remember })}
657 />
658 <Box flexDirection="row" paddingY={1} alignItems="center">
659 {fed ? (
660 <Button key="approve" variant="primary" hotkey="y" onPress={async () => {
661 const values = new Map(staged)
662 if (remember) for (const [n, v] of values) held.set(n, v)
663 await finish($, values)
664 }}>
665 RELEASE THE LOG
666 </Button>
667 ) : (
668 <Text color={WOOD} dimColor>
669 the log waits for: {missing.join(', ')}
670 </Text>
671 )}
672 <Text> </Text>
673 <Button key="deny" role="dismiss" hotkey="n" onPress={async () => finish($, null)}>
674 Decline
675 </Button>
676 </Box>
677 </Box>
678 )
679 })
680}
681
682/** Rebuilds a tool result without any secret value in it. */
683function scrubbed<R extends { deny?: unknown; result?: unknown; text?: unknown; isError?: unknown; context?: readonly string[] }>(
684 ran: R,
685): R | { deny: string } | { result: R['result']; context?: readonly string[] } {
686 if (ran.deny !== undefined) return ran
687 if (!leaks(ran.result) && !leaks(ran.text) && !leaks(ran.context)) return ran
688 if (ran.isError) return { deny: scrubText(String(ran.text ?? ran.result ?? 'error')) }
689 return {
690 result: scrubDeep(ran.result) as R['result'],
691 context: ran.context?.map(scrubText),
692 }
693}
694types/index.d.ts 20 lines1/** What the approval pane draws. Never holds a secret value, only names and flags. */
2export type SecretRequest = {
3 id: string
4 command: string
5 names: string[]
6 /** Names that have a value staged for this command. */
7 ready: string[]
8 /** Names that have a value remembered from earlier in this session. */
9 remembered: string[]
10 /** Keep the values in memory for later commands this session. */
11 remember: boolean
12 note?: string
13}
14
15declare module 'claude-code' {
16 interface PluginState {
17 'secret-broker': { request: SecretRequest | null; nudge: boolean }
18 }
19}
20