Rewrites sensitive data (emails, IPs, credentials, addresses) out of what enters the model context

A Claude Code mod: rewrites sensitive data — emails, IP addresses, API keys and credentials, residential addresses — into [redact:...] placeholders before it enters the model context. The agent reads files, runs commands and loads attachments as usual; what reaches the model and the conversation are placeholders that keep their category, so the model still understands the surrounding context.
From the GitHub marketplace (type in a Claude Code terminal session):
/plugin install redact --marketplace JinhaoFang/claude-mods
or from a local folder:
/plugin install redact --marketplace /path/to/mods/redact
Answer y to add the marketplace, pick the user scope, done.
| category | option | default | matches |
|---|---|---|---|
emails | on | email addresses | |
| IPv4 / IPv6 | ipAddresses | on | IP addresses |
| credential | credentials | on | known key/token shapes (AWS AKIA, ghp_, sk-, sk-ant-, AIza, xox-, JWT/Bearer, private-key blocks) and .env-style secret assignments |
| address | addresses | on | street-style and Chinese-format residential addresses |
| phone | phoneNumbers | off | phone numbers (off by default: bare digit runs false-positive) |
Placeholders keep the category — [redact:email], [redact:credential] — so the model can still reason about what kind of value was there.
The mod hooks the transcript append: every row is rewritten before it is stored or sent, so the model and the conversation never see the raw value. Which rows are washed:
@file loads, injected memory), notices, hook contextscrubPrompt: rows you typed yourself (prompt / command) — off by default, so the model sees what you askedDelivery rows (messages from peers, channels, relays) are washed when their origin is a person.
scrubPrompt off (the default), secrets you type yourself pass through.queue-operation rows and the toolUseResult structured record beside tool results still hold raw values). If your threat model includes the transcript file, redaction at this layer is not enough./redact prints this session's hit counts per categoryquiet suppresses it)Rows named redact.* in /plugin configure or /config:
| option | default | description |
|---|---|---|
emails | true | Redact email addresses |
ipAddresses | true | Redact IP addresses |
credentials | true | Redact keys and credentials |
addresses | true | Redact residential addresses |
phoneNumbers | false | Redact phone numbers |
scrubPrompt | false | Also scrub typed prompts |
quiet | false | Suppress the per-hit toast |
Everything happens on the transcript-append hook inside Claude Code — no subprocesses, no network, no parsing of files on disk. Misses are conservative by design: a rule only fires on a shape it is sure about.
Contributor rules — the mod contract and the inclusion bar — live in CONTRIBUTING.md.
claude plugin validate mods/redact
claude plugin test mods/redact
Apache-2.0, under the repository's root LICENSE.
hooks/register.ts 300 lines1// redact —— 在会话行存入上下文之前脱敏改写的入口。
2// 挂 session.append:doors 政策先门控(BASE_DOORS 默认洗 tool-result/tool-message/
3// attachment/notice/hook-context;response/note/compaction 永不洗;prompt/command 随
4// scrubPrompt;delivery 随 scrubPrompt 或非本人 origin——本人仅 origin.kind 为
5// composer 与 bridge,未知类别向保护侧失败),再经 washBlocks→washText 按固定顺序
6// 折叠规则表(PATTERN_LIBRARY:PEM→JWT→已知前缀→带标签赋值→.env→email→ipv6→ipv4→
7// 街道地址→中文地址→电话),命中计入 atom 'redact.hits',toast 逐行提示(quiet 可关),
8// /redact 汇报本会话分类计数。改写抛错时该行原样放行并只记一次日志,注册级 .catch 兜底。
9// 类别集合与会话状态契约在 ../types;规则顺序、类别开关与占位符格式见 README.md。
10import { atom, read, update } from 'claude-code'
11import type {
12 ApiContentBlock,
13 PluginOptions,
14 Register,
15 SessionAppendDoor,
16 SessionAppendOrigin,
17 SessionAppendMessage,
18} from 'claude-code'
19import type { Hits, RedactCategory } from '../types'
20
21type Category = RedactCategory
22type RowHits = Partial<Record<Category, number>>
23type DoorPolicy = (door: SessionAppendDoor, origin: SessionAppendOrigin) => boolean
24
25interface Pattern {
26 category: Category
27 regex: RegExp
28}
29
30interface Config {
31 wash: ReadonlySet<Category>
32 doors: ReadonlySet<SessionAppendDoor> | 'scrub'
33 quiet: boolean
34}
35
36interface Rules {
37 doors: DoorPolicy
38 patterns: readonly Pattern[]
39 quiet: boolean
40}
41
42interface Washed {
43 message: SessionAppendMessage
44 hits: RowHits
45}
46
47const ZERO_HITS: Hits = { email: 0, ipv4: 0, ipv6: 0, credential: 0, address: 0, phone: 0 }
48
49const CATEGORIES = Object.keys(ZERO_HITS) as Category[]
50
51const CONFIG_DEFAULTS = {
52 emails: true,
53 ipAddresses: true,
54 credentials: true,
55 addresses: true,
56 phoneNumbers: false,
57 scrubPrompt: false,
58 quiet: false,
59} as const
60
61const CATEGORY_SWITCHES = [
62 ['email', 'emails'],
63 ['ipv4', 'ipAddresses'],
64 ['ipv6', 'ipAddresses'],
65 ['credential', 'credentials'],
66 ['address', 'addresses'],
67 ['phone', 'phoneNumbers'],
68] as const satisfies readonly (readonly [Category, keyof typeof CONFIG_DEFAULTS])[]
69
70const DOORS = [
71 'prompt',
72 'command',
73 'response',
74 'tool-result',
75 'tool-message',
76 'delivery',
77 'attachment',
78 'hook-context',
79 'note',
80 'compaction',
81 'notice',
82] as const satisfies readonly SessionAppendDoor[]
83
84const BASE_DOORS: Record<SessionAppendDoor, boolean> = {
85 'tool-result': true,
86 'tool-message': true,
87 attachment: true,
88 notice: true,
89 'hook-context': true,
90 prompt: false,
91 command: false,
92 delivery: false,
93 response: false,
94 note: false,
95 compaction: false,
96}
97
98const ALWAYS_WASH: ReadonlySet<SessionAppendDoor> = new Set(
99 DOORS.filter(door => BASE_DOORS[door]),
100)
101
102const NEVER_WASHED: ReadonlySet<SessionAppendDoor> = new Set<SessionAppendDoor>([
103 'response',
104 'note',
105 'compaction',
106])
107
108const PERSON_ORIGIN_KINDS: ReadonlySet<string> = new Set(['composer', 'bridge'])
109
110const HITS = atom({ plugin: 'redact', key: 'hits' } as const, ZERO_HITS)
111
112const PATTERN_LIBRARY: readonly Pattern[] = [
113 {
114 category: 'credential',
115 regex: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----/g,
116 },
117 {
118 category: 'credential',
119 regex: /eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*\.[A-Za-z0-9_-]+/g,
120 },
121 {
122 category: 'credential',
123 regex: /\b(?:AKIA[A-Z0-9]{16}|gh[posu]_[A-Za-z0-9]{20,}|sk-ant-[A-Za-z0-9_-]{16,}|sk-[A-Za-z0-9]{16,}|AIza[A-Za-z0-9_-]{16,}|xox[baprs]-[A-Za-z0-9_-]{10,})/g,
124 },
125 {
126 category: 'credential',
127 regex: /(?:api[_-]?key|token|secret|password)\s*[=:]\s*["']?[A-Za-z0-9+/_=-]{16,}/g,
128 },
129 {
130 category: 'credential',
131 regex: /^[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)[A-Z0-9_]*[ \t]*=[ \t]*[^\[\s]\S{7,}$/gm,
132 },
133 {
134 category: 'email',
135 regex: /[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}/g,
136 },
137 {
138 category: 'ipv6',
139 regex: /\b(?:[A-Fa-f0-9]{1,4}:){1,6}:[A-Fa-f0-9]{1,4}(?::[A-Fa-f0-9]{1,4}){0,5}\b(?!\.\d)|\b::[A-Fa-f0-9]{1,4}(?::[A-Fa-f0-9]{1,4}){0,5}\b(?!\.\d)|\b(?:[A-Fa-f0-9]{1,4}:){3,7}[A-Fa-f0-9]{1,4}\b/g,
140 },
141 {
142 category: 'ipv4',
143 regex: /\b(?:(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\b/g,
144 },
145 {
146 category: 'address',
147 regex: /\b\d{1,5}(?:\s+[A-Za-z][a-z]+){1,4}\s(?:Street|St|Avenue|Ave|Road|Rd|Boulevard|Blvd|Lane|Ln|Drive|Dr|Court|Ct|Way|Place|Pl|Terrace|Ter|Parkway|Pkwy|Highway|Hwy)\b\.?(?:\s+(?:Apt|Unit|Suite|Ste)\s*[A-Za-z0-9-]+)?/g,
148 },
149 {
150 category: 'address',
151 regex: /\d{1,4}(?:省|市|区|县|路|街|巷|号)(?:[一-鿿]{1,12}(?:楼|室|栋))?/g,
152 },
153 {
154 category: 'phone',
155 regex: /\b\d{7,15}\b/g,
156 },
157]
158
159export function readConfig(options: PluginOptions): Config {
160 const bool = (value: unknown, fallback: boolean) =>
161 typeof value === 'boolean' ? value : fallback
162 return {
163 wash: new Set<Category>(
164 CATEGORY_SWITCHES
165 .filter(([, option]) => bool(options[option], CONFIG_DEFAULTS[option]))
166 .map(([category]) => category),
167 ),
168 doors: bool(options.scrubPrompt, false) ? 'scrub' : ALWAYS_WASH,
169 quiet: bool(options.quiet, false),
170 }
171}
172
173function isPersonOrigin(origin: SessionAppendOrigin): boolean {
174 return PERSON_ORIGIN_KINDS.has(origin.kind)
175}
176
177export function compileRules(config: Config): Rules {
178 const doors = config.doors
179 const policy: DoorPolicy =
180 doors === 'scrub'
181 ? door => !NEVER_WASHED.has(door)
182 : (door, origin) =>
183 door === 'delivery' ? !isPersonOrigin(origin) : doors.has(door)
184 return {
185 doors: policy,
186 patterns: PATTERN_LIBRARY.filter(pattern => config.wash.has(pattern.category)),
187 quiet: config.quiet,
188 }
189}
190
191function washText(text: string, patterns: readonly Pattern[], hits: RowHits): string {
192 let out = text
193 for (const pattern of patterns) {
194 out = out.replace(pattern.regex, () => {
195 hits[pattern.category] = (hits[pattern.category] ?? 0) + 1
196 return `[redact:${pattern.category}]`
197 })
198 }
199 return out
200}
201
202function washBlock(
203 block: ApiContentBlock,
204 patterns: readonly Pattern[],
205 hits: RowHits,
206): ApiContentBlock {
207 if (block.type === 'text' && typeof block.text === 'string') {
208 const text = washText(block.text, patterns, hits)
209 return text === block.text ? block : { ...block, text }
210 }
211 if (block.type === 'tool_result' && typeof block.content === 'string') {
212 const text = washText(block.content, patterns, hits)
213 return text === block.content ? block : { ...block, content: text }
214 }
215 if (block.type === 'tool_result' && Array.isArray(block.content)) {
216 const inner: readonly ApiContentBlock[] = block.content
217 const content = washBlocks(inner, patterns, hits)
218 return content.every((after, index) => after === inner[index]) ? block : { ...block, content }
219 }
220 return block
221}
222
223function washBlocks(
224 blocks: readonly ApiContentBlock[],
225 patterns: readonly Pattern[],
226 hits: RowHits,
227): ApiContentBlock[] {
228 return blocks.map(block => washBlock(block, patterns, hits))
229}
230
231function hasHits(hits: RowHits): boolean {
232 return CATEGORIES.some(category => (hits[category] ?? 0) > 0)
233}
234
235export function redactRow(
236 door: SessionAppendDoor,
237 origin: SessionAppendOrigin,
238 message: SessionAppendMessage,
239 rules: Rules,
240): Washed {
241 if (!rules.doors(door, origin)) return { message, hits: {} }
242 const hits: RowHits = {}
243 const content = washBlocks(message.content, rules.patterns, hits)
244 if (!hasHits(hits)) return { message, hits }
245 return { message: { ...message, content }, hits }
246}
247
248function addHits(prev: Hits | undefined, hits: RowHits): Hits {
249 const next = { ...(prev ?? ZERO_HITS) }
250 for (const category of CATEGORIES) next[category] += hits[category] ?? 0
251 return next
252}
253
254function formatHits(hits: RowHits): string {
255 return CATEGORIES.filter(category => (hits[category] ?? 0) > 0)
256 .map(category => `${hits[category]} ${category}`)
257 .join(', ')
258}
259
260function formatReport(hits: Hits): string {
261 const total = CATEGORIES.reduce((sum, category) => sum + hits[category], 0)
262 if (total === 0) return 'redact: nothing rewritten this session'
263 const parts = CATEGORIES.map(category => `${hits[category]} ${category}`)
264 return `redact: ${parts.join(', ')} (${total} total)`
265}
266
267export const register: Register = (on, options) => {
268 const rules = compileRules(readConfig(options))
269 let logged = false
270
271 on('session.append', async ($, e, next) => {
272 let washed: Washed
273 try {
274 washed = redactRow(e.door, e.origin, e.message, rules)
275 } catch {
276 if (!logged) {
277 logged = true
278 $.ui.log('redact: rewrite failed, rows pass unwashed')
279 }
280 return next(e)
281 }
282 if (!hasHits(washed.hits)) return next(e)
283 await update($, HITS, prev => addHits(prev, washed.hits))
284 if (!rules.quiet) $.ui.toast(`redact: ${formatHits(washed.hits)}`)
285 return next({ ...e, message: washed.message })
286 }).catch(($, e, next) => next(e))
287
288 on('session.start', async ($, e, next) => {
289 await $.command.register({
290 name: 'redact',
291 description: "Show this session's redaction counts",
292 })
293 return next(e)
294 })
295
296 on('command.run', { command: 'redact' }, async $ => ({
297 text: formatReport(await read($, HITS)),
298 }))
299}
300types/index.d.ts 14 lines1// redact —— 类别集合与会话状态契约的唯一家(single home)。RedactCategory 是六个
2// 脱敏类别(顺序即 /redact 报告顺序);Hits 是全覆盖计数记录;PluginState['redact']
3// 是命中计数的存放形态,对应 atom({ plugin: 'redact', key: 'hits' }, ZERO_HITS)。
4// 规则表与 doors 政策见 hooks/register.ts,设计结论见 README.md。
5export type RedactCategory = 'email' | 'ipv4' | 'ipv6' | 'credential' | 'address' | 'phone'
6
7export type Hits = Record<RedactCategory, number>
8
9declare module 'claude-code' {
10 interface PluginState {
11 redact: { hits: Hits }
12 }
13}
14