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

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
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.
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.
The model and the API never receive the value. Two things keep it, as Claude Code stores them and no plugin can change them:
toolUseResult) as the tool made itSo 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.
| Option | Default | |
|---|---|---|
prompts | false | Redact your prompts too |
generic | true | Hide 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) |
hooks/register.ts 77 lines1import { 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}
77hooks/rules.ts 142 lines1// 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}
142types/index.d.ts 12 lines1/** 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