Replaces API keys, tokens, private keys and database passwords in tool results with placeholders before Claude reads them. Emails and IPs too, if you ask.

When a tool result carries a secret (a cat .env, a config file, a log line), the secret is replaced with a placeholder before Claude reads it. The tool itself runs unchanged; only what the model sees is rewritten, and a toast says what was hidden.
STRIPE_SECRET=[REDACTED:stripe-key]
DATABASE_URL=postgres://app:REDACTED@db.internal:5432/main
| Kind | Format |
|---|---|
| private-key | PEM blocks (-----BEGIN … PRIVATE KEY-----), removed whole |
| anthropic-key | sk-ant-… |
| openai-key | sk-…, sk-proj-… |
| stripe-key | sk_live_…, sk_test_…, rk_live_… |
| github-token | ghp_, gho_, ghu_, ghs_, ghr_, github_pat_ |
| aws-key | AKIA…, ASIA… |
| slack-token | xoxb-, xoxp- and the other xox prefixes |
| google-key | AIza… |
| jwt | eyJ….….… |
| password | the password part of postgres://, mysql://, mongodb://, redis://, amqp:// URLs; the host stays readable |
| email, ip | only with the setting on |
Every tool's result is covered: Bash, Read, Grep, web fetches, MCP tools. Errors are left as they are.
Since Claude never sees the real value, it cannot copy it into a file. An edit that tries to match the placeholder fails and Claude reads the file again; secrets are meant to stay out of its reach.
/config)| Option | Default | |
|---|---|---|
| Also hide emails and IP addresses | off | Replace email addresses and IPv4 addresses too |
/plugin install jr-redact --marketplace jrlprost/claude-code-mods
Reads and rewrites tool results before the model reads them. Writes nothing, runs nothing, sends nothing over the network.
hooks/register.ts 19 lines1import type { Register } from 'claude-code'
2
3import { PERSONAL_RULES, redactValue, SECRET_RULES, summarize } from './redact'
4
5export const register: Register = (on, options) => {
6 const rules = [...SECRET_RULES, ...(options.personal === true ? PERSONAL_RULES : [])]
7
8 // After the tool ran, before the model reads its result: secrets become placeholders.
9 on('tool.call', async ($, e, next) => {
10 const ran = await next(e)
11 if (ran.deny !== undefined || ran.isError) return ran
12 const counts: Record<string, number> = {}
13 const result = redactValue(ran.result, rules, counts)
14 if (Object.keys(counts).length === 0) return ran
15 $.ui.toast(`jr redact · hid ${summarize(counts)} from Claude in ${e.tool}`)
16 return { result: result as typeof ran.result, context: ran.context }
17 })
18}
19hooks/redact.ts 54 lines1// Secret formats, most specific first: each match becomes [REDACTED:<kind>].
2
3type Rule = { kind: string; pattern: RegExp; group?: number }
4
5export const SECRET_RULES: Rule[] = [
6 { kind: 'private-key', pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g },
7 { kind: 'anthropic-key', pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}/g },
8 { kind: 'stripe-key', pattern: /\b[sr]k_(?:live|test)_[0-9A-Za-z]{16,}/g },
9 { kind: 'openai-key', pattern: /\bsk-(?:proj-|svcacct-)?[A-Za-z0-9_-]{20,}/g },
10 { kind: 'github-token', pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{20,})/g },
11 { kind: 'aws-key', pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
12 { kind: 'slack-token', pattern: /\bxox[abposr]-[A-Za-z0-9-]{10,}/g },
13 { kind: 'google-key', pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g },
14 { kind: 'jwt', pattern: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g },
15 // Only the password of a connection string: the host and database stay readable.
16 { kind: 'password', pattern: /\b(?:postgres(?:ql)?|mysql|mariadb|mongodb(?:\+srv)?|redis|rediss|amqps?):\/\/[^:\s/@]+:([^@\s]+)@/g, group: 1 },
17]
18
19export const PERSONAL_RULES: Rule[] = [
20 { kind: 'email', pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g },
21 { kind: 'ip', pattern: /\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b/g },
22]
23
24/** Redacts one string; counts what it replaced. */
25export const redactText = (text: string, rules: Rule[], counts: Record<string, number>) => {
26 let out = text
27 for (const rule of rules) {
28 out = out.replace(rule.pattern, (match, ...groups) => {
29 counts[rule.kind] = (counts[rule.kind] ?? 0) + 1
30 if (rule.group) {
31 const secret = groups[rule.group - 1] as string
32 return match.replace(secret, `[REDACTED:${rule.kind}]`)
33 }
34 return `[REDACTED:${rule.kind}]`
35 })
36 }
37 return out
38}
39
40/** Walks any value and redacts every string in it, keeping its shape. */
41export const redactValue = (value: unknown, rules: Rule[], counts: Record<string, number>): unknown => {
42 if (typeof value === 'string') return redactText(value, rules, counts)
43 if (Array.isArray(value)) return value.map(v => redactValue(v, rules, counts))
44 if (value && typeof value === 'object') {
45 return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([k, v]) => [k, redactValue(v, rules, counts)]))
46 }
47 return value
48}
49
50export const summarize = (counts: Record<string, number>) =>
51 Object.entries(counts)
52 .map(([kind, n]) => `${n} ${kind}${n > 1 ? 's' : ''}`)
53 .join(', ')
54