Hand the agent secrets it can use in Bash as $NAME without ever seeing their values

A Claude Code mod that lets the agent use your secrets without seeing them. It asks for a password or token by name, you type it outside the conversation, and Bash commands that name $NAME get the value while the transcript only ever shows [REDACTED:NAME].
Inspired by vimtor's opencode-blindfold.
Inside Claude Code, run:
/plugin marketplace add luisnquin/claude-blindfold
/plugin install blindfold
/reload-plugins
claude plugin marketplace add luisnquin/claude-blindfold
claude plugin install blindfold@claude-blindfold
Then run /reload-plugins inside a session, or start a new one.
Ask for something that needs a secret, such as run sudo ls. When sudo says it needs a password, the agent asks blindfold for SUDO_PASSWORD and ends its turn:
● blindfold - request (MCP)(name: "SUDO_PASSWORD", reason: "sudo needs a password to run ls")
⎿ Asked the user for SUDO_PASSWORD. End your turn now; a message will say when it is stored.
╭ blindfold ──────────────────────────────────────╮
│ The agent asks for SUDO_PASSWORD │
│ sudo needs a password to run ls │
│ value •••••••••• │
│ [cancel] from clipboard │
╰─────────────────────────────────────────────────╯
• per character. Enter stores it, cancel tells the agent you declined, and from clipboard reads the clipboard as a last resort.blindfold: SUDO_PASSWORD is stored as a prompt, and the agent runs printf '%s\n' "$SUDO_PASSWORD" | sudo -S ls.enter SUDO_PASSWORD under the request, or run /blindfold. A pane you open yourself fits any width. If no other mod uses the band above the prompt, the field shows there too.Claude Blindfold is a mod:
mcp__blindfold__request tool. The section tells the agent to call the tool on its own whenever a command needs a secret (sudo saying "a password is required", an API answering 401, a token variable being unset), instead of suggesting ! <command> or asking you to paste the value into the chat.$NAME or ${NAME}. For each name it finds, it writes export NAME='value' to a file only you can read in $XDG_RUNTIME_DIR/claude-blindfold/ (or /tmp), and prefixes the command so the shell sources and deletes that file first. The command the agent wrote, and the one shown in the transcript, keep only $NAME. Commands that don't name a secret get nothing.[REDACTED:NAME], along with its common encodings for values of 8 or more characters: JSON-escaped, URL-encoded, base64 (padded, unpadded and URL-safe) and hex.Blindfold keeps secrets out of the conversation, the transcript and the model's context, so a password doesn't end up in a log or a prompt by accident. It is not a sandbox against an agent that sets out to leak one:
The mod makes no network requests. It runs sh to write the per-command file, and wl-paste, pbpaste or xclip only when you press from clipboard. It reads XDG_RUNTIME_DIR.
Run claude plugin validate on the repo to see every event it hooks and every call it makes.
The agent tells me to run the command with !. The mod isn't loaded in that session. Run /plugin and check that blindfold is listed as an active mod, then start a new session. A session that was open before the install doesn't have the system prompt section.
No prompt appeared. Your terminal is under 144 columns. Tap enter NAME under the request, or run /blindfold.
The field above the prompt never shows. Another mod draws in that band and doesn't pass it on (for example prompt-rail once you have sent a prompt). Use the button or /blindfold.
git clone https://github.com/luisnquin/claude-blindfold
cd claude-blindfold
# Load it for one session without installing
claude --plugin-dir .
# Check it and run the tests
claude plugin validate .
claude plugin test .
Claude Code writes the API types into .claude-plugin/types/ the first time it loads the mod, and tsc -p . type-checks it from then on.
MIT. See LICENSE.
hooks/register.tsx 235 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register } from 'claude-code'
3
4import type { SecretRequest } from '../types'
5import { MASK, redact, referenced, shellQuote, unmask } from './redact'
6
7const PANE = 'blindfold'
8const TOOL = 'mcp__blindfold__request'
9const NAME = /^[A-Z_][A-Z0-9_]*$/
10
11const secrets = atom({ plugin: 'blindfold', key: 'secrets' } as const, {})
12const pending = atom({ plugin: 'blindfold', key: 'pending' } as const, [])
13const draft = atom({ plugin: 'blindfold', key: 'draft' } as const, '')
14
15const INSTRUCTIONS = [
16 '# Secrets (blindfold)',
17 `Get every secret (sudo password, API token, passphrase) through ${TOOL}, on your own, as soon as a command needs one: sudo says "a password is required", an API answers 401, a token variable is unset.`,
18 'Never suggest running the command with `!`, ask for the secret in chat, or read one from the clipboard or a file.',
19 'After the call end your turn; a message says when it is stored. Use it only in Bash as a shell variable, e.g. `printf \'%s\\n\' "$SUDO_PASSWORD" | sudo -S <cmd>`. You see it as [REDACTED:NAME]; never print or transform it.',
20].join('\n')
21
22
23function randomId(): string {
24 return Array.from(crypto.getRandomValues(new Uint8Array(12)), b => b.toString(16).padStart(2, '0')).join('')
25}
26
27async function store($: EngineInterface, request: SecretRequest, value: string): Promise<void> {
28 if (value.length === 0) {
29 $.ui.toast(`${request.name}: empty value ignored`)
30 return
31 }
32 await update($, secrets, all => ({ ...all, [request.name]: value }))
33 await settle($, request.name)
34 $.ui.toast(`${request.name} stored`)
35 void $.prompt.submit({
36 text: `blindfold: ${request.name} is stored. Use it as $${request.name} in Bash.`,
37 })
38}
39
40async function submitDraft($: EngineInterface, request: SecretRequest, shown: string): Promise<void> {
41 const value = unmask(shown, await read($, draft))
42 await store($, request, value)
43}
44
45async function settle($: EngineInterface, name: string): Promise<void> {
46 await update($, draft, () => '')
47 const left = await update($, pending, list => list.filter(one => one.name !== name))
48 if (left.length === 0) await $.ui.close({ id: PANE })
49}
50
51function openPane($: EngineInterface) {
52 return $.ui.open({ id: PANE, title: 'blindfold', focus: true, holdToasts: true, rows: 9 })
53}
54
55async function clipboard($: EngineInterface): Promise<string | undefined> {
56 for (const argv of [['wl-paste', '--no-newline'], ['pbpaste'], ['xclip', '-selection', 'clipboard', '-o']]) {
57 try {
58 const { exitCode, stdout } = await $.process.run(argv)
59 if (exitCode === 0) return stdout
60 } catch {
61 // ignored: a missing tool falls through to the next one
62 }
63 }
64 return undefined
65}
66
67export const register: Register = on => {
68 on('session.start', async ($, e, next) => {
69 await $.tool.register({
70 name: 'request',
71 description:
72 'Ask the user for a secret by name; they enter it outside the conversation and you never see it. ' +
73 'End your turn after calling; once stored, use it in Bash as $NAME.',
74 inputSchema: {
75 type: 'object',
76 properties: {
77 name: { type: 'string', description: 'UPPER_SNAKE variable name, e.g. GITHUB_TOKEN' },
78 reason: { type: 'string', description: 'Why the secret is needed, shown to the user' },
79 },
80 required: ['name', 'reason'],
81 },
82 isDeferred: false,
83 })
84 await $.command.register({ name: 'blindfold', description: 'Open the pending secret prompt' })
85 return next(e)
86 })
87
88 on('command.run', { command: 'blindfold' }, async $ => {
89 const list = await read($, pending)
90 if (list.length === 0) return { text: 'No secret requested.' }
91 await openPane($)
92 return { text: `Secret prompt opened for ${list.map(one => one.name).join(', ')}.` }
93 })
94
95 on('prompt.compose', async ($, e, next) => {
96 const { sections } = await next(e)
97 return { sections: [...sections, { id: 'blindfold', text: INSTRUCTIONS, scope: 'session' }] }
98 })
99
100 on('tool.call', { tool: TOOL }, async ($, e) => {
101 const input = e as { name?: unknown; reason?: unknown }
102 const name = typeof input.name === 'string' ? input.name : ''
103 const reason = typeof input.reason === 'string' ? input.reason : ''
104 if (!NAME.test(name)) return { deny: `${name || '(empty)'} is not an UPPER_SNAKE variable name.` }
105
106 const stored = await read($, secrets)
107 if (name in stored) return { result: `${name} is already stored. Use it as $${name} in Bash.` }
108
109 await update($, pending, list => [...list.filter(one => one.name !== name), { name, reason, toolUseId: e.tool_use_id }])
110 const opened = await openPane($)
111 if (!opened.isPlaced) {
112 $.ui.toast(`${name} requested: tap "enter ${name}" or run /blindfold`)
113 return {
114 result: `Asked the user for ${name}, but the screen is too narrow for the prompt to open by itself. Tell them to tap "enter ${name}" under this call or run /blindfold, then end your turn.`,
115 }
116 }
117 return {
118 result: `Asked the user for ${name}. End your turn now; a message will say when it is stored.`,
119 }
120 }).catch(() => ({ deny: 'blindfold could not open the secret prompt.' }))
121
122 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
123 const all = await read($, secrets)
124 const names = referenced(e.command, Object.keys(all))
125 if (names.length === 0) return next(e)
126
127 const runtime = (await $.env.get('XDG_RUNTIME_DIR')) ?? '/tmp'
128 const file = `${runtime}/claude-blindfold/${randomId()}.sh`
129 const script = names.map(name => `export ${name}=${shellQuote(all[name] ?? '')}`).join('\n') + '\n'
130 const { exitCode } = await $.process.run(
131 ['sh', '-c', 'umask 077 && mkdir -p "${1%/*}" && cat > "$1"', 'sh', file],
132 { stdin: script },
133 )
134 if (exitCode !== 0) return { deny: `blindfold could not stage ${names.join(', ')}.` }
135
136 const quoted = shellQuote(file)
137 return next({ ...e, command: `. ${quoted}; rm -f ${quoted}\n${e.command}` })
138 }).catch(($, e, next) => (next.called ? next(e) : { deny: 'blindfold failed to stage the secrets.' }))
139
140 on('session.append', async ($, e, next) => {
141 const all = await read($, secrets)
142 if (Object.keys(all).length === 0) return next(e)
143
144 const content = e.message.content.map(block => {
145 if (block.type === 'text' && typeof block.text === 'string') return { ...block, text: redact(block.text, all) }
146 if (block.type === 'tool_result') {
147 if (typeof block.content === 'string') return { ...block, content: redact(block.content, all) }
148 if (Array.isArray(block.content)) {
149 return {
150 ...block,
151 content: block.content.map(part => (part.type === 'text' && typeof part.text === 'string' ? { ...part, text: redact(part.text, all) } : part)),
152 }
153 }
154 }
155 return block
156 })
157 return next({ ...e, message: { ...e.message, content } })
158 }).catch(($, e, next) => (next.called ? next(e) : next({ ...e, message: { ...e.message, content: [] } })))
159
160 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
161 const ui = $.ui.resolve(e)
162 if (!('Input' in ui)) return next(e)
163 const { Text } = ui
164 const [request] = await read($, pending)
165 if (request === undefined) return <Text dimColor>No secret requested.</Text>
166 return form($, ui, request)
167 })
168
169 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
170 if (e.props.tool !== TOOL) return next(e)
171 const request = (await read($, pending)).find(one => one.toolUseId === e.props.tool_use_id)
172 if (request === undefined) return next(e)
173 const { Box, Button } = $.ui.resolve(e)
174 return (
175 <Box flexDirection="column">
176 {await next(e)}
177 <Button key="enter" label={`enter ${request.name}`} onPress={() => void openPane($)} />
178 </Box>
179 )
180 })
181
182 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
183 const ui = $.ui.resolve(e)
184 if (!('Input' in ui) || e.props.hasSurvey) return next(e)
185 const [request] = await read($, pending)
186 if (request === undefined) return next(e)
187 const panes = await $.ui.panes()
188 if (panes.some(pane => pane.id === PANE && pane.isPlaced)) return next(e)
189 return form($, ui, request)
190 })
191}
192
193async function form($: EngineInterface, ui: Pick<Elements['terminal'], 'Box' | 'Text' | 'Input' | 'Button'>, request: SecretRequest) {
194 const { Box, Text, Input, Button } = ui
195 const typed = await read($, draft)
196
197 return (
198 <Box flexDirection="column">
199 <Text>
200 The agent asks for <Text bold>{request.name}</Text>
201 </Text>
202 <Text dimColor>{request.reason}</Text>
203 <Input
204 key="value"
205 label="value"
206 placeholder="type or paste, Enter to store"
207 value={MASK.repeat(Array.from(typed).length)}
208 autoFocus
209 onInput={value => void update($, draft, real => unmask(value, real))}
210 onSubmit={value => void submitDraft($, request, value)}
211 />
212 <Box flexDirection="row" gap={2}>
213 <Button
214 key="cancel"
215 label="cancel"
216 onPress={async () => {
217 await settle($, request.name)
218 void $.prompt.submit({ text: `blindfold: the user declined to provide ${request.name}.` })
219 }}
220 />
221 <Button
222 key="clipboard"
223 plain
224 label="from clipboard"
225 onPress={async () => {
226 const value = await clipboard($)
227 if (value === undefined) $.ui.toast('clipboard is not readable')
228 else await store($, request, value)
229 }}
230 />
231 </Box>
232 </Box>
233 )
234}
235hooks/redact.ts 71 lines1const MIN_ENCODED = 8
2
3function bytesOf(value: string): Uint8Array {
4 return new TextEncoder().encode(value)
5}
6
7function base64(bytes: Uint8Array): string {
8 const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
9 let out = ''
10 for (let i = 0; i < bytes.length; i += 3) {
11 const n = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
12 out += chars.charAt((n >> 18) & 63) + chars.charAt((n >> 12) & 63)
13 out += i + 1 < bytes.length ? chars.charAt((n >> 6) & 63) : '='
14 out += i + 2 < bytes.length ? chars.charAt(n & 63) : '='
15 }
16 return out
17}
18
19function hex(bytes: Uint8Array): string {
20 return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')
21}
22
23export function formsOf(value: string): string[] {
24 const forms = new Set([value])
25 if (value.length >= MIN_ENCODED) {
26 const bytes = bytesOf(value)
27 const b64 = base64(bytes)
28 forms.add(JSON.stringify(value).slice(1, -1))
29 forms.add(encodeURIComponent(value))
30 forms.add(b64)
31 forms.add(b64.replace(/=+$/, ''))
32 forms.add(b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''))
33 forms.add(hex(bytes))
34 forms.add(hex(bytes).toUpperCase())
35 }
36 return [...forms].filter(form => form.length > 0).sort((a, b) => b.length - a.length)
37}
38
39export function redact(text: string, secrets: Record<string, string>): string {
40 let out = text
41 for (const [name, value] of Object.entries(secrets)) {
42 for (const form of formsOf(value)) {
43 if (out.includes(form)) out = out.split(form).join(`[REDACTED:${name}]`)
44 }
45 }
46 return out
47}
48
49export function referenced(command: string, names: readonly string[]): string[] {
50 return names.filter(name => new RegExp(`\\$\\{?${name}(?![A-Za-z0-9_])`).test(command))
51}
52
53export function shellQuote(value: string): string {
54 return `'${value.replace(/'/g, `'\\''`)}'`
55}
56
57export const MASK = '•'
58
59// The field shows one MASK per real character, so each MASK in the new text
60// stands for the next unconsumed real character and anything else was typed.
61export function unmask(shown: string, real: string): string {
62 const chars = Array.from(real)
63 let next = 0
64 let out = ''
65 for (const char of shown) {
66 if (char === MASK && next < chars.length) out += chars[next++] ?? ''
67 else out += char
68 }
69 return out
70}
71types/index.d.ts 12 lines1export type SecretRequest = { name: string; reason: string; toolUseId?: string }
2
3declare module 'claude-code' {
4 interface PluginState {
5 blindfold: {
6 secrets: Record<string, string>
7 pending: SecretRequest[]
8 draft: string
9 }
10 }
11}
12