SLOPSHOPPER

secret-redactor

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…

newguardtoastprompt
A shopper browsing a rack in a slop shop
README

secret-redactor

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 with CLAUDE_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.

The three hooks

EventWhat it does
prompt.submitA key you paste never reaches the model as itself
tool.callPuts 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.contextHides 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

What counts as a secret

Three tests, in order.

  1. A vendor shape. 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.
  2. Entropy plus mixed case. A token of 24 characters or more that carries lower case, upper case and digits, above 3.6 bits per character.
  3. Entropy plus a name. A shorter token, above 3.0 bits per character, that sits after 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.

  • A public id is not a secret. 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.
  • A name is not a key. 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.
  • An alphabet is not a key. A token of 20 characters or more that uses every character once is a charset constant. A real key of that length repeats a character with near certainty.
  • Bare hex needs a name beside it. A 40-character hex run is a git SHA or a checksum far more often than it is a key, so hex only counts as a secret when API_KEY= or "token": sits in front of it.

What counts as PII

  • Email addresses. Skipped: 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.
  • IP addresses, v4 and v6. Private and reserved ranges stay visible by default, because 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.

Options

Set them per plugin under pluginConfigs in your user settings.

OptionDefaultWhat it does
secretstrueHide secrets
piitrueHide emails and IP addresses
restoreInToolInputstruePut the real value back before a tool runs
notifytrueCount on the tool row, toast on a prompt
minEntropy3.6Bits per character for the mixed-case test
minLength24Shortest token the mixed-case test looks at
contextMinEntropy3.0Bits per character when a key name sits in front
redactPrivateIpsfalseHide 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

Two things it does not do

  • It does not hide a secret the model itself writes. Only what the model reads is scanned. A key the model types into a command is a key it already had.
  • It does not survive a restart. The vault is memory only. Placeholders from an earlier session are meaningless in a new one.

Testing

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"
Source 1 files
hooks/redact.ts 370 lines
1import 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