Masks API keys, tokens, passwords, card numbers and emails in your messages before they reach the model.

Masks API keys, tokens, passwords, card numbers, emails and phone numbers in your messages before they reach the model, and in replies on screen, with boxes you click to copy the original.
/plugin marketplace add namtrdev/claude-mods
/plugin install hide-sensitive@jackie-mods
Commands: /hide-sensitive.
Built for Claude Code mods (hook modules). Nothing here runs outside your machine: the mod reads session events and draws its own interface.
You set these when you install the mod, and can change them later in the Claude Code config menu.
maskKeys, default true): Mask API keys, tokens, JWTs, private keys and password=... values.maskEmail, default true): Replace email addresses with a [hidden:email] box.maskPhone, default true): Mask +country-code numbers and Vietnamese mobile numbers.maskCard, default true): Mask 13 to 19 digit numbers that pass the Luhn check.extraPatterns, default empty): Your own regular expressions to mask, comma separated. Example: EMP-\d{6},ACME-[A-Z0-9]{8}MIT, Copyright (c) 2026 Jackie.
hooks/register.tsx 373 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4
5const isOn = atom({ plugin: 'hide-sensitive', key: 'isOn' } as const, true)
6const masked = atom({ plugin: 'hide-sensitive', key: 'masked' } as const, 0)
7
8const RULES: { label: string; re: RegExp }[] = [
9 { label: 'private-key', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g },
10 { label: 'anthropic-key', re: /\bsk-ant-[A-Za-z0-9_-]{20,}/g },
11 { label: 'api-key', re: /\bsk-[A-Za-z0-9_-]{20,}/g },
12 { label: 'github-token', re: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{30,}|\bgithub_pat_[A-Za-z0-9_]{30,}/g },
13 { label: 'aws-key', re: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g },
14 { label: 'slack-token', re: /\bxox[abprs]-[A-Za-z0-9-]{10,}/g },
15 { label: 'google-key', re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
16 { label: 'jwt', re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g },
17 { label: 'bearer-token', re: /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/g },
18 { label: 'email', re: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g },
19]
20
21// Value of key=value or key: value where the key names a secret.
22const ASSIGN = /(?<=\b(?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key)\s*[=:]\s*["']?)[^\s"']{4,}/gi
23
24// Strict phone shapes only: +<country code> numbers and Vietnamese mobile numbers (03/05/07/08/09).
25const PHONE = /(?<![\w+])(?:\+\d{1,3}[ .-]?\d{1,4}(?:[ .-]?\d{2,4}){2,4}|0[35789](?:[ .-]?\d){8})(?![\w])/g
26
27const CARD = /\b(?:\d[ -]?){13,19}\b/g
28
29const isCard = (raw: string) => {
30 const digits = raw.replace(/\D/g, '')
31 if (digits.length < 13 || digits.length > 19) return false
32 let sum = 0
33 for (let i = 0; i < digits.length; i++) {
34 let d = Number(digits[digits.length - 1 - i])
35 if (i % 2 === 1) {
36 d *= 2
37 if (d > 9) d -= 9
38 }
39 sum += d
40 }
41 return sum % 10 === 0
42}
43
44type Hit = { start: number; end: number; label: string; original: string }
45type Item = { label: string; original: string }
46
47// The person's choices from the manifest's userConfig; register() fills it in.
48let cfg = { keys: true, email: true, phone: true, card: true, extra: [] as RegExp[] }
49
50const find = (text: string): Hit[] => {
51 const hits: Hit[] = []
52 const add = (re: RegExp, label: string, ok: (s: string) => boolean = () => true) => {
53 for (const m of text.matchAll(re)) {
54 if (m.index !== undefined && ok(m[0])) {
55 hits.push({ start: m.index, end: m.index + m[0].length, label, original: m[0] })
56 }
57 }
58 }
59 for (const rule of RULES) {
60 if (rule.label === 'email' ? cfg.email : cfg.keys) add(rule.re, rule.label)
61 }
62 if (cfg.keys) add(ASSIGN, 'secret')
63 if (cfg.card) add(CARD, 'card', isCard)
64 if (cfg.phone) add(PHONE, 'phone')
65 for (const re of cfg.extra) add(re, 'custom')
66 hits.sort((a, b) => a.start - b.start || b.end - a.end)
67 const kept: Hit[] = []
68 for (const hit of hits) {
69 if (kept.length === 0 || hit.start >= kept[kept.length - 1].end) kept.push(hit)
70 }
71 // A secret inside a URL masks the whole URL, so copying gives back the real link.
72 const urls = [...text.matchAll(/https?:\/\/[^\s)>\]"'`]+/g)]
73 const out: Hit[] = []
74 for (const hit of kept) {
75 const url = urls.find(u => u.index! <= hit.start && hit.end <= u.index! + u[0].length)
76 if (url === undefined) {
77 out.push(hit)
78 } else if (!out.some(o => o.start === url.index)) {
79 out.push({ start: url.index!, end: url.index! + url[0].length, label: `url (${hit.label})`, original: url[0] })
80 }
81 }
82 return out
83}
84
85const mask = (text: string) => {
86 const hits = find(text)
87 let out = ''
88 let at = 0
89 for (const hit of hits) {
90 out += text.slice(at, hit.start) + `[hidden:${hit.label}]`
91 at = hit.end
92 }
93 return { out: out + text.slice(at), items: hits.map(h => ({ label: h.label, original: h.original })) }
94}
95
96// Originals, by the masked text the row will show. Memory only: gone on reload.
97const vault = new Map<string, Item[]>()
98const PLACEHOLDER = /\[hidden:([a-z-]+)\]/g
99
100type Seg = { plain: string } | { label: string; original?: string }
101
102// Draws segments as rows: plain text as text, each mask as a box that copies its original.
103// eslint-disable-next-line @typescript-eslint/no-explicit-any
104const view = (segs: Seg[], ui: { Box: any; Button: any; Text: any; Svg?: any }, copy: (item: { label: string; original?: string }, surface: unknown) => void) => {
105 const { Box, Button, Text } = ui
106 const lines: Seg[][] = [[]]
107 for (const seg of segs) {
108 if ('plain' in seg) {
109 seg.plain.split('\n').forEach((part, i) => {
110 if (i > 0) lines.push([])
111 if (part !== '') lines[lines.length - 1].push({ plain: part })
112 })
113 } else {
114 lines[lines.length - 1].push(seg)
115 }
116 }
117 let n = 0
118
119 return (
120 <Box flexDirection="column">
121 {lines.map(line => (
122 <Box flexWrap="wrap">
123 {line.length === 0 && <Text> </Text>}
124 {line.map(seg => {
125 if ('plain' in seg) return <Text>{seg.plain}</Text>
126 const id = n
127 n += 1
128 return chip(ui, `hidden-${id}`, '████████ ', seg.label, '#ffe066', (press: { surface: unknown }) => copy(seg, press.surface))
129 })}
130 </Box>
131 ))}
132 </Box>
133 )
134}
135
136// One chip: a single Button, so the whole control (copy glyph included) is the press target.
137// eslint-disable-next-line @typescript-eslint/no-explicit-any
138const chip = (ui: { Button: any; Text: any }, id: string, face: string, label: string, color: string, onPress: (press: { surface: unknown }) => void) => {
139 const { Button, Text } = ui
140
141 return (
142 <Button key={id} plain onPress={onPress}>
143 <Text backgroundColor="#3b3b3b" color={color}>
144 {` ${face}${label} ⧉ `}
145 </Text>
146 </Button>
147 )
148}
149
150// A user's row sits on the right in a rounded-looking block, as the surface draws it.
151// eslint-disable-next-line @typescript-eslint/no-explicit-any
152const bubble = (ui: { Box: any }, tree: unknown) => {
153 const { Box } = ui
154 return (
155 <Box justifyContent="flex-end">
156 <Box backgroundColor="#2b2b2b" borderStyle="round" borderColor="#2b2b2b" paddingX={1} flexDirection="column">
157 {tree}
158 </Box>
159 </Box>
160 )
161}
162
163export const register: Register = (on, options) => {
164 const extra = String(options.extraPatterns ?? '')
165 .split(',')
166 .map(one => one.trim())
167 .filter(one => one !== '')
168 .flatMap(one => {
169 try {
170 return [new RegExp(one, 'g')]
171 } catch {
172 return []
173 }
174 })
175 cfg = {
176 keys: options.maskKeys !== false,
177 email: options.maskEmail !== false,
178 phone: options.maskPhone !== false,
179 card: options.maskCard !== false,
180 extra,
181 }
182
183 on('session.start', async ($, e, next) => {
184 await $.command.register({
185 name: 'hide-sensitive',
186 description: 'Turn masking of sensitive data in your messages on or off',
187 })
188
189 return next(e)
190 })
191
192 on('command.run', { command: 'hide-sensitive' }, async $ => {
193 const was = await read($, isOn)
194 await update($, isOn, () => !was)
195 $.ui.status(was ? 'sensitive masking OFF' : undefined)
196
197 return { text: was ? 'Sensitive-data masking is OFF.' : 'Sensitive-data masking is ON.' }
198 })
199
200 on('prompt.submit', async ($, e, next) => {
201 if (e.command !== undefined || !(await read($, isOn))) {
202 return next(e)
203 }
204 const { out, items } = mask(e.text)
205 if (items.length === 0) {
206 return next(e)
207 }
208 vault.set(out, items)
209 if (vault.size > 50) vault.delete(vault.keys().next().value as string)
210 await update($, masked, n => n + items.length)
211 $.ui.toast(`Hid ${items.length} sensitive item${items.length > 1 ? 's' : ''} before sending`)
212
213 return next({ ...e, text: out })
214 })
215
216 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
217 // The row carries the prompt as typed, so the secrets are still in it: box them on screen.
218 const typed = (await read($, isOn)) && ['composer', 'sdk', 'bridge'].includes(e.props.origin.kind) ? find(e.props.text) : []
219 if (typed.length > 0) {
220 const segs: Seg[] = []
221 let from = 0
222 for (const hit of typed) {
223 segs.push({ plain: e.props.text.slice(from, hit.start) })
224 segs.push({ label: hit.label, original: hit.original })
225 from = hit.end
226 }
227 segs.push({ plain: e.props.text.slice(from) })
228
229 const ui = $.ui.resolve(e)
230
231 return bubble(
232 ui,
233 view(segs, ui, async (item, surface) => {
234 const done = await $.ui.copy({ text: item.original ?? '', surface: surface as typeof e.surface })
235 $.ui.toast(done.isCopied ? `Copied ${item.label}` : 'Could not copy')
236 }),
237 )
238 }
239
240 const found = [...e.props.text.matchAll(PLACEHOLDER)]
241 if (found.length === 0) {
242 return next(e)
243 }
244 const kept = vault.get(e.props.text) ?? vault.get(e.props.text.trim())
245 const items: Item[] =
246 kept !== undefined && kept.length === found.length ? kept : found.map(m => ({ label: m[1], original: '' }))
247 const segs: Seg[] = []
248 let at = 0
249 found.forEach((m, i) => {
250 segs.push({ plain: e.props.text.slice(at, m.index) })
251 segs.push({ label: items[i].label, original: items[i].original || undefined })
252 at = m.index! + m[0].length
253 })
254 segs.push({ plain: e.props.text.slice(at) })
255
256 const ui = $.ui.resolve(e)
257
258 return bubble(
259 ui,
260 view(segs, ui, async (item, surface) => {
261 if (item.original === undefined) {
262 $.ui.toast('Original not kept (reloaded since it was sent)')
263 return
264 }
265 const done = await $.ui.copy({ text: item.original, surface: surface as typeof e.surface })
266 $.ui.toast(done.isCopied ? `Copied ${item.label}` : 'Could not copy')
267 }),
268 )
269 })
270
271 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
272 if (!(await read($, isOn)) || find(e.props.text).length === 0) {
273 return next(e)
274 }
275
276 const ui = $.ui.resolve(e)
277 const { Box, Button, Markdown, Text } = ui
278 const hasSvg = ui.Svg !== undefined
279
280 // Blocks split at blank lines outside code fences, so each keeps its own markdown.
281 const blocks: string[] = []
282 let current: string[] = []
283 let inFence = false
284 for (const line of e.props.text.split('\n')) {
285 if (line.trimStart().startsWith('```')) inFence = !inFence
286 if (line.trim() === '' && !inFence) {
287 if (current.length > 0) blocks.push(current.join('\n'))
288 current = []
289 } else {
290 current.push(line)
291 }
292 }
293 if (current.length > 0) blocks.push(current.join('\n'))
294
295 return (
296 <Box flexDirection="column" gap={1}>
297 {blocks.map((block, b) => {
298 const hits = find(block)
299 if (hits.length === 0) return <Markdown text={block} />
300 const isFence = block.trimStart().startsWith('```')
301 if (isFence) {
302 // Drawn here, not by Markdown, so its copy button is ours and copies the original.
303 const rows = block.split('\n')
304 const lang = rows[0].trim().replace(/^`+/, '')
305 const body = rows.slice(1, rows[rows.length - 1].trim().startsWith('```') ? -1 : undefined).join('\n')
306 const segs: Seg[] = []
307 let from = 0
308 for (const hit of find(body)) {
309 segs.push({ plain: body.slice(from, hit.start) })
310 segs.push({ label: hit.label, original: hit.original })
311 from = hit.end
312 }
313 segs.push({ plain: body.slice(from) })
314 const copy = async (original: string, label: string, surface: typeof e.surface) => {
315 const done = await $.ui.copy({ text: original, surface })
316 $.ui.toast(done.isCopied ? `Copied ${label}` : 'Could not copy')
317 }
318
319 return (
320 <Box flexDirection="column" borderStyle="round" paddingX={1}>
321 <Box justifyContent="space-between">
322 <Text dimColor>{lang === '' ? 'code' : lang}</Text>
323 {chip(ui, `code-${b}`, '', 'Copy (original)', '#7dff9a', press => copy(body, 'original code', press.surface as typeof e.surface))}
324 </Box>
325 {view(segs, ui, (item, surface) =>
326 copy(item.original ?? '', item.label, surface as typeof e.surface),
327 )}
328 </Box>
329 )
330 }
331 const hrefs = hits.map((_, i) => `https://hidden.invalid/${b}-${i}`)
332 let shown = ''
333 let at = 0
334 hits.forEach((hit, i) => {
335 const before = block.slice(at, hit.start)
336 const lineStart = block.lastIndexOf('\n', hit.start - 1) + 1
337 const inCode = (block.slice(lineStart, hit.start).split('`').length - 1) % 2 === 1
338 const face = `████ ${hit.label}`
339 // A link makes the mask clickable inside the markdown; code shows links as raw text.
340 shown += before + (inCode ? face : `[${face}](${hrefs[i]})`)
341 at = hit.end
342 })
343 shown += block.slice(at)
344
345 return (
346 <Box flexDirection="column">
347 <Markdown
348 key={`md-${b}`}
349 text={shown}
350 pressableLinks={hrefs}
351 onLinkPress={async (link, press) => {
352 const i = hrefs.indexOf(link.href)
353 if (i < 0) return
354 const done = await $.ui.copy({ text: hits[i].original, surface: press.surface })
355 $.ui.toast(done.isCopied ? `Copied ${hits[i].label}` : 'Could not copy')
356 }}
357 />
358 <Box gap={1} flexWrap="wrap">
359 {hits.map((hit, i) => (
360 chip(ui, `hidden-${b}-${i}`, '████████ ', hit.label, '#ffe066', async press => {
361 const done = await $.ui.copy({ text: hit.original, surface: press.surface as typeof e.surface })
362 $.ui.toast(done.isCopied ? `Copied ${hit.label}` : 'Could not copy')
363 })
364 ))}
365 </Box>
366 </Box>
367 )
368 })}
369 </Box>
370 )
371 })
372}
373types/index.d.ts 8 lines1export type MaskCount = number
2
3declare module 'claude-code' {
4 interface PluginState {
5 'hide-sensitive': { isOn: boolean; masked: MaskCount }
6 }
7}
8