Redacts API keys and tokens from tool results and prompts, and lets the model use stored keys without reading them.

Dutch for "secretly, after all".
A Claude Code mod that keeps API keys and tokens out of what the model reads and what the transcript keeps, and lets the model use keys you store without ever reading them.
When a tool's answer or something you type holds a key in a known format, or a key you stored, the key is replaced with [redacted: <kind>] before the model reads it or the transcript stores it. A Bash command the model writes with a key in it is denied, so it does not run. A key you store goes out only through the mod's own request tool, and only to the hosts you allowed it. The mod writes no files, and makes network requests only when the model calls its request tool.
hooks/patterns.ts: private key blocks, AWS access keys, GitHub tokens, sk- API keys (Anthropic and OpenAI), Stripe keys, Slack tokens, Google API keys, and JSON Web Tokens.Store a key and the hosts it may go to, and the model can make HTTPS requests with it through the mcp__stiekem-toch__request tool. The model names the key. The mod adds it to the request on the host and removes it from the response before the model reads it. The tool is offered even when nothing is stored, so the model knows this is how a key reaches a request.
Set them in your own terminal, so the key never passes through a session:
claude plugin configure stiekem-toch --values-stdin
Give it a JSON object whose vault holds the secrets, written as one line of JSON:
{"vault": "{\"github\":{\"value\":\"ghp_…\",\"hosts\":[\"api.github.com\"],\"header\":\"Authorization\",\"template\":\"Bearer {}\"}}"}
Each secret has:
value: the key, at least 8 characters.hosts: the exact hosts it may be sent to.header: the request header that carries it.template: how the header is spelled, {} standing for the key. Optional; {} by default.A secret missing any of these is dropped whole, so a typo never widens what it may do. The vault option is marked sensitive, so Claude Code keeps it in secure storage, not in settings.json. The hosts live in the same field on purpose: a model that can edit settings.json cannot add a host. If the field is not valid JSON, the request tool is not offered and a notice says why. Redaction by the known formats still runs.
The request tool refuses:
https://<host>/<path>, or that has user info, a port other than 443, or a backslash;A redirect is followed. One that leaves the origin drops every request header, the secret's included; this was checked against Authorization and a custom X-Api-Key. A response body is cut at 100,000 characters.
The keys live only in the mod's memory for the session: never in its state, its store or a file.
When a key in a known format is in a prompt you send, the mod keeps it in memory for the session under a name, pasted-1, pasted-2 and so on. The model reads the name in the key's place and is told it can send the key with the request tool. A key you paste twice keeps its first name.
A pasted key has no hosts to start with. The first time the model sends it to a host, you are asked whether to send it there. If you allow it, that host stays allowed for the session. If you refuse, close the dialog, or no one can answer, as in claude -p, the request does not run. A pasted key always goes in Authorization: Bearer <key>. A key that goes in another header, such as an Anthropic x-api-key, has to be stored in the vault instead.
Keys in a slash command's arguments are not kept, only redacted.
@. The transcript keeps a mentioned file's content as read.If a check throws, nothing it was checking passes through unchecked:
Per the mods documentation, hooks run in the terminal, the Desktop app's Code tab (not in WSL sessions), the VS Code chat panel, claude -p, the Agent SDK and Remote Control. Notices appear as toasts only where something draws, so claude -p shows none. Cloud sessions run the mod only when the plugin reaches them.
Requires Claude Code 2.1.275 or later.
In Claude Code:
/plugin install stiekem-toch --marketplace mitchellvanw/stiekem-toch
Or from a terminal:
claude plugin install stiekem-toch --marketplace mitchellvanw/stiekem-toch
This installs for you, in user scope. To try it for one session from a clone instead:
claude --plugin-dir /path/to/stiekem-toch
claude plugin validate .
claude plugin test .
tsc -p .
tsc needs the types Claude Code writes to .claude-plugin/types/ once it has loaded the plugin from this folder, for example with claude --plugin-dir ..
The tests cover:
The rest was checked by hand, with fake keys:
Version 0.2.0.
hooks/register.ts 194 lines1import type { Register } from 'claude-code'
2import { formatMatches, redact, redactBlock, redactDeep, secretKinds, withhold } from './patterns'
3import { checkRequest, hostOf, parseVault, pastedGrant, spellings, type Grant, type Request } from './vault'
4
5const TOOL = 'request'
6const TOOL_NAME = `mcp__stiekem-toch__${TOOL}`
7// Bodies past this are cut before the model reads them.
8const MAX_BODY = 100_000
9const ALLOW = 'Send it'
10
11export const register: Register = (on, options) => {
12 // The stored secrets live here alone, in this module's memory: never in `$.state`, `$.store` or a file.
13 // Keys the person pastes into a prompt join them for the session.
14 const vault = new Map<string, Grant>()
15 let isVaultBroken = false
16 try {
17 for (const [name, grant] of parseVault(options.vault as string | undefined)) vault.set(name, grant)
18 } catch {
19 isVaultBroken = true
20 }
21 let known = spellings(vault)
22
23 const keep = (name: string, grant: Grant) => {
24 vault.set(name, grant)
25 known = spellings(vault)
26 }
27
28 // The tool is offered with an empty vault too, so the model learns how a key reaches a request
29 // instead of reading one from a file into a Bash command. Its `secret` takes no enum: a key
30 // kept mid-session is usable before the tool list is read again, and an unknown name is refused.
31 const toolSpec = () => {
32 const listing = [...vault].map(([name, g]) =>
33 g.pasted === undefined
34 ? `- "${name}": ${g.hosts.join(', ')}`
35 : `- "${name}": a ${g.pasted} the person pasted, sent as a bearer token; the person is asked before it goes to a host they have not allowed${g.hosts.length > 0 ? ` (allowed: ${g.hosts.join(', ')})` : ''}`,
36 )
37 return {
38 name: TOOL,
39 description: [
40 'Makes an HTTPS request with a stored secret attached. You never see the secret: the plugin adds it to the request and removes it from the response.',
41 listing.length > 0 ? `Each secret goes only to its own hosts:\n${listing.join('\n')}` : 'No secret is stored yet.',
42 'The person adds one by pasting it into a prompt, which keeps it for the session, or for good with `claude plugin configure stiekem-toch --values-stdin` in their own terminal.',
43 'Use this instead of putting a key in a Bash command, which is refused, or reading one from a file into a command.',
44 ].join('\n'),
45 inputSchema: {
46 type: 'object',
47 properties: {
48 secret: { type: 'string', description: 'The name of a stored secret' },
49 method: { type: 'string', enum: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'] },
50 url: { type: 'string', description: "https://<host>/<path>, the host one of the secret's own" },
51 headers: { type: 'object', additionalProperties: { type: 'string' } },
52 body: { type: 'string' },
53 },
54 required: ['secret', 'url'],
55 },
56 isDeferred: false,
57 }
58 }
59
60 on('session.start', async ($, e, next) => {
61 const started = await next(e)
62
63 if (isVaultBroken) $.ui.toast(`${$.plugin.name}: the vault option is not valid JSON, so no configured secret can be used`)
64 await $.tool.register(toolSpec())
65
66 return started
67 })
68
69 // The request runs here, on the host: the model's call names the secret, never holds it.
70 on('tool.call', { tool: TOOL_NAME }, async ($, e) => {
71 const req = e as unknown as Request
72 const grant = vault.get(req.secret)
73 const host = hostOf(req.url)
74
75 // A pasted key has no hosts of its own: the person allows each one at first use.
76 if (grant?.pasted !== undefined && host !== undefined && !grant.hosts.includes(host)) {
77 const answer = await $.ui
78 .ask(`Send the ${grant.pasted} you pasted to ${host}? It is added as a bearer token, and ${host} stays allowed for this session.`, {
79 options: [ALLOW, "Don't send it"],
80 header: 'Secret',
81 })
82 .catch(() => undefined)
83 if (answer !== ALLOW) return { deny: `${$.plugin.name}: the person did not allow "${req.secret}" to go to ${host}.` }
84
85 keep(req.secret, { ...grant, hosts: [...grant.hosts, host] })
86 }
87
88 const checked = checkRequest(vault, req)
89 if ('deny' in checked) return { deny: `${$.plugin.name}: ${checked.deny}` }
90
91 const res = await $.http.fetch(checked.url, checked.init)
92
93 const kinds: string[] = []
94 const body = res.text.length > MAX_BODY ? `${res.text.slice(0, MAX_BODY)}\n[cut at ${MAX_BODY} characters]` : res.text
95 const result = redactDeep({ status: res.status, headers: res.headers, body }, kinds, known)
96
97 if (kinds.length > 0) $.ui.toast(`Redacted ${kinds.length} secret${kinds.length === 1 ? '' : 's'} from a response: ${kinds.join(', ')}`)
98
99 // A plugin's tool answers text. It is written after the redaction, so JSON escaping cannot hide a secret from it.
100 return { result: JSON.stringify(result, null, 2) }
101 }).catch($ => ({ deny: `${$.plugin.name}: the request failed, so nothing is returned.` }))
102
103 // A tool's answer, before the model reads it and before the transcript keeps its structured record.
104 on('tool.call', async ($, e, next) => {
105 if (e.tool === 'Bash') {
106 const kinds = secretKinds(e.command, known)
107
108 // A command that holds a key would send it somewhere or write it down, so it does not run.
109 if (kinds.length > 0) {
110 $.ui.toast(`Blocked a Bash command holding a ${kinds[0]}`)
111 return {
112 deny: `${$.plugin.name}: the command holds what looks like a ${kinds[0]}, so it did not run. Leave the value out of the command.`,
113 }
114 }
115 }
116
117 const ran = await next(e)
118
119 if (ran.deny !== undefined) return ran
120
121 const kinds: string[] = []
122 const answer = {
123 ...ran,
124 text: redactDeep(ran.text, kinds, known) as typeof ran.text,
125 result: redactDeep(ran.result, kinds, known) as typeof ran.result,
126 }
127
128 if (kinds.length > 0) {
129 $.ui.toast(`Redacted ${kinds.length} secret${kinds.length === 1 ? '' : 's'} from ${e.tool}: ${kinds.join(', ')}`)
130 }
131
132 return answer
133 }).catch($ => ({ deny: `${$.plugin.name}: its check failed, so this call's result is withheld.` }))
134
135 // What the person typed, before the prompt history and the queue record keep it.
136 // A slash command's line takes no rewrite here; its arguments are masked at command.run below.
137 on('prompt.submit', async ($, e, next) => {
138 if (e.command !== undefined) return next(e)
139
140 // A key the person pastes is one they mean the model to use: keep it, so the redaction names it.
141 const pasted = formatMatches(e.text, known)
142 const kept: string[] = []
143 for (const { kind, value } of pasted) {
144 if (known.some(([form]) => form === value)) continue
145 const name = `pasted-${[...vault.values()].filter(g => g.pasted !== undefined).length + 1}`
146 keep(name, pastedGrant(kind, value))
147 kept.push(`"${name}", a ${kind}`)
148 }
149 if (kept.length > 0) await $.tool.register(toolSpec())
150
151 const kinds: string[] = []
152 const text = redact(e.text, kinds, known)
153
154 if (kinds.length === 0) return next(e)
155
156 $.ui.toast(
157 `Redacted ${kinds.length} secret${kinds.length === 1 ? '' : 's'} from your prompt: ${kinds.join(', ')}` +
158 (kept.length > 0 ? `. Kept for this session; you are asked before it goes to a host.` : ''),
159 )
160 if (kept.length === 0) return next({ ...e, text })
161
162 const note = `${$.plugin.name} kept what the person pasted, for this session: ${kept.join('; ')}. Send it with the ${TOOL_NAME} tool, naming it; the person is asked before it goes to each host.`
163 return next({ ...e, text, context: [...(e.context ?? []), note] })
164 }).catch(($, e, next) => (next.called ? next(e) : { drop: `${$.plugin.name}: its check failed, so the prompt was not sent.` }))
165
166 // A slash command's arguments, before the command runs and before its rows and its history line keep them.
167 on('command.run', ($, e, next) => {
168 const kinds: string[] = []
169 const args = redact(e.args, kinds, known)
170
171 if (kinds.length === 0) return next(e)
172
173 $.ui.toast(`Redacted ${kinds.length} secret${kinds.length === 1 ? '' : 's'} from /${e.command}: ${kinds.join(', ')}`)
174 return next({ ...e, args })
175 }).catch(($, e, next) => (next.called ? next(e) : { text: `${$.plugin.name}: its check failed, so /${e.command} did not run.` }))
176
177 // The rows the model reads and the transcript keeps: what the person typed, a command's rows, a queued
178 // prompt's record, and any tool result the hook above did not reach.
179 for (const door of ['tool-result', 'prompt', 'command', 'queue'] as const) {
180 on('session.append', { door }, ($, e, next) => {
181 const kinds: string[] = []
182 const content = e.message.content.map(block => redactBlock(block, kinds, known))
183
184 if (kinds.length === 0) return next(e)
185
186 $.ui.toast(`Redacted ${kinds.length} secret${kinds.length === 1 ? '' : 's'}: ${kinds.join(', ')}`)
187 return next({ ...e, message: { ...e.message, content } })
188 }).catch(($, e, next) =>
189 // The check failed before it could read the row, so the row keeps nothing it did not read.
190 next.called ? next(e) : next({ ...e, message: { ...e.message, content: e.message.content.map(withhold) } }),
191 )
192 }
193}
194hooks/patterns.ts 92 lines1import { redactKnown } from './vault'
2
3// Known key and token formats. Each pattern starts at a word boundary, so an
4// ordinary word such as "task-management" does not match.
5const PATTERNS: readonly { kind: string; re: RegExp }[] = [
6 { kind: 'private key', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g },
7 { kind: 'AWS access key', re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
8 { kind: 'GitHub token', re: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/g },
9 { kind: 'GitHub token', re: /\bgithub_pat_[A-Za-z0-9_]{22,}\b/g },
10 { kind: 'API key', re: /\bsk-[A-Za-z0-9_-]{20,}\b/g },
11 { kind: 'Stripe key', re: /\b[sr]k_(?:live|test)_[A-Za-z0-9]{16,}\b/g },
12 { kind: 'Slack token', re: /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/g },
13 { kind: 'Google API key', re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
14 // A JWT's header, then two to four more parts. Those parts may be empty: an unsigned token ends in '.', and a JWE has five.
15 { kind: 'JSON Web Token', re: /\beyJ[A-Za-z0-9_-]{10,}(?:\.[A-Za-z0-9_-]*){2,4}/g },
16]
17
18export type Block = { type: string; [field: string]: unknown }
19
20const WITHHELD = '[withheld: stiekem-toch could not check this]'
21
22// Each spelling of a stored secret, with the name the model reads in its place.
23export type Known = readonly [string, string][]
24
25// Replaces each secret in `text` with its kind, and records the kind in `kinds`:
26// first the stored secrets by value, then the known formats.
27export function redact(text: string, kinds: string[] = [], known: Known = []): string {
28 return replaceFormats(redactKnown(text, known, kinds), kind => kinds.push(kind))
29}
30
31// Each key in a known format that `text` holds and that is not a stored secret,
32// with its kind, found in the order `redact` replaces them.
33export function formatMatches(text: string, known: Known = []): { kind: string; value: string }[] {
34 const found: { kind: string; value: string }[] = []
35 replaceFormats(redactKnown(text, known), (kind, value) => found.push({ kind, value }))
36 return found
37}
38
39function replaceFormats(text: string, onMatch: (kind: string, value: string) => void): string {
40 return PATTERNS.reduce(
41 (out, { kind, re }) =>
42 out.replace(re, value => {
43 onMatch(kind, value)
44 return `[redacted: ${kind}]`
45 }),
46 text,
47 )
48}
49
50// The kind of each secret `text` holds, one entry per match.
51export function secretKinds(text: string, known: Known = []): string[] {
52 const kinds: string[] = []
53 redact(text, kinds, known)
54 return kinds
55}
56
57// A content block with its text redacted: a text block, or the text of a tool
58// result, whether a string or the text blocks it holds. Other fields pass through.
59export function redactBlock(block: Block, kinds: string[], known: Known = []): Block {
60 if (block.type === 'text' && typeof block.text === 'string') {
61 return { ...block, text: redact(block.text, kinds, known) }
62 }
63
64 if (block.type === 'tool_result') {
65 const { content } = block
66
67 if (typeof content === 'string') return { ...block, content: redact(content, kinds, known) }
68 if (Array.isArray(content)) return { ...block, content: content.map(b => redactBlock(b as Block, kinds, known)) }
69 }
70
71 return block
72}
73
74// Every string in a value, at any depth, redacted: a tool's structured record has no fixed shape.
75export function redactDeep(value: unknown, kinds: string[], known: Known = []): unknown {
76 if (typeof value === 'string') return redact(value, kinds, known)
77 if (Array.isArray(value)) return value.map(v => redactDeep(v, kinds, known))
78 if (value !== null && typeof value === 'object') {
79 return Object.fromEntries(Object.entries(value).map(([key, v]) => [key, redactDeep(v, kinds, known)]))
80 }
81
82 return value
83}
84
85// The stand-in for a block the check could not read: its text, or a tool result's, replaced.
86export function withhold(block: Block): Block {
87 if (block.type === 'text') return { ...block, text: WITHHELD }
88 if (block.type === 'tool_result') return { ...block, content: WITHHELD }
89
90 return block
91}
92hooks/vault.ts 168 lines1// Secrets the person stores for the model to use without reading them. Each one
2// is bound to the hosts it may be sent to and the header that carries it.
3export type Grant = {
4 value: string
5 hosts: readonly string[]
6 header: string
7 // How the header's value is spelled, `{}` standing for the secret: `Bearer {}`.
8 template: string
9 // Set on a key the person pasted into a prompt, to its kind. Such a grant starts
10 // with no hosts; each one is added when the person allows it at first use.
11 pasted?: string
12}
13
14export type Vault = ReadonlyMap<string, Grant>
15
16export type Request = {
17 secret: string
18 method?: string
19 url: string
20 headers?: Record<string, string>
21 body?: string
22}
23
24export type Checked = { url: string; init: { method: string; headers: Record<string, string>; body?: string } }
25
26const NAME = /^[a-z0-9_-]{1,32}$/
27const HOST = /^[a-z0-9-]+(?:\.[a-z0-9-]+)+$/
28// https only, a bare host (no user info, no port but 443), a path with no backslash.
29const URL_SHAPE = /^https:\/\/([a-z0-9.-]+)(?::443)?(\/[^\s#\\]*)?$/i
30const METHODS = new Set(['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'])
31
32// The vault from the `vault` option: a JSON object of grants by name. A grant
33// that does not hold its shape is dropped whole, so a typo never widens one.
34export function parseVault(text: string | undefined): Vault {
35 const vault = new Map<string, Grant>()
36 if (text === undefined || text.trim() === '') return vault
37
38 const parsed: unknown = JSON.parse(text)
39 if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return vault
40
41 for (const [name, raw] of Object.entries(parsed)) {
42 const g = raw as Partial<Grant>
43 const template = g.template ?? '{}'
44 const isGrant =
45 NAME.test(name) &&
46 typeof g.value === 'string' &&
47 g.value.length >= 8 &&
48 Array.isArray(g.hosts) &&
49 g.hosts.length > 0 &&
50 g.hosts.every(h => typeof h === 'string' && HOST.test(h)) &&
51 typeof g.header === 'string' &&
52 /^[A-Za-z0-9-]+$/.test(g.header) &&
53 typeof template === 'string' &&
54 template.includes('{}')
55
56 if (isGrant) vault.set(name, { value: g.value!, hosts: g.hosts!, header: g.header!, template })
57 }
58
59 return vault
60}
61
62// A key pasted into a prompt, kept for the session. It goes to no host until the
63// person allows one, and then as a bearer token.
64export function pastedGrant(kind: string, value: string): Grant {
65 return { value, hosts: [], header: 'Authorization', template: 'Bearer {}', pasted: kind }
66}
67
68// The host a request's URL names, lowercased, or undefined when the URL is not https://<host>/<path>.
69export function hostOf(url: string): string | undefined {
70 return URL_SHAPE.exec(url)?.[1]?.toLowerCase()
71}
72
73// The fetch a request comes to, or why it does not run. The secret goes in
74// only here, after the host is held to the grant.
75export function checkRequest(vault: Vault, req: Request): Checked | { deny: string } {
76 const grant = vault.get(req.secret)
77 if (grant === undefined) return { deny: `no secret is named "${req.secret}"` }
78
79 const host = hostOf(req.url)
80 if (host === undefined) return { deny: 'the URL must be https://<host>/<path>, with no user info or port' }
81
82 if (!grant.hosts.includes(host)) return { deny: `"${req.secret}" may be sent to ${grant.hosts.join(', ')} only, not ${host}` }
83
84 const method = (req.method ?? 'GET').toUpperCase()
85 if (!METHODS.has(method)) return { deny: `the method ${method} is not allowed` }
86
87 const headers: Record<string, string> = {}
88 for (const [name, value] of Object.entries(req.headers ?? {})) {
89 if (/[\r\n]/.test(name) || /[\r\n]/.test(value)) return { deny: 'a header holds a line break' }
90 // The model may not set the header the secret rides in, nor where it goes.
91 if (name.toLowerCase() === grant.header.toLowerCase() || name.toLowerCase() === 'host') continue
92 headers[name] = value
93 }
94 headers[grant.header] = grant.template.replace('{}', grant.value)
95
96 return { url: req.url, init: { method, headers, ...(req.body === undefined ? {} : { body: req.body }) } }
97}
98
99// Every spelling of each secret a response or output is checked for: the value
100// as is, URL-encoded, base64 (standard and URL-safe, unpadded) and hex.
101// Each pairs with the name the model reads in its place.
102export function spellings(vault: Vault): readonly [string, string][] {
103 const out: [string, string][] = []
104
105 for (const [name, { value }] of vault) {
106 const b64 = base64(value)
107 const forms = new Set([
108 value,
109 encodeURIComponent(value),
110 b64.replace(/=+$/, ''),
111 b64.replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_'),
112 hex(value),
113 hex(value).toUpperCase(),
114 ])
115 for (const form of forms) out.push([form, name])
116 }
117
118 // Longest first, so a spelling that holds another is replaced whole.
119 return out.sort((a, b) => b[0].length - a[0].length)
120}
121
122export function redactKnown(text: string, known: readonly [string, string][], kinds: string[] = []): string {
123 return known.reduce((out, [form, name]) => {
124 if (!out.includes(form)) return out
125 kinds.push(`stored secret "${name}"`)
126 return out.split(form).join(`[redacted: stored secret "${name}"]`)
127 }, text)
128}
129
130function utf8(text: string): number[] {
131 return [...encodeURIComponent(text)].reduce<{ bytes: number[]; pct: string }>(
132 (acc, ch) => {
133 if (acc.pct !== '' || ch === '%') {
134 acc.pct += ch
135 if (acc.pct.length === 3) {
136 acc.bytes.push(parseInt(acc.pct.slice(1), 16))
137 acc.pct = ''
138 }
139 } else {
140 acc.bytes.push(ch.charCodeAt(0))
141 }
142 return acc
143 },
144 { bytes: [], pct: '' },
145 ).bytes
146}
147
148function hex(text: string): string {
149 return utf8(text)
150 .map(b => b.toString(16).padStart(2, '0'))
151 .join('')
152}
153
154const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
155
156function base64(text: string): string {
157 const bytes = utf8(text)
158 let out = ''
159 for (let i = 0; i < bytes.length; i += 3) {
160 const [a = 0, b = 0, c = 0] = bytes.slice(i, i + 3)
161 const n = (a << 16) | (b << 8) | c
162 out += B64.charAt((n >> 18) & 63) + B64.charAt((n >> 12) & 63)
163 out += i + 1 < bytes.length ? B64.charAt((n >> 6) & 63) : '='
164 out += i + 2 < bytes.length ? B64.charAt(n & 63) : '='
165 }
166 return out
167}
168