SLOPSHOPPER

redact

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

newcommandtoast
v0.1.0Apache-2.0updated 2026-10-08JinhaoFang/claude-mods/mods/redact
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 ╭───────────────────────────────╮ │ redact │ ⏺ Read(src/auth.ts) │ redact: 1 email, 1 credential │ ⎿ 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: redact: 1 email, 0 ipv4, 0 ipv6, 1 credential, 0 address, 0 phone (2 total) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

redact

English | 中文

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.

Install

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.

What it redacts

categoryoptiondefaultmatches
emailemailsonemail addresses
IPv4 / IPv6ipAddressesonIP addresses
credentialcredentialsonknown key/token shapes (AWS AKIA, ghp_, sk-, sk-ant-, AIza, xox-, JWT/Bearer, private-key blocks) and .env-style secret assignments
addressaddressesonstreet-style and Chinese-format residential addresses
phonephoneNumbersoffphone 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.

Where it washes

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:

  • washed by default: tool results, tool-message addenda, attachments (@file loads, injected memory), notices, hook context
  • never washed: model responses, notes, compaction rows
  • washed only with scrubPrompt: rows you typed yourself (prompt / command) — off by default, so the model sees what you asked

Delivery rows (messages from peers, channels, relays) are washed when their origin is a person.

Residual risks, stated honestly

  • Conversation history that existed before the mod loaded is out of reach.
  • With scrubPrompt off (the default), secrets you type yourself pass through.
  • The model context is clean — verified end to end — but the transcript file keeps engine-level records the hook cannot reach (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.

Usage

  • /redact prints this session's hit counts per category
  • Each redacted row shows a toast (quiet suppresses it)

Configuration

Rows named redact.* in /plugin configure or /config:

optiondefaultdescription
emailstrueRedact email addresses
ipAddressestrueRedact IP addresses
credentialstrueRedact keys and credentials
addressestrueRedact residential addresses
phoneNumbersfalseRedact phone numbers
scrubPromptfalseAlso scrub typed prompts
quietfalseSuppress the per-hit toast

Data source

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.

Development

Contributor rules — the mod contract and the inclusion bar — live in CONTRIBUTING.md.

claude plugin validate mods/redact
claude plugin test mods/redact

License

Apache-2.0, under the repository's root LICENSE.

Source 2 files
hooks/register.ts 300 lines
1// 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}
300
types/index.d.ts 14 lines
1// 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