Redacts tokens, keys and passwords from tool results before they reach the model or the session transcript

A Claude Code mod that redacts tokens, keys and passwords from tool output before the model reads it and before it is written to your session transcript (~/.claude/projects/).
When a command prints a token, or Claude reads a file holding one, the secret is replaced with [REDACTED:<kind>]. Output with nothing to redact passes through untouched.
At the prompt of a terminal session:
/plugin install secret-scrub --marketplace funkadelic/claude-secret-scrub
Answer y to add the marketplace, then pick a scope.
From a shell:
claude plugin install secret-scrub --marketplace funkadelic/claude-secret-scrub
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with funkadelic/claude-secret-scrub, then Add plugin.
https://user:pass@host)api_key=, secret:, token=, password= style assignments whose value looks like a secretIt covers every tool, including subagents and MCP tools. If a tool fails and its error text holds a secret, the error is withheld and a scrubbed copy is shown instead. If scrubbing itself fails, the output is withheld rather than passed through.
hooks/register.ts 24 lines1import type { Register } from 'claude-code'
2import { scrubDeep, scrubText } from './scrub.ts'
3
4export const register: Register = on => {
5 // Every tool, subagents included. Untouched results pass through as core's own.
6 on('tool.call', async ($, e, next) => {
7 const ran = await next(e)
8 if (ran.deny !== undefined) return ran
9
10 if (ran.isError) {
11 const text = ran.text ?? ''
12 const clean = scrubText(text)
13 return clean === text ? ran : { deny: clean }
14 }
15
16 const result = scrubDeep(ran.result)
17 if (JSON.stringify(result) === JSON.stringify(ran.result)) return ran
18
19 $.ui.toast(`secret-scrub: redacted a secret from ${e.tool} output`)
20 // A fresh { result } (no ref/text) makes core record this, not the raw output.
21 return { result: result as typeof ran.result, context: ran.context }
22 }).catch($ => ({ deny: `${$.plugin.name}: scrub failed, output withheld.` }))
23}
24hooks/scrub.ts 47 lines1// Shapes drawn from gitleaks findings in synced session transcripts.
2// Order matters: specific prefixes before the generic key=value rule.
3const RULES: ReadonlyArray<[string, RegExp]> = [
4 ['private-key', /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g],
5 ['github-pat', /(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,})\b/g],
6 ['gitlab-pat', /glpat-[A-Za-z0-9_-]{20,}/g],
7 ['stripe-key', /\b[sr]k_(?:live|test)_[A-Za-z0-9]{16,}\b/g],
8 ['slack-token', /xox[abposr]-[A-Za-z0-9-]{10,}/g],
9 ['anthropic-key', /sk-ant-[A-Za-z0-9_-]{20,}/g],
10 ['openai-key', /\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}/g],
11 ['aws-key-id', /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g],
12 ['google-api-key', /\bAIza[0-9A-Za-z_-]{35}\b/g],
13 ['jwt', /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g],
14]
15
16// Keeps the user and host, hides the password in scheme://user:pass@host.
17const URL_CREDS = /(\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)([^\s@/]{3,})(@)/gi
18
19// name=value / name: value where the name says secret and the value looks
20// like one (12+ token chars with a digit and a letter), so code such as
21// `password = process.env.PASSWORD` survives.
22const ASSIGN =
23 /(\b[\w.-]*(?:api[_-]?key|secret|token|passw(?:or)?d|pwd|credential)[\w.-]*["']?\s*[:=]\s*["']?)([A-Za-z0-9_\-+/=]{12,})/gi
24
25const tag = (name: string) => `[REDACTED:${name}]`
26
27export const scrubText = (s: string): string => {
28 let out = s
29 for (const [name, re] of RULES) out = out.replace(re, tag(name))
30 out = out.replace(URL_CREDS, (_m, pre: string, _p, at: string) => `${pre}${tag('password')}${at}`)
31 return out.replace(ASSIGN, (m, pre: string, val: string) =>
32 /\d/.test(val) && /[A-Za-z]/.test(val) && !val.startsWith('[REDACTED') ? `${pre}${tag('secret')}` : m,
33 )
34}
35
36// Rewrites every string in a JSON-shaped value; `base64` fields (images, PDFs) are left whole.
37export const scrubDeep = (v: unknown): unknown => {
38 if (typeof v === 'string') return scrubText(v)
39 if (Array.isArray(v)) return v.map(scrubDeep)
40 if (v && typeof v === 'object') {
41 return Object.fromEntries(
42 Object.entries(v).map(([k, x]) => [k, k === 'base64' ? x : scrubDeep(x)]),
43 )
44 }
45 return v
46}
47