SLOPSHOPPER

secret-scrubber

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

newcommandtoaststatus
★ 1v0.1.0no licenseupdated 2026-10-08TroyJLorents-GH/mod-squad/mods/secret-scrubber
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · secret-scrubber
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ secret-scrubber │ ⏺ Read(src/auth.ts) │ secret-scrubber: redacted a stripe-key │ ⎿ Read 6 lines │ from tool output │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /scrub ⎿ secret-scrubber: secret-scrubber is on. Redacted 1 this session: ⎿ secret-scrubber: stripe-key: 1 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ secret-scrubber: 🔒 1 secret redacted
README

secret-scrubber

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

What it redacts

KindMatches
aws-access-key-idAKIA… / ASIA… + 16 characters
aws-secretthe value of aws_secret_access_key = …
github-tokenghp_, gho_, ghu_, ghs_, ghr_, github_pat_
anthropic-keysk-ant-…
openai-keysk-… / sk-proj-… keys (32+ mixed-case characters with digits, so sk- in prose is left alone)
azure-storage-keyAccountKey=… / SharedAccessKey=… in connection strings
azure-sasSharedAccessSignature=… and sig=… in connection strings and SAS URLs
azure-api-keyapi-key: / Ocp-Apim-Subscription-Key: header values
slack-tokenxoxa-, xoxb-, xoxp-, xoxo-, xoxs-, xoxr-
stripe-keysk_live_…, rk_live_…
google-api-keyAIza… + 35 characters
jwtthree base64url segments starting eyJ
private-keywhole PEM -----BEGIN … PRIVATE KEY----- blocks (a block cut off by truncated output is redacted to its end)
env-secretthe value (only) of KEY=value where KEY has a SECRET, TOKEN, PASSWORD, PASSWD, API_KEY or PRIVATE_KEY segment
customyour 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.

Commands

  • /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.

Settings

  • 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.

Limits

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.

Source 3 files
hooks/register.ts 91 lines
1import { 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}
91
hooks/redact.ts 214 lines
1// 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}
214
types/index.d.ts 15 lines
1/** 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