SLOPSHOPPER

redact

Keeps secrets that tools print (keys, tokens, passwords, private keys) out of what the model reads and the next request sends

newcommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · redact
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ 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 › /redact ⎿ redact: Active. ⎿ redact: Hidden from the model this session: stripe-key 1, url-password 1 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ redact: redact: 2 hidden
README

redact

Keeps secrets that tools print out of what the model reads. A cat .env, env, a config file or a failing curl -v puts keys and passwords into a tool result, and from there into every later request; redact replaces them before the row is stored.

GITHUB_TOKEN=[redacted:github-token]
AWS_ACCESS_KEY_ID=[redacted:aws-access-key]
DB_PASSWORD=[redacted:secret-value]
DATABASE_URL=postgres://app:REDACTED@db.local:5432/app
LOG_LEVEL=debug

That block is what the model answered in a live run after cat creds.txt on a file of fake keys: it never saw the values.

/plugin install redact@claude-mods

What it hides

Known formats, wherever they appear: private key blocks, AWS access and secret keys, GitHub and GitLab tokens, Anthropic, OpenAI, Slack, Stripe, Google, npm and PyPI keys, JWTs, the password in a connection string, and Authorization: Bearer|Basic|Token values.

Secret-named values (generic): .env and shell lines (DB_PASSWORD=…, export API_KEY="…") and quoted JSON/YAML/code values ("apiKey": "…", password: '…') whose name contains secret, token, password, passphrase, api key, private key, access key, auth key, client secret or credentials. Placeholders stay: changeme, <your-key>, ${API_KEY}, $TOKEN, xxxx, numbers, booleans, process.env.X, and anything shorter than six characters.

Each secret becomes [redacted:<kind>], so the model knows a value was there and asks for it, or reads it through an environment variable, instead of guessing.

Where

Rows from the outside world: tool results, rows tools hand over, attachments (@file), settings-hook context and messages from other agents. Your own prompts pass untouched unless prompts is on: a key you paste on purpose is one you meant to give.

What it does not change

The model and the API never receive the value. Two things keep it, as Claude Code stores them and no plugin can change them:

  • your screen: a tool's row draws from its own record, so you still see what the tool printed
  • the local transcript file: next to each tool result Claude Code keeps that record (toolUseResult) as the tool made it

So redact protects what leaves your machine and what the model can repeat, not the transcript on disk.

/redact

/redact shows what was hidden this session by kind; the status line shows the count. /redact off lets secrets through for the session, /redact on resumes; both only from the person at the prompt, never from another agent or a channel.

Options

OptionDefault
promptsfalseRedact your prompts too
generictrueHide secret-named values, not only known formats
patterns[]Extra regular expressions, hidden as [redacted:custom]
allow[]Exact values that are not secrets (a public test key)
Source 3 files
hooks/register.ts 77 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { addHits, buildRules, countHits, redactBlocks } from './rules'
5
6const hits = atom({ plugin: 'redact', key: 'hits' } as const, {})
7const isPaused = atom({ plugin: 'redact', key: 'isPaused' } as const, false)
8
9/** The doors rows from the outside world come in by: what a tool, a file or another agent put there. */
10const DOORS = new Set(['tool-result', 'tool-message', 'attachment', 'hook-context', 'delivery'])
11
12/** Who may pause it: the person, never another agent, a channel or a peer session. */
13const PERSON = new Set(['composer', 'bridge', 'sdk'])
14
15async function showStatus($: EngineInterface) {
16  const paused = await read($, isPaused)
17  const count = countHits(await read($, hits))
18  $.ui.status(paused ? 'redact: paused' : count > 0 ? `redact: ${count} hidden` : undefined)
19}
20
21export const register: Register = (on, options) => {
22  const { rules, allow, problems } = buildRules(options)
23  const doors = new Set(DOORS)
24  if (options.prompts === true) doors.add('prompt')
25
26  on('session.start', async ($, e, next) => {
27    await $.command.register({
28      name: 'redact',
29      description: 'Show what redact hid from the model this session; /redact off or /redact on pauses it',
30      argumentHint: '[on|off]',
31    })
32    if (problems.length > 0) $.ui.toast(`redact: ${problems.join('; ')}`)
33
34    return next(e)
35  })
36
37  // The row as the chain answers it is what the transcript keeps and the next
38  // request sends: a secret replaced here never reaches the model.
39  on('session.append', async ($, e, next) => {
40    if (!doors.has(e.door) || (await read($, isPaused))) return next(e)
41
42    const redacted = redactBlocks(e.message.content, rules, allow)
43    if (countHits(redacted.hits) === 0) return next(e)
44
45    await update($, hits, sum => addHits(sum, redacted.hits))
46    await showStatus($)
47
48    return next({ ...e, message: { ...e.message, content: redacted.content } })
49  })
50
51  on('command.run', { command: 'redact' }, async ($, e) => {
52    const arg = e.args.trim().toLowerCase()
53
54    if (arg === 'off' || arg === 'on') {
55      if (!PERSON.has(e.origin.kind)) {
56        return { text: `/redact ${arg} is only accepted from the person at the prompt (got ${e.origin.kind}).` }
57      }
58      await update($, isPaused, () => arg === 'off')
59      await showStatus($)
60
61      return { text: arg === 'off' ? 'Paused for this session: secrets reach the model. /redact on resumes it.' : 'Active.' }
62    }
63    if (arg !== '') return { text: 'Usage: /redact [on|off]' }
64
65    const sum = await read($, hits)
66    const kinds = Object.entries(sum).sort(([, a], [, b]) => b - a)
67    const lines = [
68      (await read($, isPaused)) ? 'Paused for this session (/redact on resumes it).' : 'Active.',
69      kinds.length === 0
70        ? 'Nothing hidden this session.'
71        : `Hidden from the model this session: ${kinds.map(([kind, n]) => `${kind} ${n}`).join(', ')}`,
72    ]
73
74    return { text: lines.join('\n') }
75  })
76}
77
hooks/rules.ts 142 lines
1// What a secret looks like and how a row's blocks are rewritten, as pure
2// functions: no `$`, so tests call them directly.
3
4export type Rule = {
5  /** What the placeholder names: `[redacted:<kind>]`. */
6  kind: string
7  /**
8   * Global. Without groups the whole match is the secret; with named groups
9   * `pre` and `post` around `secret`, only the secret is replaced.
10   */
11  pattern: RegExp
12}
13
14export type Hits = Record<string, number>
15
16const SECRET_NAME = String.raw`[A-Za-z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|PASSPHRASE|API_?KEY|PRIVATE_?KEY|ACCESS_?KEY|AUTH_?KEY|CLIENT_SECRET|CREDENTIALS?)[A-Za-z0-9_]*`
17const SECRET_KEY = String.raw`[A-Za-z0-9_.-]*(?:secret|token|password|passwd|passphrase|api[_-]?key|private[_-]?key|access[_-]?key|auth[_-]?key|client[_-]?secret|credentials?)[A-Za-z0-9_.-]*`
18
19/** Well-known formats: precise enough to redact wherever they appear. */
20export const FORMAT_RULES: Rule[] = [
21  { kind: 'private-key', pattern: /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z0-9]+ )*PRIVATE KEY-----/g },
22  { kind: 'aws-access-key', pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
23  { kind: 'aws-secret-key', pattern: /(?<pre>aws_secret_access_key["']?\s*[=:]\s*["']?)(?<secret>[A-Za-z0-9/+=]{40})/gi },
24  { kind: 'github-token', pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{60,})\b/g },
25  { kind: 'gitlab-token', pattern: /\bglpat-[A-Za-z0-9_-]{20,}/g },
26  { kind: 'anthropic-key', pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}/g },
27  { kind: 'openai-key', pattern: /\bsk-(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{32,}/g },
28  { kind: 'slack-token', pattern: /\bxox[abposr]-[A-Za-z0-9-]{10,}/g },
29  { kind: 'stripe-key', pattern: /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}/g },
30  { kind: 'google-api-key', pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g },
31  { kind: 'npm-token', pattern: /\bnpm_[A-Za-z0-9]{36}\b/g },
32  { kind: 'pypi-token', pattern: /\bpypi-[A-Za-z0-9_-]{50,}/g },
33  { kind: 'jwt', pattern: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g },
34  { kind: 'url-password', pattern: /(?<pre>\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)(?<secret>[^\s@/]+)(?<post>@)/gi },
35  { kind: 'auth-header', pattern: /(?<pre>\bAuthorization["']?\s*[:=]\s*["']?(?:Bearer|Basic|Token)\s+)(?<secret>[A-Za-z0-9._~+/=-]{12,})/gi },
36]
37
38/** Values under a secret-looking name: catch what has no known format. */
39export const GENERIC_RULES: Rule[] = [
40  // .env and shell style: DB_PASSWORD=..., export API_KEY="..."
41  { kind: 'secret-value', pattern: new RegExp(String.raw`(?<pre>^[ \t]*(?:export[ \t]+)?${SECRET_NAME}[ \t]*=[ \t]*["']?)(?<secret>[^\s"'#]+)`, 'gim') },
42  // JSON, YAML, code: "apiKey": "...", password: '...', token = "..."
43  { kind: 'secret-value', pattern: new RegExp(String.raw`(?<pre>["']?${SECRET_KEY}["']?[ \t]*[:=][ \t]*["'])(?<secret>[^"'\s]+)(?<post>["'])`, 'gi') },
44]
45
46/** Values that are not secrets even under a secret-looking name. */
47const NOT_SECRET =
48  /^(?:changeme|change_me|change-me|secret|password|token|your[_-].*|xxx+|\*+|\.{3,}|<.*>|\$\{.*\}|\$[A-Za-z_][A-Za-z0-9_]*|\{\{.*\}\}|%.*%|example.*|placeholder|dummy|test|none|null|nil|undefined|true|false|yes|no|on|off|\d+(?:\.\d+)?|process\.env\..*|os\.environ.*|\[redacted:.*)$/i
49
50const MIN_GENERIC_LENGTH = 6
51
52export function isPlaceholder(value: string): boolean {
53  return NOT_SECRET.test(value) || /^(.)\1*$/.test(value)
54}
55
56export const placeholder = (kind: string) => `[redacted:${kind}]`
57
58/** Builds the rules from the mod's options; bad patterns are reported, not thrown. */
59export function buildRules(options: Readonly<Record<string, unknown>>): { rules: Rule[]; allow: Set<string>; problems: string[] } {
60  const problems: string[] = []
61  const custom: Rule[] = []
62  const list = (value: unknown) => (Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string' && item !== '') : [])
63
64  for (const source of list(options.patterns)) {
65    try {
66      custom.push({ kind: 'custom', pattern: new RegExp(source, 'g') })
67    } catch {
68      problems.push(`invalid pattern /${source}/`)
69    }
70  }
71
72  return {
73    rules: [...FORMAT_RULES, ...(options.generic === false ? [] : GENERIC_RULES), ...custom],
74    allow: new Set(list(options.allow)),
75    problems,
76  }
77}
78
79/** Replaces every secret in `text`, counting them by kind. */
80export function redactText(text: string, rules: Rule[], allow: ReadonlySet<string> = new Set()): { text: string; hits: Hits } {
81  const hits: Hits = {}
82  let out = text
83
84  for (const { kind, pattern } of rules) {
85    out = out.replace(pattern, (...args) => {
86      const match = args[0] as string
87      const groups = args.at(-1) as Record<string, string | undefined> | undefined
88      const hasGroups = typeof groups === 'object' && groups !== null && groups.secret !== undefined
89      const secret = hasGroups ? groups.secret! : match
90
91      if (allow.has(secret) || secret.startsWith('[redacted:')) return match
92      if (kind === 'secret-value' && (secret.length < MIN_GENERIC_LENGTH || isPlaceholder(secret))) return match
93      if (kind === 'url-password' && isPlaceholder(secret)) return match
94
95      hits[kind] = (hits[kind] ?? 0) + 1
96      return hasGroups ? `${groups.pre ?? ''}${placeholder(kind)}${groups.post ?? ''}` : placeholder(kind)
97    })
98  }
99
100  return { text: out, hits }
101}
102
103export function addHits(into: Hits, from: Hits): Hits {
104  const sum = { ...into }
105  for (const [kind, n] of Object.entries(from)) sum[kind] = (sum[kind] ?? 0) + n
106  return sum
107}
108
109export const countHits = (hits: Hits) => Object.values(hits).reduce((sum, n) => sum + n, 0)
110
111type Block = { type: string; [field: string]: unknown }
112
113/**
114 * Rewrites the blocks a row may change: text blocks, and a tool_result's
115 * content, a string or text blocks. Every other block is left as it is.
116 */
117export function redactBlocks(content: readonly Block[], rules: Rule[], allow: ReadonlySet<string>): { content: Block[]; hits: Hits } {
118  let hits: Hits = {}
119  const redact = (text: string) => {
120    const result = redactText(text, rules, allow)
121    hits = addHits(hits, result.hits)
122    return result.text
123  }
124  const redactInner = (inner: unknown): unknown => {
125    if (typeof inner === 'string') return redact(inner)
126    if (!Array.isArray(inner)) return inner
127    return inner.map(part =>
128      part !== null && typeof part === 'object' && (part as Block).type === 'text' && typeof (part as Block).text === 'string'
129        ? { ...(part as Block), text: redact((part as Block).text as string) }
130        : part,
131    )
132  }
133
134  const next = content.map(block => {
135    if (block.type === 'text' && typeof block.text === 'string') return { ...block, text: redact(block.text) }
136    if (block.type === 'tool_result') return { ...block, content: redactInner(block.content) }
137    return block
138  })
139
140  return { content: next, hits }
141}
142
types/index.d.ts 12 lines
1/** Secrets hidden from the model, by kind (`github-token`, `secret-value`, ...). */
2export type RedactHits = Record<string, number>
3
4declare module 'claude-code' {
5  interface PluginState {
6    redact: {
7      hits: RedactHits
8      isPaused: boolean
9    }
10  }
11}
12