Redacts API keys, tokens, private keys and .env secrets from tool output before the transcript stores it and before the model reads it.

Redacts secrets from tool output before Claude Code stores it in the transcript and before the model reads it. Each secret becomes [REDACTED:<kind>]; the text around it is left as it was.
/plugin marketplace add troyjlorents-gh/mod-squad
/plugin install secret-scrubber@mod-squad
| Kind | Matches |
|---|---|
aws-access-key-id | AKIA… / ASIA… + 16 characters |
aws-secret | the value of aws_secret_access_key = … |
github-token | ghp_, gho_, ghu_, ghs_, ghr_, github_pat_ |
anthropic-key | sk-ant-… |
openai-key | sk-… / sk-proj-… keys (32+ mixed-case characters with digits, so sk- in prose is left alone) |
azure-storage-key | AccountKey=… / SharedAccessKey=… in connection strings |
azure-sas | SharedAccessSignature=… and sig=… in connection strings and SAS URLs |
azure-api-key | api-key: / Ocp-Apim-Subscription-Key: header values |
slack-token | xoxa-, xoxb-, xoxp-, xoxo-, xoxs-, xoxr- |
stripe-key | sk_live_…, rk_live_… |
google-api-key | AIza… + 35 characters |
jwt | three base64url segments starting eyJ |
private-key | whole PEM -----BEGIN … PRIVATE KEY----- blocks (a block cut off by truncated output is redacted to its end) |
env-secret | the value (only) of KEY=value where KEY has a SECRET, TOKEN, PASSWORD, PASSWD, API_KEY or PRIVATE_KEY segment |
custom | your own extraPatterns |
Only rows that come in as a tool's result (and rows a tool hands over beside it) are rewritten. Your own prompts and Claude's responses are never touched. Images and documents in tool output pass through unchanged. Token matches must stand on their own, so key-shaped runs inside base64 data, hashes and identifiers are not redacted, and env lines that hold references or code ($VAR, process.env.X, max_tokens=4096) are left alone.
If redaction itself ever fails on a row, the scrubber fails closed: that row's output is replaced by a short "withheld because redaction failed" notice instead of being stored as it was.
/scrub: counts redacted this session, by kind./scrub off / /scrub on: pause or resume redaction for this session.The status line shows 🔒 N secrets redacted once anything has been redacted, and a toast appears the first time each kind is seen.
extraPatterns (default empty): comma-separated extra regular expressions, redacted as [REDACTED:custom]. A pattern cannot itself contain a comma. Invalid ones (or ones that match an empty string) are ignored, and a toast names them.This is pattern matching, not a guarantee: a secret in a format it does not know (or split across lines) gets through, and an odd string that looks like a key may be redacted. It only sees what tools hand to the conversation: output your own commands already printed to your terminal, files on disk, and what a tool sends elsewhere are out of its reach. The screen may briefly show a tool result before its redacted form is stored; the model and the transcript file only get the redacted form.
Test: claude plugin test mods/secret-scrubber. Validate: claude plugin validate mods/secret-scrubber.
hooks/register.ts 91 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { compileExtra, redactBlocks, sum, withheld } from './redact'
5import type { Block, Counts } from './redact'
6
7const enabled = atom({ plugin: 'secret-scrubber', key: 'enabled' } as const, true)
8const counts = atom({ plugin: 'secret-scrubber', key: 'counts' } as const, {} as Counts)
9
10/** Rows this mod rewrites: a tool's result, and the rows a tool hands over beside it. */
11const DOORS = new Set(['tool-result', 'tool-message'])
12
13type Options = { extraPatterns?: string }
14
15const statusLine = (total: number) => `🔒 ${total} secret${total === 1 ? '' : 's'} redacted`
16
17/** Adds a row's redactions to the session's tally, toasts new kinds and repins the status line. */
18async function tally($: EngineInterface, found: Counts) {
19 const before = await read($, counts)
20 const after = await update($, counts, c => {
21 const out = { ...c }
22 for (const [k, n] of Object.entries(found)) out[k] = (out[k] ?? 0) + n
23 return out
24 })
25 const total = sum(after)
26 for (const kind of Object.keys(found)) {
27 if (!(kind in before)) $.ui.toast(`secret-scrubber: redacted a ${kind} from tool output`)
28 }
29 $.ui.status(statusLine(total))
30}
31
32async function summary($: EngineInterface): Promise<string> {
33 const on = await read($, enabled)
34 const c = await read($, counts)
35 const rows = Object.entries(c).sort((a, b) => b[1] - a[1])
36 const head = `secret-scrubber is ${on ? 'on' : 'OFF for this session'}.`
37 if (rows.length === 0) return `${head} Nothing redacted yet.`
38 return [`${head} Redacted ${sum(c)} this session:`, ...rows.map(([k, n]) => ` ${k}: ${n}`)].join('\n')
39}
40
41export const register: Register = (on, options) => {
42 const opts = (options ?? {}) as Options
43 const extra = compileExtra(opts.extraPatterns)
44
45 on('session.start', async ($, e, next) => {
46 await $.command.register({
47 name: 'scrub',
48 description: 'Secret scrubber: show what was redacted, or turn it off/on for this session',
49 argumentHint: '[off | on]',
50 immediate: true,
51 })
52 if (extra.invalid.length > 0) {
53 $.ui.toast(`secret-scrubber: ignored invalid extraPatterns: ${extra.invalid.join(', ')}`, { timeoutMs: 8000 })
54 }
55 return next(e)
56 })
57
58 on('session.append', async ($, e, next) => {
59 // Only tool output: never the person's prompt, the model's response or anything else.
60 if (!DOORS.has(e.door) || !(await read($, enabled))) return next(e)
61 const r = redactBlocks(e.message.content as Block[], extra.rules)
62 if (r.total === 0) return next(e)
63 const stored = await next({ ...e, message: { ...e.message, content: r.content } })
64 await tally($, r.counts)
65 return stored
66 }).catch(($, e, next) => {
67 // Already stored (the tally failed after `next`): replay the stored row.
68 if (next.called) return next(e)
69 // Fail closed: if redaction itself threw, the output is withheld rather
70 // than stored with whatever secrets it holds. Rows outside the doors we
71 // scrub are passed on as they came.
72 if (!DOORS.has(e.door)) return next(e)
73 return next({ ...e, message: { ...e.message, content: withheld(e.message.content) } })
74 })
75
76 on('command.run', { command: 'scrub' }, async ($, e) => {
77 const verb = e.args.trim().toLowerCase()
78 if (verb === 'off' || verb === 'on') {
79 await update($, enabled, () => verb === 'on')
80 return {
81 text:
82 verb === 'on'
83 ? 'secret-scrubber: on. Tool output is redacted again.'
84 : 'secret-scrubber: OFF for this session. Tool output reaches the transcript and the model unredacted.',
85 }
86 }
87 if (verb !== '') return { text: 'Usage: /scrub [off | on]' }
88 return { text: await summary($) }
89 })
90}
91hooks/redact.ts 214 lines1// Pure redaction: no engine calls, no state. Every rule replaces a secret with
2// `[REDACTED:<kind>]` and leaves the text around it as it was.
3
4export type Rule = {
5 kind: string
6 /** Must carry the `g` flag. */
7 re: RegExp
8 /**
9 * The capture group holding the secret. With it, only that group is replaced
10 * and the rest of the match (a `KEY=` prefix, quotes) is kept; without it,
11 * the whole match is replaced.
12 */
13 group?: number
14 /** A last say on a match: false keeps it as it is. */
15 check?: (secret: string) => boolean
16}
17
18export type Counts = Record<string, number>
19
20export type Redacted = { text: string; counts: Counts; total: number }
21
22export const marker = (kind: string) => `[REDACTED:${kind}]`
23
24// A token's start may not sit inside a longer run of token or base64
25// characters, so a key-shaped run inside an image's base64, a hash or an
26// identifier is left alone.
27const B = '(?<![A-Za-z0-9+/_-])'
28const E = '(?![A-Za-z0-9+/_-])'
29
30const mixed = (s: string) => /[0-9]/.test(s) && /[a-z]/.test(s) && /[A-Z]/.test(s)
31
32const ENV_KEYWORD = '(?:SECRET|TOKEN|PASSWORD|PASSWD|API[_-]?KEY|PRIVATE[_-]KEY)'
33const SEG = '[A-Za-z0-9]+'
34
35/** Values an env-style line holds that are not secrets: references, code, flags. */
36function isEnvSecret(value: string): boolean {
37 const v = value.replace(/^["']|["']$/g, '')
38 if (v === '' || v.startsWith('[REDACTED:')) return false
39 if (/^\$\{?[A-Za-z_][A-Za-z0-9_]*\}?$/.test(v)) return false // $VAR, ${VAR}
40 if (/^(?:true|false|null|none|nil|undefined|yes|no)$/i.test(v)) return false
41 if (/^\d{1,5}$/.test(v)) return false // max_tokens=4096, TOKEN_TTL=300
42 if (/^\*+$/.test(v)) return false // already masked
43 // Code, not a value: process.env.X, os.environ["X"], getToken(), new Foo
44 if (value === v && /^[A-Za-z_$][\w$]*(?:\.[\w$]|\(|\[)/.test(v)) return false
45 return true
46}
47
48export const RULES: readonly Rule[] = [
49 // PEM private key blocks; a block cut off by truncated output is taken to its end.
50 {
51 kind: 'private-key',
52 re: /-----BEGIN ((?:[A-Z0-9]+ )*)PRIVATE KEY-----(?:[A-Za-z0-9+/=\s]|\\[nr]|(?<=\n)[A-Za-z-]+: [^\n]*)+?(?:-----END \1PRIVATE KEY-----|(?![\s\S]))/g,
53 },
54 { kind: 'jwt', re: new RegExp(`${B}eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}${E}`, 'g') },
55 { kind: 'anthropic-key', re: new RegExp(`${B}sk-ant-[A-Za-z0-9_-]{20,}`, 'g') },
56 {
57 kind: 'openai-key',
58 re: new RegExp(`${B}sk-(?:proj-|svcacct-|admin-)?([A-Za-z0-9_-]{32,})${E}`, 'g'),
59 check: s => mixed(s.replace(/^sk-(?:proj-|svcacct-|admin-)?/, '')),
60 },
61 { kind: 'aws-access-key-id', re: new RegExp(`${B}(?:AKIA|ASIA)[A-Z0-9]{16}${E}`, 'g') },
62 {
63 kind: 'aws-secret',
64 re: /(aws_secret_access_key["']?\s*[:=]\s*["']?)([A-Za-z0-9/+=]{20,})/gi,
65 group: 2,
66 },
67 { kind: 'github-token', re: new RegExp(`${B}(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,})`, 'g') },
68 { kind: 'slack-token', re: new RegExp(`${B}xox[abposr]-[A-Za-z0-9-]{10,}`, 'g') },
69 { kind: 'stripe-key', re: new RegExp(`${B}(?:sk|rk)_live_[A-Za-z0-9]{16,}`, 'g') },
70 { kind: 'google-api-key', re: new RegExp(`${B}AIza[0-9A-Za-z_-]{35}${E}`, 'g') },
71 // Azure: storage/service-bus keys and SAS signatures inside connection strings and URLs.
72 { kind: 'azure-storage-key', re: /((?:AccountKey|SharedAccessKey)=)([A-Za-z0-9+/]{20,}={0,2})/g, group: 2 },
73 { kind: 'azure-sas', re: /(SharedAccessSignature=)([^;\s"'<>]+)/g, group: 2 },
74 { kind: 'azure-sas', re: /([?&;]sig=)([A-Za-z0-9%+/=]{16,})/g, group: 2 },
75 // Azure OpenAI / Cognitive Services keys in headers and configs.
76 {
77 kind: 'azure-api-key',
78 re: /((?<![A-Za-z0-9_])(?:api-key|ocp-apim-subscription-key)["']?\s*[:=]\s*["']?)([A-Za-z0-9]{32,})/gi,
79 group: 2,
80 },
81 // .env-style KEY=value where KEY names a secret: only the value goes.
82 {
83 kind: 'env-secret',
84 re: new RegExp(
85 `((?<![A-Za-z0-9_.])(?:${SEG}[_.-])*${ENV_KEYWORD}(?:[_.-]${SEG})*["']?\\s*=(?!=)[ \\t]*)("[^"\\n]*"|'[^'\\n]*'|[^\\s"'&;,]+)`,
86 'gi',
87 ),
88 group: 2,
89 check: isEnvSecret,
90 },
91]
92
93/**
94 * Compiles the person's comma-separated extra patterns. Each valid one becomes
95 * a `custom` rule; the ones that do not compile are handed back by text.
96 */
97export function compileExtra(spec: string | undefined): { rules: Rule[]; invalid: string[] } {
98 const rules: Rule[] = []
99 const invalid: string[] = []
100 for (const raw of (spec ?? '').split(',')) {
101 const source = raw.trim()
102 if (!source) continue
103 try {
104 const re = new RegExp(source, 'g')
105 if (re.test('')) invalid.push(source) // matches nothing at all: would mark every gap
106 else rules.push({ kind: 'custom', re })
107 } catch {
108 invalid.push(source)
109 }
110 }
111 return { rules, invalid }
112}
113
114function applyRule(text: string, rule: Rule, counts: Counts): string {
115 rule.re.lastIndex = 0
116 return text.replace(rule.re, (...args: unknown[]) => {
117 const match = args[0] as string
118 if (match === '') return match
119 const g = rule.group
120 if (g === undefined) {
121 if (rule.check && !rule.check(match)) return match
122 counts[rule.kind] = (counts[rule.kind] ?? 0) + 1
123 return marker(rule.kind)
124 }
125 const secret = args[g] as string | undefined
126 if (!secret || (rule.check && !rule.check(secret))) return match
127 // Groups before `g` are the match's prefix; the secret is the match's tail
128 // or sits right after them.
129 let prefix = ''
130 for (let i = 1; i < g; i++) prefix += (args[i] as string | undefined) ?? ''
131 const rest = match.slice(prefix.length + secret.length)
132 counts[rule.kind] = (counts[rule.kind] ?? 0) + 1
133 // A quoted value keeps its quotes: KEY="[REDACTED:env-secret]".
134 const q = secret[0]
135 const quoted = secret.length >= 2 && (q === '"' || q === "'") && secret.endsWith(q)
136 return prefix + (quoted ? q + marker(rule.kind) + q : marker(rule.kind)) + rest
137 })
138}
139
140/** Redacts every secret the rules (built-ins, then `extra`) find in `text`. */
141export function redactText(text: string, extra: readonly Rule[] = []): Redacted {
142 const counts: Counts = {}
143 let out = text
144 for (const rule of RULES) out = applyRule(out, rule, counts)
145 for (const rule of extra) out = applyRule(out, rule, counts)
146 return { text: out, counts, total: sum(counts) }
147}
148
149export const sum = (counts: Counts) => Object.values(counts).reduce((a, b) => a + b, 0)
150
151export function mergeCounts(into: Counts, from: Counts): Counts {
152 const out = { ...into }
153 for (const [k, n] of Object.entries(from)) out[k] = (out[k] ?? 0) + n
154 return out
155}
156
157/** A content block as the Messages API spells it. */
158export type Block = { type: string; [field: string]: unknown }
159
160/**
161 * Redacts the text a row's blocks carry: text blocks, and each tool_result's
162 * `content` (a string, or a list whose text blocks are redacted). Images,
163 * documents, tool_use and thinking blocks are passed through untouched.
164 * `content` is the original array when nothing changed.
165 */
166export function redactBlocks(content: readonly Block[], extra: readonly Rule[] = []): { content: Block[]; counts: Counts; total: number } {
167 let counts: Counts = {}
168 let changed = false
169 const text = (s: string) => {
170 const r = redactText(s, extra)
171 if (r.total > 0) {
172 changed = true
173 counts = mergeCounts(counts, r.counts)
174 }
175 return r.total > 0 ? r.text : s
176 }
177 const textBlock = (b: Block): Block => (b.type === 'text' && typeof b.text === 'string' ? withField(b, 'text', text(b.text)) : b)
178
179 const out = content.map((b): Block => {
180 if (b.type === 'text') return textBlock(b)
181 if (b.type === 'tool_result') {
182 const c = b.content
183 if (typeof c === 'string') return withField(b, 'content', text(c))
184 if (Array.isArray(c)) {
185 const inner = (c as Block[]).map(textBlock)
186 return inner.some((x, i) => x !== c[i]) ? { ...b, content: inner } : b
187 }
188 }
189 return b
190 })
191 return { content: changed ? out : (content as Block[]), counts, total: sum(counts) }
192}
193
194function withField(b: Block, field: string, value: string): Block {
195 return b[field] === value ? b : { ...b, [field]: value }
196}
197
198export const FAILURE_NOTICE = '[secret-scrubber: this output was withheld because redaction failed]'
199
200/**
201 * The fail-closed form of a row: every text block and every tool_result's
202 * content replaced by a short notice. Written so it cannot throw on odd input.
203 */
204export function withheld(content: unknown): Block[] {
205 if (!Array.isArray(content)) return [{ type: 'text', text: FAILURE_NOTICE }]
206 return content.map((b): Block => {
207 if (!b || typeof b !== 'object') return { type: 'text', text: FAILURE_NOTICE }
208 const block = b as Block
209 if (block.type === 'text') return { ...block, text: FAILURE_NOTICE }
210 if (block.type === 'tool_result') return { ...block, content: FAILURE_NOTICE }
211 return block
212 })
213}
214types/index.d.ts 15 lines1/** Secrets redacted this session, by kind. */
2export type Counts = Record<string, number>
3
4declare module 'claude-code' {
5 interface PluginState {
6 'secret-scrubber': {
7 /** False after `/scrub off`, for the rest of the session. */
8 enabled: boolean
9 /** Secrets redacted this session, by kind (`github-token`, `jwt`, ...). */
10 counts: Counts
11 }
12 }
13}
14
15