SLOPSHOPPER

jr-redact

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.

newguardtoast
★ 2v0.1.0MITupdated 2026-10-15jrlprost/claude-code-mods/plugins/jr-redact
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jr-redact
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ jr-redact │ ⏺ Read(src/auth.ts) │ jr redact · hid 1 stripe-key, 1 password │ ⎿ Read 6 lines │ from Claude in Bash │ ⏺ 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

jr-redact

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
KindFormat
private-keyPEM blocks (-----BEGIN … PRIVATE KEY-----), removed whole
anthropic-keysk-ant-…
openai-keysk-…, sk-proj-…
stripe-keysk_live_…, sk_test_…, rk_live_…
github-tokenghp_, gho_, ghu_, ghs_, ghr_, github_pat_
aws-keyAKIA…, ASIA…
slack-tokenxoxb-, xoxp- and the other xox prefixes
google-keyAIza…
jwteyJ….….…
passwordthe password part of postgres://, mysql://, mongodb://, redis://, amqp:// URLs; the host stays readable
email, iponly 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.

Settings (/config)

OptionDefault
Also hide emails and IP addressesoffReplace email addresses and IPv4 addresses too

Install

/plugin install jr-redact --marketplace jrlprost/claude-code-mods

Access

Reads and rewrites tool results before the model reads them. Writes nothing, runs nothing, sends nothing over the network.

Source 2 files
hooks/register.ts 19 lines
1import 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}
19
hooks/redact.ts 54 lines
1// 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