SLOPSHOPPER

blindfold

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

newpanebandrowsguardcommand
v0.1.0MITupdated 2026-10-15luisnquin/claude-blindfold
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · blindfold
│ ┃ blindfold ✕ › fix the failing auth test and add an audit log call │ ┃ No secret requested. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /blindfold │ ⎿ blindfold: No secret requested. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · blindfold
No secret requested.
README

Claude Blindfold

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].

License Stars

Inspired by vimtor's opencode-blindfold.

Install

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.

What You See

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                        │
╰─────────────────────────────────────────────────╯
  • Type or paste it. The field shows one • per character. Enter stores it, cancel tells the agent you declined, and from clipboard reads the clipboard as a last resort.
  • The agent picks up by itself. Once the value is stored, blindfold sends blindfold: SUDO_PASSWORD is stored as a prompt, and the agent runs printf '%s\n' "$SUDO_PASSWORD" | sudo -S ls.
  • Works on narrow screens. Claude Code only opens a pane by itself on terminals at least 144 columns wide. On a narrower one (a phone, a split), tap 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.
  • Asked once per session. The agent reuses a stored name for the rest of the session.

How It Works

Claude Blindfold is a mod:

  1. It adds a short section to the system prompt and registers the 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.
  2. The tool returns at once and opens the prompt. The value you enter is kept in the session's plugin state, never in a message.
  3. Before each Bash call, it checks the command for $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.
  4. Every row written to the transcript passes through it first. Text and tool results have the value replaced with [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.

Security

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:

  • Any command that names a secret gets it, for the rest of the session, with no further approval.
  • Redaction only matches the forms listed above. A command that reverses, splits or re-encodes the value some other way prints it unredacted.
  • The value lives in Claude Code's session state until the session ends.

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.

Requirements

  • Claude Code with mods support. Developed and tested on v2.1.300.
  • macOS or Linux. Commands are prefixed for a POSIX shell, so native Windows doesn't work.

Troubleshooting

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.

Development

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.

License

MIT. See LICENSE.

Source 3 files
hooks/register.tsx 235 lines
1import { 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}
235
hooks/redact.ts 71 lines
1const 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}
71
types/index.d.ts 12 lines
1export 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