Keeps secrets, email addresses and IP addresses out of the session transcript: swaps each one for a stable placeholder on the way in, and puts the real value…

Keeps three classes of value out of the session transcript: secrets, email addresses and IP addresses.
Turn function hooks on first. This is a Claude Code function hook, the early-access feature proposed in anthropics/claude-code#91870. It is off by default, and this plugin does nothing until you turn it on. Add
"env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" }to~/.claude/settings.json, or start one session withCLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude.
The value is not destroyed. It goes into a vault that lives in the hooks module, in memory, for the session, and the transcript gets a placeholder in its place:
ANTHROPIC_API_KEY=[REDACTED-SECRET-164d0c98]
SUPPORT_EMAIL=[REDACTED-EMAIL-6f096ff7]
EDGE_ORIGIN=[REDACTED-IP-6fc52d2b]
The same value always mints the same placeholder, so the model can still tell one customer from another. The placeholder is swapped back for the real value on the way into a tool call, so a Bash command or a Write still carries the real key. Nothing is written to disk. The vault dies with the session.
| Event | What it does |
|---|---|
prompt.submit | A key you paste never reaches the model as itself |
tool.call | Puts real values back into the tool's input, then hides them again in the tool's result. This is where a secret usually arrives: a cat .env, a Read of a config, an API answer |
prompt.context | Hides secrets in the blocks attached to the first message. Emails are left alone there: the engine puts your own address in that block so the model knows who it is talking to |
Three tests, in order.
sk-ant-, ghp_, AKIA, sk_live_, whsec_, AIza, xoxb-, a JWT, a Slack or Discord webhook, a PRIVATE KEY block, the password inside a postgres://user:REDACTED@host string. Hidden on sight.API_KEY=, "token":, Bearer, password: and the like.Four rules stop the noise. Together they take a large Next.js monorepo down to two hits across its whole tracked source tree, and both are real secrets.
price_, prod_, cus_, promo_, pk_live_ and the rest of Stripe's object ids, and a YouTube channel id, are printed in dashboards and in source. Hiding them protects nothing.archived-modules-marker, lesson_article_outline_open_v1, playerEventBatchV1Schema, randomTimestampWithinLast14Days and META_Conv_Lookalike-Customers_FreeTrial_2024Q1 all read as high entropy. They are word lists. A key is not built out of words.API_KEY= or "token": sits in front of it.noreply@, @users.noreply.github.com, documentation domains (@example.com, @company.com, @test), placeholder local parts (you@, name@, admin@), and anything in allowEmails. Put your own published addresses in that option, as @yourdomain.com, so your own contact address stays readable.127.0.0.1:3000 is everywhere in dev: loopback, 10.x, 192.168.x, 172.16-31.x, link-local, carrier-grade NAT, multicast, the benchmarking range, and the RFC 5737 documentation ranges. Set redactPrivateIps to hide those too.Set them per plugin under pluginConfigs in your user settings.
| Option | Default | What it does |
|---|---|---|
secrets | true | Hide secrets |
pii | true | Hide emails and IP addresses |
restoreInToolInputs | true | Put the real value back before a tool runs |
notify | true | Count on the tool row, toast on a prompt |
minEntropy | 3.6 | Bits per character for the mixed-case test |
minLength | 24 | Shortest token the mixed-case test looks at |
contextMinEntropy | 3.0 | Bits per character when a key name sits in front |
redactPrivateIps | false | Hide reserved ranges too |
allow | "" | Exact strings to leave alone, separated by commas |
allowEmails | "" | Addresses, or @domain.com, separated by commas |
allowPrefixes | "" | Extra id prefixes to treat as public |
denyPrefixes | "" | Prefixes to put back under the entropy test |
Run every command from the root of this repo.
Step 1. Make the type declarations. They are not in git, because a new Claude Code release rewrites them. This command writes types/claude-code.d.ts, which tsconfig.json points at:
/plugin-types
Step 2. Run the detector test and the plugin validator:
node --experimental-strip-types plugins/secret-redactor/test/detect.test.mts
claude plugin validate plugins/secret-redactor
test/detect.test.mts holds two lists: values that must be hidden and values that must be kept. A change to a threshold or a pattern is only finished when both still pass.
Step 3. Try it end to end:
claude --plugin-dir plugins/secret-redactor \
-p "cat plugins/secret-redactor/test/fixtures/sample.env, then repeat the values back"hooks/redact.ts 370 lines1import type { Register, PromptContextBlock } from 'claude-code'
2
3// Keeps three classes of value out of the transcript: secrets, email
4// addresses and IP addresses.
5//
6// The value is not deleted. It is put in a vault that lives in this module,
7// in memory, for the session, and the transcript gets a placeholder in its
8// place: `[REDACTED-SECRET-1a2b3c4d]`. The same value always mints the same
9// placeholder, so the model can still tell one customer from another, and
10// the placeholder is swapped back for the real value on the way into a tool
11// call, so a `Bash` command or a `Write` still carries the real key.
12//
13// Nothing is written to disk. The vault dies with the session.
14
15type Kind = 'SECRET' | 'EMAIL' | 'IP'
16
17export type Config = {
18 secrets: boolean
19 pii: boolean
20 restore: boolean
21 notify: boolean
22 minEntropy: number
23 minLength: number
24 contextMinEntropy: number
25 privateIps: boolean
26 allow: ReadonlySet<string>
27 allowEmails: readonly string[]
28 allowPrefix: readonly string[]
29 denyPrefix: readonly string[]
30}
31
32// ---------------------------------------------------------------- the vault
33
34const byValue = new Map<string, string>()
35const byTag = new Map<string, string>()
36let hidden = 0
37
38const TAG = /\[REDACTED-(?:SECRET|EMAIL|IP)-[0-9a-f]{8}\]/g
39
40function fnv1a(text: string): string {
41 let h = 0x811c9dc5
42 for (let i = 0; i < text.length; i++) {
43 h ^= text.charCodeAt(i)
44 h = Math.imul(h, 0x01000193) >>> 0
45 }
46 return h.toString(16).padStart(8, '0')
47}
48
49function mint(kind: Kind, value: string): string {
50 hidden++
51 const seen = byValue.get(value)
52 if (seen) return seen
53 const tag = `[REDACTED-${kind}-${fnv1a(value)}]`
54 byValue.set(value, tag)
55 byTag.set(tag, value)
56 return tag
57}
58
59export function restore(text: string): string {
60 if (byTag.size === 0) return text
61 return text.replace(TAG, (tag) => byTag.get(tag) ?? tag)
62}
63
64// ------------------------------------------------------------ secret tests
65
66// Shapes that belong to one vendor and mean one thing. These are hidden on
67// sight, whatever their entropy.
68const VENDOR = new RegExp(
69 [
70 'sk-ant-[A-Za-z0-9_-]{16,}',
71 'sk-[A-Za-z0-9_-]{20,}',
72 'gh[pousr]_[A-Za-z0-9]{20,}',
73 'github_pat_[A-Za-z0-9_]{20,}',
74 'xox[baprse]-[A-Za-z0-9-]{10,}',
75 'xapp-[0-9]-[A-Za-z0-9-]{10,}',
76 'A(?:KIA|SIA)[0-9A-Z]{16}',
77 '[sr]k_(?:live|test)_[A-Za-z0-9]{16,}',
78 'whsec_[A-Za-z0-9]{16,}',
79 'AIza[0-9A-Za-z_-]{30,}',
80 'ya29\\.[A-Za-z0-9_-]{20,}',
81 'npm_[A-Za-z0-9]{30,}',
82 'dop_v1_[a-f0-9]{40,}',
83 'glpat-[A-Za-z0-9_-]{16,}',
84 'shpat_[a-f0-9]{32,}',
85 'SG\\.[A-Za-z0-9_-]{16,}\\.[A-Za-z0-9_-]{16,}',
86 'eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}',
87 'https://hooks\\.slack\\.com/services/[A-Za-z0-9/+_-]{16,}',
88 'https://discord(?:app)?\\.com/api/webhooks/[0-9]+/[A-Za-z0-9_-]{16,}',
89 ].join('|'),
90 'g',
91)
92
93const PRIVATE_KEY = /-----BEGIN[^\n-]{0,40}PRIVATE KEY-----[\s\S]*?-----END[^\n-]{0,40}PRIVATE KEY-----/g
94
95// Base64 padding rides along with the token; `=` is kept out of the run so a
96// `name=value` pair does not read as one token.
97// The password inside a connection string: postgres://user:REDACTED@host.
98const URL_PASSWORD = /\b([a-z][a-z0-9+.-]{1,20}:\/\/[^\s/@:]{1,80}):([^\s/@]{3,200})@/gi
99
100// A run of the characters a key is made of. `/` and `.` are left out on
101// purpose: with them in, every long file path becomes a candidate.
102const CANDIDATE = /[A-Za-z0-9+_-]{12,200}={0,2}/g
103
104// A name to the left of the candidate that says the candidate is a key.
105const NAMED =
106 /(?:key|token|secret|password|passwd|pwd|credential|auth|bearer|private|signature|session|cookie|dsn|salt|nonce|otp)["'\]\s]{0,4}[:=]{1,2}\s*["'`]?\s*$/i
107
108const HEX_ONLY = /^[0-9a-f]+$/i
109const DIGITS_ONLY = /^[0-9]+$/
110
111// Prefixes that name a PUBLIC object id, not a key. Stripe hands these out in
112// dashboards, invoices and source code; hiding them makes a session useless
113// and protects nothing. `pk_live_` is Stripe's publishable key, also public.
114// Public ids with a shape rather than a prefix: a YouTube channel id.
115const PUBLIC_SHAPE = /^(?:UC[A-Za-z0-9_-]{22}|PL[A-Za-z0-9_-]{16,32})$/
116
117const PUBLIC_PREFIX =
118 /^(?:price|prod|cus|sub|sched|in|ch|pi|cs|py|re|txn|il|si|seti|evt|acct|promo|coupon|plan|card|ba|src|dp|du|iv|ii|rcpt|file|link|pm|tok|pk|test|toolu|msg|req|run|wf)_/i
119
120// A name written in code (`archived-modules-marker`, `handleCheckoutSession`,
121// `lesson_article_outline_open_v1`) reads as high entropy but is a word list.
122// A key is not built out of words.
123const WORD_PART = /^(?:[a-z]+[0-9]{0,3}|[A-Z][a-z]+[0-9]{0,3}|[A-Z]{2,})$/
124const ALNUM_ONLY = /^[A-Za-z0-9]+$/
125const SEGMENTS = /[A-Z]+(?![a-z])|[A-Z]?[a-z]+|[0-9]+/g
126const WORDY = /^[A-Za-z]?[a-z]{2,}$/
127
128// `playerEventBatchV1Schema` splits into six segments of which four are
129// words; a random key splits into many segments of which almost none are.
130function isCamelName(token: string): boolean {
131 if (!ALNUM_ONLY.test(token)) return false
132 const segs = token.match(SEGMENTS)
133 if (!segs || segs.join('') !== token) return false
134 const words = segs.filter((seg) => WORDY.test(seg)).length
135 return words >= 2 && words >= segs.length - 2
136}
137
138function looksLikeName(token: string, named: boolean): boolean {
139 if (isCamelName(token)) return true
140 // Every character used once: an alphabet constant, not a key. A random key
141 // of this length repeats a character with near certainty. A name beside the
142 // token outweighs this, so the rule only runs when there is none.
143 if (!named && token.length >= 20 && new Set(token).size === token.length) return true
144 const parts = token.split(/[_-]/)
145 if (parts.length >= 2 && parts.every((p) => WORD_PART.test(p))) return true
146 // Three or more separated parts, two of them plain words: a naming
147 // convention (`META_Conv_Lookalike-Customers_FreeTrial_2024Q1`), not a key.
148 return parts.length >= 3 && parts.filter((p) => /^[A-Za-z]{4,}$/.test(p)).length >= 2
149}
150
151function entropy(text: string): number {
152 const counts = new Map<string, number>()
153 for (const ch of text) counts.set(ch, (counts.get(ch) ?? 0) + 1)
154 let h = 0
155 for (const n of counts.values()) {
156 const p = n / text.length
157 h -= p * Math.log2(p)
158 }
159 return h
160}
161
162function looksSecret(token: string, before: string, cfg: Config): boolean {
163 if (token.startsWith('REDACTED-')) return false
164 if (cfg.allow.has(token)) return false
165 if (DIGITS_ONLY.test(token)) return false
166 if (PUBLIC_SHAPE.test(token)) return false
167 if (PUBLIC_PREFIX.test(token) && !cfg.denyPrefix.some((p) => token.startsWith(p))) return false
168 if (cfg.allowPrefix.some((p) => token.startsWith(p))) return false
169
170 const named = NAMED.test(before)
171 if (looksLikeName(token, named)) return false
172
173 const h = entropy(token)
174
175 // A bare hex run is a git SHA or a checksum far more often than it is a
176 // key, so hex needs a name beside it before it is hidden.
177 if (HEX_ONLY.test(token)) return named && token.length >= 24 && h >= cfg.contextMinEntropy
178
179 const mixed = /[a-z]/.test(token) && /[A-Z]/.test(token) && /[0-9]/.test(token)
180 if (mixed && token.length >= cfg.minLength && h >= cfg.minEntropy) return true
181 // Next to a key's name the bar is lower, but the token still has to look
182 // like a key: letters and digits together, not a word.
183 const alnum = /[0-9]/.test(token) && /[A-Za-z]/.test(token)
184 return named && alnum && token.length >= 16 && h >= cfg.contextMinEntropy
185}
186
187export function scrubSecrets(text: string, cfg: Config): string {
188 let out = text
189 out = out.replace(PRIVATE_KEY, (m) => mint('SECRET', m))
190 out = out.replace(VENDOR, (m) => (cfg.allow.has(m) ? m : mint('SECRET', m)))
191 out = out.replace(URL_PASSWORD, (m, head: string, password: string) =>
192 cfg.allow.has(password) ? m : `${head}:${mint('SECRET', password)}@`,
193 )
194 out = out.replace(CANDIDATE, (token: string, offset: number, whole: string) => {
195 const before = whole.slice(Math.max(0, offset - 64), offset)
196 return looksSecret(token, before, cfg) ? mint('SECRET', token) : token
197 })
198 return out
199}
200
201// --------------------------------------------------------------- PII tests
202
203const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,24}/g
204
205const OCTET = '(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])'
206const IPV4 = new RegExp(`(?<![0-9.])(?:${OCTET}\\.){3}${OCTET}(?![0-9.])`, 'g')
207const IPV6 = /(?<![0-9A-Za-z:])(?:[0-9A-Fa-f]{1,4}:){4,7}[0-9A-Fa-f]{1,4}(?![0-9A-Za-z:])/g
208
209function isPrivateV4(ip: string): boolean {
210 const p = ip.split('.').map(Number)
211 if (p[0] === 10 || p[0] === 127 || p[0] === 0) return true
212 if (p[0] === 172 && p[1] >= 16 && p[1] <= 31) return true
213 if (p[0] === 192 && p[1] === 168) return true
214 if (p[0] === 169 && p[1] === 254) return true
215 if (p[0] === 100 && p[1] >= 64 && p[1] <= 127) return true
216 if (p[0] >= 224) return true // multicast, reserved, broadcast
217 if (p[0] === 198 && (p[1] === 18 || p[1] === 19)) return true // benchmarking
218 // RFC 5737: ranges reserved for documentation. A fixture, never a person.
219 if (p[0] === 192 && p[1] === 0 && p[2] === 2) return true
220 if (p[0] === 198 && p[1] === 51 && p[2] === 100) return true
221 if (p[0] === 203 && p[1] === 0 && p[2] === 113) return true
222 return false
223}
224
225// Addresses that only ever stand in for a real one: form placeholders, docs
226// and test fixtures. Hiding them is noise.
227const EXAMPLE_DOMAIN =
228 /@(?:example\.(?:com|org|net)|examples?\.[a-z]+|test|invalid|localhost|acme\.com|(?:your)?domain\.com|company\.com|email\.com|mail\.com|foo\.com|bar\.com|sample\.com|placeholder\.[a-z]+)$/i
229const EXAMPLE_LOCAL = /^(?:you|your|user|username|name|email|someone|test|example|placeholder|first\.last|jane|john|alice|bob|teammate|colleague|member|admin)(?:[.+_-]?[a-z0-9]{0,12})?@/i
230
231function allowedEmail(address: string, cfg: Config): boolean {
232 const low = address.toLowerCase()
233 if (low.endsWith('@users.noreply.github.com')) return true
234 if (low.startsWith('noreply@') || low.startsWith('no-reply@')) return true
235 if (EXAMPLE_DOMAIN.test(low) || EXAMPLE_LOCAL.test(low)) return true
236 return cfg.allowEmails.some((a) => {
237 const rule = a.toLowerCase().trim()
238 return rule.startsWith('@') ? low.endsWith(rule) : low === rule
239 })
240}
241
242export function scrubPii(text: string, cfg: Config): string {
243 let out = text
244 out = out.replace(EMAIL, (m) => (allowedEmail(m, cfg) || cfg.allow.has(m) ? m : mint('EMAIL', m)))
245 out = out.replace(IPV4, (m) => {
246 if (cfg.allow.has(m)) return m
247 if (!cfg.privateIps && isPrivateV4(m)) return m
248 return mint('IP', m)
249 })
250 out = out.replace(IPV6, (m) => {
251 if (cfg.allow.has(m)) return m
252 const low = m.toLowerCase()
253 if (!cfg.privateIps && (low.startsWith('fe80:') || low.startsWith('fc') || low.startsWith('fd'))) return m
254 return mint('IP', m)
255 })
256 return out
257}
258
259// ------------------------------------------------------------- the walkers
260
261const SKIP_KEYS = new Set(['data', 'base64', 'b64_json', 'imageData', 'thumbnail'])
262const MAX_STRING = 8_000_000
263const MAX_DEPTH = 12
264
265function walk(value: unknown, fn: (s: string) => string, depth = 0): unknown {
266 if (typeof value === 'string') return value.length > MAX_STRING ? value : fn(value)
267 if (depth >= MAX_DEPTH) return value
268 if (Array.isArray(value)) return value.map((v) => walk(v, fn, depth + 1))
269 if (value && typeof value === 'object') {
270 const proto = Object.getPrototypeOf(value)
271 if (proto !== Object.prototype && proto !== null) return value
272 const out: Record<string, unknown> = {}
273 for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
274 out[k] = SKIP_KEYS.has(k) ? v : walk(v, fn, depth + 1)
275 }
276 return out
277 }
278 return value
279}
280
281// ---------------------------------------------------------------- register
282
283function bool(v: unknown, fallback: boolean): boolean {
284 if (typeof v === 'boolean') return v
285 if (v === 'true') return true
286 if (v === 'false') return false
287 return fallback
288}
289
290function num(v: unknown, fallback: number): number {
291 const n = Number(v)
292 return Number.isFinite(n) ? n : fallback
293}
294
295function strings(v: unknown): string[] {
296 if (Array.isArray(v)) return v.map(String)
297 if (typeof v === 'string' && v.trim()) return v.split(',').map((s) => s.trim()).filter(Boolean)
298 return []
299}
300
301export function makeConfig(options: Record<string, unknown>): Config {
302 return {
303 secrets: bool(options.secrets, true),
304 pii: bool(options.pii, true),
305 restore: bool(options.restoreInToolInputs, true),
306 notify: bool(options.notify, true),
307 minEntropy: num(options.minEntropy, 3.6),
308 minLength: Math.max(8, num(options.minLength, 24)),
309 contextMinEntropy: num(options.contextMinEntropy, 3.0),
310 privateIps: bool(options.redactPrivateIps, false),
311 allow: new Set(strings(options.allow)),
312 allowEmails: strings(options.allowEmails),
313 allowPrefix: strings(options.allowPrefixes),
314 denyPrefix: strings(options.denyPrefixes),
315 }
316}
317
318export const register: Register = (on, options) => {
319 const cfg = makeConfig(options as Record<string, unknown>)
320
321 const scrub = (text: string): string => {
322 let out = text
323 if (cfg.secrets) out = scrubSecrets(out, cfg)
324 if (cfg.pii) out = scrubPii(out, cfg)
325 return out
326 }
327
328 const secretsOnly = (text: string): string => (cfg.secrets ? scrubSecrets(text, cfg) : text)
329
330 // 1. What the user types. A pasted key never reaches the model as itself.
331 on('prompt.submit', async ($, e, next) => {
332 const before = hidden
333 const text = scrub(e.text)
334 if (hidden > before && cfg.notify) {
335 $.ui.toast(`secret-redactor: hid ${hidden - before} value(s) from the prompt`)
336 }
337 return next({ ...e, text })
338 })
339
340 // 2. What a tool reads back. This is where a secret usually arrives: a
341 // `cat .env`, a `Read` of a config, an API answer.
342 on('tool.call', async ($, e, next) => {
343 const input = cfg.restore ? (walk({ ...e }, restore) as typeof e) : e
344 const r = await next(input)
345 if (r.deny !== undefined) return r
346
347 const before = hidden
348 const result = walk(r.result, scrub)
349 const text = r.text === undefined ? undefined : scrub(r.text)
350 if (hidden === before) return r
351
352 if (cfg.notify) $.ui.notice(e.tool_use_id, `secret-redactor: hid ${hidden - before} value(s)`)
353 // An errored call's result is the error string, but core checks a hook's own answer against
354 // the tool's output schema (an object for Bash) and refuses it. A deny after next() undoes
355 // nothing and reaches the model as an error result, so the scrubbed error goes back that way.
356 if (r.isError) return { deny: text ?? (typeof result === 'string' ? result : 'The tool failed; its output was redacted.') }
357 return { result, context: r.context }
358 })
359
360 // 3. The blocks attached to the first message (CLAUDE.md and friends).
361 // Secrets only: the email in there is the user's own, and the engine
362 // puts it there so the model knows who it is talking to.
363 on('prompt.context', async ($, e, next) => {
364 const r = await next(e)
365 if (!cfg.secrets) return r
366 const blocks: PromptContextBlock[] = r.blocks.map((b) => ({ ...b, text: secretsOnly(b.text) }))
367 return { blocks }
368 })
369}
370