SLOPSHOPPER

secret-scrub

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

newguardtoast
★ 1v0.1.0no licenseupdated 2026-10-15funkadelic/claude-secret-scrub
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · secret-scrub
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ secret-scrub │ ⏺ Read(src/auth.ts) │ secret-scrub: redacted a secret from Bash │ ⎿ Read 6 lines │ 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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

secret-scrub

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.

Install

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.

What it catches

  • GitHub, GitLab, Slack, Stripe, Anthropic, OpenAI, AWS and Google keys
  • JWTs and PEM private key blocks
  • Passwords in URLs (https://user:pass@host)
  • api_key=, secret:, token=, password= style assignments whose value looks like a secret

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

Limits

  • Only tool results are scrubbed. A secret you type into a prompt, or one Claude writes into a command, is not touched.
  • Files holding token-shaped test fixtures read back redacted, so an edit aimed at that exact line will not match.
  • Pattern matching is not a guarantee. Keep running a secret scanner such as gitleaks on anything you sync or share.
Source 2 files
hooks/register.ts 24 lines
1import 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}
24
hooks/scrub.ts 47 lines
1// 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