Masks emails, names, account numbers and secrets on screen while you screen-record. Display only: the model and the transcript keep the real values.

Masks personal data and secrets on screen while you record or share your screen, so a demo video doesn't show your email address, your API keys or the name of the client in your file paths.
mail me at jin.song@agentsy.ai, key sk-ant-api03-…, home /Users/jins/code
is drawn as
mail me at ████[email], key ████[key], home /Users/████/code
It is display only. It rewrites what Claude Code draws, and nothing else. Claude still reads the real values, and the transcript stores them, so your session works exactly as before.
/plugin install pii-shield --marketplace mrjk05/modemon
Answer y to add the marketplace, then pick a scope.
| Mode | What happens |
|---|---|
auto (default) | Every 5 seconds it checks the process list for a known screen recorder or screen-share helper. When it finds one, redaction turns on and you get a toast. It stays on for the rest of the session (sticky), even after the recorder quits, until you run /redact off. |
on | Always redacting. |
off | Never redacting, and no polling. |
Detected recorders:
screencaptureui, ⇧⌘5), screencapture, OBS, Loom, CleanShot X, Kap, ScreenFlow, Screen Studio, Camtasia, Rotato, and Zoom while it shares your screen (CptHost).CptHost.The status entry shows ● REDACTING while masking and ○ pii-shield while not, on every surface (the terminal's status line, the desktop's and the mobile app's status list).
| Command | Effect |
|---|---|
/redact on | Start masking now. Run this before you start recording if you need to be sure. |
/redact off | Stop masking and clear the "recorder seen" flag. |
/redact auto | Go back to watching for recorders (checks right away). |
/redact status (or /redact) | Show the mode, whether it is masking, which recorder was seen, and which categories are on, as a compact card. |
/redact overrides the configured mode for the current session only.
| Category | Masked |
|---|---|
| Contact | E-mail addresses (not git@host SSH logins or icon@2x.png); international +… phone numbers; US numbers like (415) 555-0123, 415-555-0123, 1-800-555-0199. |
| Network | IPv4 and IPv6 addresses. Loopback (127.*, ::1), 0.0.0.0 and 255.255.* netmasks are left alone. |
| Financial | Card numbers (13 to 19 digits, a card network's prefix, Luhn-checked); IBANs (mod-97 checked); US SSNs (123-45-6789); account, routing and sort-code numbers that follow a banking word (Account number: 12345678, sort code 12-34-56). |
| Secrets | sk-ant-…, sk-…/sk-proj-…, Stripe sk_live_…, ghp_/gho_/ghu_/ghs_/ghr_…, github_pat_…, glpat-…, Slack xox[abpors]-…, AWS AKIA…/ASIA…, Google AIza…, npm_…, hf_…; JWTs; Bearer … and Basic … credentials; private-key blocks (-----BEGIN … PRIVATE KEY-----; the BEGIN/END lines stay, the body is masked line by line); the password in scheme://user:password@host; assignments with secret-sounding names: API_KEY=…, PASSWORD=…, DB_PASS="…", "client_secret": "…", password: …, --password=…. |
| Names | The names in the names setting, your OS username (from $USER or whoami, unless it is a generic one like root or user) and your git config user.name. Whole words only, any case. |
| Paths | The username in /Users/<you>/, /home/<you>/ and C:\Users\<you>\ (/Users/Shared is left alone). |
Masks use a fixed width (████) so the length of the hidden value doesn't leak. Most also carry a tag (████[email], ████[key]) so the video still makes sense. Masking never adds or removes a line.
The following are deliberately not masked: git SHAs, version numbers (1.2.3, v18.17.1), timestamps (2024-10-06T12:34:56Z, 12:34:56), file.ts:12:34 positions, UUIDs, epoch timestamps, sha256: digests, std::vector-style paths, MAC addresses, max_tokens: 4096, password: string type annotations, and placeholders like ${API_KEY} or <your token>.
Creating notes.md).$.ui.toast, $.ui.status, $.ui.notice, $.ui.log and $.ui.open).pii-shield runs in the engine, on the machine that hosts the session (your laptop, or the cloud container of a cloud session). Every surface watching the session asks that engine for each row, and the engine hands back the props and trees pii-shield rewrote. So the masked text is what the terminal, the desktop app (Code tab) and the Claude mobile app (watching a cloud or Remote Control session) all draw. Nothing on the phone or the desktop sees the raw value first.
| Terminal | Desktop | Mobile | |
|---|---|---|---|
| Transcript rows, command output, AskUserQuestion | masked | masked | masked |
| Other plugins' panes | masked | masked | masked |
| Band above the prompt, spinner | masked | masked | not shown on mobile |
| Toasts, status entries | masked | masked | masked |
Status entry ● REDACTING / ○ pii-shield | yes | yes | yes |
/redact status card | yes | yes | yes (sized to the phone's width) |
Recording your phone's screen is not detected. Recorder detection looks at the process list of the machine hosting the session, not at your phone. iOS and Android screen recording, or mirroring your phone to a computer, will not turn redaction on. Run
/redact on(from the phone or anywhere else) before you start recording. The/redact statuscard says so on the phone while redaction is off.
The same holds for the desktop app watching a cloud session: a recorder on your desktop is only seen when the session runs on that desktop.
Open /config (or the plugin's config screen at install time):
| Setting | Default | Meaning |
|---|---|---|
mode | auto | auto, on or off (see above). |
names | empty | Comma-separated extra names or words to mask: people, clients, project code names. |
maskContact | on | E-mails and phone numbers. |
maskNetwork | on | IP addresses. |
maskFinancial | on | Cards, IBANs, SSNs, bank numbers. |
maskSecrets | on | Keys, tokens, private keys, secret assignments. |
maskNames | on | The names above plus your OS username and git name. |
maskPaths | on | Your username in home-folder paths. |
If masking a row, pane or band fails (an exception, or redaction overruns its time budget) while redaction is on, or before pii-shield knows whether it is on, it is replaced by a single dim line: ████ pii-shield hid this row (it could not be redacted). It is plain Text, so every surface, the phone included, can draw it. A toast, status entry or pane title that could not be masked is refused; a transcript log line goes to the debug log instead. Nothing is drawn unmasked. For a privacy tool, a hidden row is a smaller problem than a leaked one. While redaction is known to be off, a failure just draws the normal row.
/redact on before you start recording if you need to be sure. Polling runs every 5 seconds, so a recording can start up to 5 seconds before detection. Windows and other platforms have no detection, so use /redact on.~/.claude/projects/…), exports, --resume and anything that reads the session see the real values. Only the screen is masked.PushNotification tool, and OS notifications a plugin sends itself (such as the notify mod's ntfy push): masking those would change what is sent, not what is drawn, and the lock screen draws them outside Claude;Client module, an Svg's markup, or the values of Input/Select fields, is not masked.dead::beef read as IPv6, or a long random literal assigned to a *_token field.jins_test). Short or common words in names will mask that word everywhere. A generic OS username (root, user, admin, …) is skipped for that reason.claude plugin validate mods/pii-shield
claude plugin test mods/pii-shield
The redaction engine is hooks/redact.ts (pure functions with no engine access). Recorder detection is hooks/detect.ts. The hooks module is hooks/register.tsx. The render tests run every transcript-row, pane and command-output case on terminal, desktop and mobile, and the band and spinner on terminal and desktop, the surfaces that raise them.
MIT
hooks/register.tsx 563 lines1// pii-shield: masks personal data and secrets on screen while you record.
2//
3// Display only: every drawing hook here is a `ui.render` rewrite of the props
4// a row is drawn from (`next({ ...e, props })`), a rewrite of the tree another
5// plugin drew (panes, the band), or a rewrite of the text of a `$.ui` call
6// (toasts, status entries, notices, pane titles). What the model reads and
7// what the transcript stores are never touched.
8//
9// Surfaces: terminal, desktop and the mobile app. A remote surface asks core
10// for a row over the wire (ui_render) and draws with the props these hooks
11// handed core, so the phone draws the masked text too. No hook branches on
12// the surface to decide whether to mask, and every element drawn here (Box,
13// Text) is in every surface's table, mobile's included.
14//
15// Failure policy: FAIL CLOSED. If redacting a row throws or overruns its
16// budget while redaction is (or may be) on, the row is replaced by a one-line
17// placeholder instead of being drawn unredacted. While redaction is known to
18// be off, a failure draws the engine's own row.
19
20import { atom, read, update } from 'claude-code'
21import type { EngineInterface, PluginOptions, Register, RenderElement, RenderInput, RenderNode } from 'claude-code'
22
23import type { PiiShieldMode, PiiShieldState } from '../types'
24import { findRecorder, isMaskableUsername, parsePlatform, platformFromHome, processListArgv } from './detect'
25import type { Platform } from './detect'
26import { BLOCK, CATEGORIES, parseNameList, redactDeep, redactText } from './redact'
27import type { Category, RedactOptions } from './redact'
28
29const POLL_MS = 5000
30const PS_TIMEOUT_MS = 4000
31const MAX_POLL_FAILURES = 3
32
33export const STATUS_ON = '● REDACTING'
34export const STATUS_OFF = '○ pii-shield'
35
36const MODES: readonly PiiShieldMode[] = ['auto', 'on', 'off']
37
38const CATEGORY_OPTION: Readonly<Record<Category, string>> = {
39 contact: 'maskContact',
40 network: 'maskNetwork',
41 financial: 'maskFinancial',
42 secrets: 'maskSecrets',
43 names: 'maskNames',
44 paths: 'maskPaths',
45}
46
47const shield = atom({ plugin: 'pii-shield', key: 'shield' } as const, { override: null, recorder: null })
48const identity = atom({ plugin: 'pii-shield', key: 'identity' } as const, [])
49
50/** What one load of the module knows: its options and its poller. */
51type Ctx = {
52 defaultMode: PiiShieldMode
53 categories: Record<Category, boolean>
54 extraNames: string[]
55 /**
56 * Only for the failure fallback: whether the last draw that read the state
57 * redacted. Unknown counts as on, so a failure before any read fails closed.
58 */
59 wasRedacting: boolean
60 platform: Platform
61 pollFailures: number
62 isPolling: boolean
63 timer: { cancel: () => void } | undefined
64}
65
66// ---------------------------------------------------------------------------
67// Pure helpers
68
69function configMode(options: PluginOptions): PiiShieldMode {
70 const value = options['mode']
71 return MODES.find(mode => mode === value) ?? 'auto'
72}
73
74function configCategories(options: PluginOptions): Record<Category, boolean> {
75 const out = {} as Record<Category, boolean>
76 for (const category of CATEGORIES) out[category] = options[CATEGORY_OPTION[category]] !== false
77 return out
78}
79
80function configNames(options: PluginOptions): string[] {
81 const value = options['names']
82 if (typeof value === 'string') return parseNameList(value)
83 if (Array.isArray(value)) return parseNameList(value.join(','))
84 return []
85}
86
87export function effectiveMode(state: PiiShieldState, fallback: PiiShieldMode): PiiShieldMode {
88 return state.override ?? fallback
89}
90
91export function isRedacting(state: PiiShieldState, fallback: PiiShieldMode): boolean {
92 const mode = effectiveMode(state, fallback)
93 return mode === 'on' || (mode === 'auto' && state.recorder !== null)
94}
95
96/**
97 * The AskUserQuestion dialog's questions with each option's `description` and
98 * `preview` redacted. The question text and the option labels are left alone:
99 * the dialog answers with the label picked, keyed by the question's text, so
100 * masking them would change what Claude reads back.
101 */
102export function redactQuestions(questions: unknown[], opts: RedactOptions): unknown[] {
103 return questions.map(question => {
104 if (question === null || typeof question !== 'object' || !('options' in question)) return question
105 const options = (question as { options: unknown }).options
106 if (!Array.isArray(options)) return question
107 const redacted = options.map((option: unknown) => {
108 if (option === null || typeof option !== 'object') return option
109 const out: Record<string, unknown> = { ...(option as Record<string, unknown>) }
110 if (typeof out['description'] === 'string') out['description'] = redactText(out['description'], opts)
111 if (typeof out['preview'] === 'string') out['preview'] = redactText(out['preview'], opts)
112 return out
113 })
114 return { ...question, options: redacted }
115 })
116}
117
118/** The first line of every `/redact` answer the status card draws. */
119export const CARD_HEAD = 'pii-shield: '
120const DISPLAY_ONLY = 'Display only: Claude and the transcript still see the real values.'
121
122/**
123 * The `/redact` answer as text, one `Label: value` line per field: what the
124 * model reads, what a surface draws if the card cannot be, and what the card
125 * (`statusCard`) is drawn from, so a past row keeps the state it reported.
126 */
127function describe(ctx: Ctx, state: PiiShieldState): string {
128 const mode = effectiveMode(state, ctx.defaultMode)
129 const source = state.override === null ? 'from settings' : 'set by /redact'
130 const isOn = isRedacting(state, ctx.defaultMode)
131 let recorder: string
132 if (mode !== 'auto') {
133 recorder = `not watched (mode ${mode})`
134 } else if (state.recorder !== null) {
135 recorder = `${state.recorder}, seen this session (stays on until /redact off)`
136 } else if (processListArgv(ctx.platform) === null) {
137 recorder = 'detection not available on this platform; use /redact on'
138 } else if (ctx.timer === undefined) {
139 recorder = 'detection stopped; use /redact on before recording'
140 } else {
141 recorder = 'none seen; watching every 5s'
142 }
143 const enabled = CATEGORIES.filter(category => ctx.categories[category])
144 return [
145 `${CARD_HEAD}${isOn ? 'REDACTING' : 'not redacting'}`,
146 `Mode: ${mode} (${source})`,
147 `Recorder: ${recorder}`,
148 `Masking: ${enabled.length === 0 ? 'nothing' : enabled.join(', ')}`,
149 DISPLAY_ONLY,
150 ].join('\n')
151}
152
153/** The fields of a `/redact` answer (`describe`), or null for any other text. */
154export function parseStatus(text: string): { isOn: boolean; fields: [string, string][] } | null {
155 const [head, ...rest] = text.split('\n')
156 if (head === undefined || !head.startsWith(CARD_HEAD)) return null
157 const fields: [string, string][] = []
158 for (const line of rest) {
159 if (line === DISPLAY_ONLY) continue
160 const at = line.indexOf(': ')
161 if (at <= 0) return null
162 fields.push([line.slice(0, at).toLowerCase(), line.slice(at + 2)])
163 }
164 return { isOn: head.slice(CARD_HEAD.length) === 'REDACTING', fields }
165}
166
167/**
168 * Every string a tree shows, redacted: Text/Box/Link children, a Button's
169 * label, a Markdown's text, a Code's source and path, an Svg's alt. An
170 * element with nothing to mask is handed back as it came (same object), so a
171 * tree with no personal data reaches the surface untouched. Input and Select
172 * values are left alone (masking them would change what the person types),
173 * as are a Client's own drawing and an Svg's markup, which no hook can read.
174 */
175export function redactTree(node: RenderNode, opts: RedactOptions, depth = 0): RenderNode {
176 if (typeof node === 'string') return redactText(node, opts)
177 if (depth > 64) return node
178 switch (node.type) {
179 case 'Box':
180 case 'Text':
181 case 'Link': {
182 const children = node.children
183 if (children === undefined) return node
184 const out = children.map(child => redactTree(child, opts, depth + 1))
185 return out.every((child, i) => child === children[i]) ? node : ({ ...node, children: out } as RenderElement)
186 }
187 case 'Button': {
188 const label = redactText(node.props.label, opts)
189 return label === node.props.label ? node : { ...node, props: { ...node.props, label } }
190 }
191 case 'Markdown': {
192 const text = redactText(node.props.text, opts)
193 return text === node.props.text ? node : { ...node, props: { ...node.props, text } }
194 }
195 case 'Code': {
196 const props = { ...node.props, source: redactText(node.props.source, opts) }
197 if (node.props.path !== undefined) props.path = redactText(node.props.path, opts)
198 return props.source === node.props.source && props.path === node.props.path ? node : { ...node, props }
199 }
200 case 'Svg': {
201 const alt = redactText(node.props.alt, opts)
202 return alt === node.props.alt ? node : { ...node, props: { ...node.props, alt } }
203 }
204 default:
205 return node
206 }
207}
208
209// ---------------------------------------------------------------------------
210// Helpers on $
211
212async function redactOptions($: EngineInterface, ctx: Ctx): Promise<RedactOptions> {
213 const found = await read($, identity)
214 return { categories: ctx.categories, names: [...ctx.extraNames, ...found] }
215}
216
217async function shouldRedact($: EngineInterface, ctx: Ctx): Promise<boolean> {
218 const state = await read($, shield)
219 ctx.wasRedacting = isRedacting(state, ctx.defaultMode)
220 return ctx.wasRedacting
221}
222
223/** A `$.ui` call's text, masked while redacting. */
224async function maskedText($: EngineInterface, ctx: Ctx, text: string | undefined): Promise<string | undefined> {
225 if (text === undefined || !(await shouldRedact($, ctx))) return text
226 return redactText(text, await redactOptions($, ctx))
227}
228
229async function showStatus($: EngineInterface, ctx: Ctx): Promise<void> {
230 const state = await read($, shield)
231 $.ui.status(isRedacting(state, ctx.defaultMode) ? STATUS_ON : STATUS_OFF)
232}
233
234/** The OS username (env, else `whoami`), the home folder's name, git user.name. */
235async function lookUpIdentity($: EngineInterface, cwd: string): Promise<string[]> {
236 const found: string[] = []
237 const user = (await $.env.get('USER')) ?? (await $.env.get('LOGNAME'))
238 const home = await $.env.get('HOME')
239 if (user !== undefined) found.push(user)
240 if (home !== undefined) {
241 const base = home.replace(/\/+$/, '').split('/').pop()
242 if (base !== undefined && base.length > 0) found.push(base)
243 }
244 if (user === undefined) {
245 try {
246 const who = await $.process.run(['whoami'], { timeoutMs: 3000 })
247 if (who.exitCode === 0) found.push(who.stdout.trim())
248 } catch {
249 // No whoami: nothing to add.
250 }
251 }
252 const names = found.filter(isMaskableUsername)
253 try {
254 const git = await $.process.run(['git', 'config', 'user.name'], { cwd, timeoutMs: 3000 })
255 const name = git.stdout.trim()
256 if (git.exitCode === 0 && name.length >= 2) names.push(name)
257 } catch {
258 // No git: nothing to add.
259 }
260 return [...new Set(names)]
261}
262
263/** `uname -s`, else a guess from the home folder's shape. */
264async function detectPlatform($: EngineInterface): Promise<Platform> {
265 try {
266 const uname = await $.process.run(['uname', '-s'], { timeoutMs: 3000 })
267 if (uname.exitCode === 0) return parsePlatform(uname.stdout)
268 } catch {
269 // Fall through to the guess.
270 }
271 return platformFromHome(await $.env.get('HOME'))
272}
273
274/** One look at the process list, in auto mode, until a recorder is seen. */
275async function poll($: EngineInterface, ctx: Ctx): Promise<void> {
276 if (ctx.isPolling) return
277 const state = await read($, shield)
278 if (effectiveMode(state, ctx.defaultMode) !== 'auto' || state.recorder !== null) return
279 const argv = processListArgv(ctx.platform)
280 if (argv === null) return
281 ctx.isPolling = true
282 try {
283 const ps = await $.process.run(argv, { timeoutMs: PS_TIMEOUT_MS })
284 ctx.pollFailures = 0
285 const recorder = findRecorder(ps.stdout, ctx.platform)
286 if (recorder === null) return
287 let isNew = false
288 await update($, shield, current => {
289 if (effectiveMode(current, ctx.defaultMode) !== 'auto' || current.recorder !== null) return current
290 isNew = true
291 return { ...current, recorder }
292 })
293 if (isNew) {
294 $.ui.toast(`${recorder} detected: masking personal data on screen. /redact off to stop.`, { timeoutMs: 6000 })
295 await showStatus($, ctx)
296 }
297 } catch (error) {
298 ctx.pollFailures += 1
299 if (ctx.pollFailures >= MAX_POLL_FAILURES) {
300 ctx.timer?.cancel()
301 ctx.timer = undefined
302 $.ui.log(`pii-shield: recorder detection stopped (${String(error)}). Use /redact on before recording.`, {
303 to: 'debug',
304 })
305 }
306 } finally {
307 ctx.isPolling = false
308 }
309}
310
311function startPolling($: EngineInterface, ctx: Ctx): void {
312 ctx.timer?.cancel()
313 ctx.timer = undefined
314 ctx.pollFailures = 0
315 if (processListArgv(ctx.platform) === null) return
316 ctx.timer = $.clock.every(POLL_MS, () => {
317 void poll($, ctx)
318 })
319}
320
321/** The placeholder drawn in a row's place when redacting it failed. */
322function failClosed($: EngineInterface, e: RenderInput) {
323 const { Text } = $.ui.resolve(e)
324 return <Text dimColor>{BLOCK} pii-shield hid this row (it could not be redacted)</Text>
325}
326
327/**
328 * Runs async steps one after another, in the order they were queued. A step
329 * that throws still lets the next one run.
330 */
331export function queue(): <T>(step: () => Promise<T>) => Promise<T> {
332 let tail: Promise<void> = Promise.resolve()
333 return async step => {
334 const before = tail
335 let release = () => {}
336 tail = new Promise<void>(resolve => {
337 release = resolve
338 })
339 try {
340 await before
341 return await step()
342 } finally {
343 release()
344 }
345 }
346}
347
348const CARD_WIDTH = 56
349
350/**
351 * `/redact`'s answer as a compact card, Box and Text only so every surface
352 * (the phone's included) draws it; null when the row is not a status answer.
353 */
354function statusCard($: EngineInterface, e: RenderInput<'CommandOutput'>) {
355 if (e.props.isErrored) return null
356 const status = parseStatus(e.props.text)
357 if (status === null) return null
358 const { Box, Text } = $.ui.resolve(e)
359 const width = Math.max(20, Math.min(CARD_WIDTH, e.viewport?.columns ?? CARD_WIDTH))
360 const labelWidth = Math.max(...status.fields.map(([label]) => label.length)) + 1
361 return (
362 <Box flexDirection="column" borderStyle="round" borderColor={status.isOn ? 'error' : 'subtle'} paddingX={1} width={width}>
363 <Text bold color={status.isOn ? 'error' : undefined}>
364 {status.isOn ? STATUS_ON : STATUS_OFF}
365 </Text>
366 {status.fields.map(([label, value]) => (
367 <Box flexDirection="row">
368 <Text dimColor>{label.padEnd(labelWidth)}</Text>
369 <Text wrap="wrap">{value}</Text>
370 </Box>
371 ))}
372 <Text dimColor wrap="wrap">
373 {e.surface === 'mobile' && !status.isOn
374 ? 'Recording this phone? It is not detected: run /redact on.'
375 : 'Display only: Claude and the transcript see real values.'}
376 </Text>
377 </Box>
378 )
379}
380
381// ---------------------------------------------------------------------------
382
383export const register: Register = (on, options) => {
384 const ctx: Ctx = {
385 defaultMode: configMode(options),
386 categories: configCategories(options),
387 extraNames: configNames(options),
388 wasRedacting: true,
389 platform: 'other',
390 pollFailures: 0,
391 isPolling: false,
392 timer: undefined,
393 }
394
395 on('session.start', async ($, e, next) => {
396 const started = await next(e)
397 // Each step stands alone: one failing must not stop recorder detection.
398 try {
399 await $.command.register({
400 name: 'redact',
401 description: 'pii-shield: mask personal data on screen (on, off, auto, status)',
402 argumentHint: 'on|off|auto|status',
403 })
404 } catch (error) {
405 $.ui.log(`pii-shield: /redact could not be registered (${String(error)})`, { to: 'debug' })
406 }
407 try {
408 const names = await lookUpIdentity($, e.cwd)
409 await update($, identity, () => names)
410 } catch (error) {
411 $.ui.log(`pii-shield: could not look up your username (${String(error)})`, { to: 'debug' })
412 }
413 ctx.platform = await detectPlatform($)
414 startPolling($, ctx)
415 await showStatus($, ctx)
416 void poll($, ctx)
417 return started
418 })
419
420 on('command.run', { command: 'redact' }, async ($, e) => {
421 const arg = e.args.trim().toLowerCase()
422 if (arg === 'on' || arg === 'off' || arg === 'auto') {
423 const mode: PiiShieldMode = arg
424 await update($, shield, () => ({ override: mode, recorder: null }))
425 if (arg === 'auto') {
426 if (ctx.timer === undefined) startPolling($, ctx)
427 await poll($, ctx)
428 }
429 await showStatus($, ctx)
430 return { text: describe(ctx, await read($, shield)) }
431 }
432 if (arg === '' || arg === 'status') return { text: describe(ctx, await read($, shield)) }
433 return { text: 'Usage: /redact on | off | auto | status' }
434 })
435
436 // ---- rows drawn from text -------------------------------------------------
437
438 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
439 if (!(await shouldRedact($, ctx))) return next(e)
440 const opts = await redactOptions($, ctx)
441 return next({ ...e, props: { ...e.props, text: redactText(e.props.text, opts) } })
442 }).catch(($, e, next) => (next.called || !ctx.wasRedacting ? next(e) : failClosed($, e)))
443
444 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
445 if (!(await shouldRedact($, ctx))) return next(e)
446 const opts = await redactOptions($, ctx)
447 return next({ ...e, props: { ...e.props, text: redactText(e.props.text, opts) } })
448 }).catch(($, e, next) => (next.called || !ctx.wasRedacting ? next(e) : failClosed($, e)))
449
450 // `/redact`'s own answer, as a compact card on every surface. Registered
451 // before the masking hook below, so it sits outside it: its text holds no
452 // personal data (a recorder's name at most).
453 on('ui.render', { component: 'CommandOutput', props: { command: 'redact' } }, ($, e, next) => {
454 return statusCard($, e) ?? next(e)
455 }).catch(($, e, next) => next(e))
456
457 on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
458 if (!(await shouldRedact($, ctx))) return next(e)
459 const opts = await redactOptions($, ctx)
460 return next({ ...e, props: { ...e.props, text: redactText(e.props.text, opts) } })
461 }).catch(($, e, next) => (next.called || !ctx.wasRedacting ? next(e) : failClosed($, e)))
462
463 // ---- rows drawn from tool data --------------------------------------------
464
465 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
466 if (!(await shouldRedact($, ctx))) return next(e)
467 const opts = await redactOptions($, ctx)
468 const props = { ...e.props, input: redactDeep(e.props.input, opts) }
469 if (e.props.output !== undefined) props.output = redactDeep(e.props.output, opts)
470 return next({ ...e, props })
471 }).catch(($, e, next) => (next.called || !ctx.wasRedacting ? next(e) : failClosed($, e)))
472
473 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
474 if (!(await shouldRedact($, ctx))) return next(e)
475 const opts = await redactOptions($, ctx)
476 return next({ ...e, props: { ...e.props, output: redactDeep(e.props.output, opts) } })
477 }).catch(($, e, next) => (next.called || !ctx.wasRedacting ? next(e) : failClosed($, e)))
478
479 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
480 if (!(await shouldRedact($, ctx))) return next(e)
481 const opts = await redactOptions($, ctx)
482 const calls = e.props.calls.map(call => {
483 const out = { ...call, input: redactDeep(call.input, opts) }
484 if (call.output !== undefined) out.output = redactDeep(call.output, opts)
485 return out
486 })
487 return next({ ...e, props: { ...e.props, calls } })
488 }).catch(($, e, next) => (next.called || !ctx.wasRedacting ? next(e) : failClosed($, e)))
489
490 on('ui.render', { component: 'AskUserQuestion' }, async ($, e, next) => {
491 if (!(await shouldRedact($, ctx))) return next(e)
492 const opts = await redactOptions($, ctx)
493 return next({ ...e, props: { ...e.props, questions: redactQuestions(e.props.questions, opts) } })
494 }).catch(($, e, next) => (next.called || !ctx.wasRedacting ? next(e) : failClosed($, e)))
495
496 // The turn's working line: on the desktop it names the step (`Creating
497 // notes.md`), which can carry a path. Terminal and desktop only.
498 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
499 if (!(await shouldRedact($, ctx))) return next(e)
500 const opts = await redactOptions($, ctx)
501 const message = e.props.message === null ? null : redactText(e.props.message, opts)
502 return next({ ...e, props: { ...e.props, word: redactText(e.props.word, opts), message } })
503 }).catch(($, e, next) => (next.called || !ctx.wasRedacting ? next(e) : failClosed($, e)))
504
505 // ---- trees other plugins draw ---------------------------------------------
506 // A pane (every surface, the phone's included) and the band above the
507 // prompt (terminal and desktop): the tree the hooks beneath drew, its text
508 // masked. Only plugins whose hooks sit beneath this one in the chain are
509 // reached. Here a failure after `next` must not fall back to `next(e)`: that
510 // would replay the raw tree, so it fails closed whenever redaction may be on.
511
512 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
513 if (!(await shouldRedact($, ctx))) return next(e)
514 const opts = await redactOptions($, ctx)
515 return redactTree(await next(e), opts) as RenderElement
516 }).catch(($, e, next) => (ctx.wasRedacting ? failClosed($, e) : next(e)))
517
518 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
519 if (!(await shouldRedact($, ctx))) return next(e)
520 const opts = await redactOptions($, ctx)
521 return redactTree(await next(e), opts) as RenderElement
522 }).catch(($, e, next) => (ctx.wasRedacting ? failClosed($, e) : next(e)))
523
524 // ---- text handed to $.ui by any plugin -------------------------------------
525 // Toasts and status entries are drawn on every surface (the phone's status
526 // list included). A failure while redaction may be on refuses the call.
527
528 const DENIED = { deny: 'pii-shield could not redact this text' } as const
529
530 // One queue for these calls: a caller's `status('x')` then `status(undefined)`
531 // reach the status line in that order, though masking the first takes a
532 // state read the second does not need. Each call is handed on (`next`) in
533 // its turn; the queue never waits for what it settles to.
534 const inOrder = queue()
535
536 on('ui.toast', async ($, e, next) => {
537 const { sent } = await inOrder(async () => ({ sent: next({ ...e, text: (await maskedText($, ctx, e.text)) ?? e.text }) }))
538 return sent
539 }).catch(($, e, next) => (ctx.wasRedacting && !next.called ? DENIED : next(e)))
540
541 on('ui.status', async ($, e, next) => {
542 const { sent } = await inOrder(async () => ({ sent: next({ ...e, text: await maskedText($, ctx, e.text) }) }))
543 return sent
544 }).catch(($, e, next) => (ctx.wasRedacting && !next.called ? DENIED : next(e)))
545
546 on('ui.notice', async ($, e, next) => {
547 const { sent } = await inOrder(async () => ({ sent: next({ ...e, text: await maskedText($, ctx, e.text) }) }))
548 return sent
549 }).catch(($, e, next) => (ctx.wasRedacting && !next.called ? DENIED : next(e)))
550
551 // A line for the transcript; the debug log is a file, not the screen.
552 on('ui.log', async ($, e, next) => {
553 if (e.to !== 'transcript') return next(e)
554 const { sent } = await inOrder(async () => ({ sent: next({ ...e, text: (await maskedText($, ctx, e.text)) ?? e.text }) }))
555 return sent
556 }).catch(($, e, next) => (ctx.wasRedacting && !next.called ? next({ ...e, to: 'debug' }) : next(e)))
557
558 on('ui.open', async ($, e, next) => {
559 if (e.title === undefined || !(await shouldRedact($, ctx))) return next(e)
560 return next({ ...e, title: redactText(e.title, await redactOptions($, ctx)) })
561 }).catch(($, e, next) => (ctx.wasRedacting && !next.called ? DENIED : next(e)))
562}
563hooks/detect.ts 147 lines1// pii-shield: screen recorder and screen-share detection. Pure helpers; the
2// hooks module runs the commands through `$.process.run` and hands the output
3// here.
4//
5// Heuristic by nature: macOS has no public "is the screen being captured" API
6// and Linux compositors expose none either, so this looks for the processes
7// of known recorders and sharing helpers.
8
9export type Platform = 'darwin' | 'linux' | 'other'
10
11/** `uname -s` output → the platform. */
12export function parsePlatform(uname: string): Platform {
13 const s = uname.trim().toLowerCase()
14 if (s.startsWith('darwin')) return 'darwin'
15 if (s.startsWith('linux')) return 'linux'
16 return 'other'
17}
18
19/** A guess from the home folder, for when `uname` cannot run. */
20export function platformFromHome(home: string | undefined): Platform {
21 if (home === undefined) return 'other'
22 if (home.startsWith('/Users/')) return 'darwin'
23 if (home.startsWith('/home/') || home === '/root') return 'linux'
24 return 'other'
25}
26
27/**
28 * The argv that lists running processes' executable names, one per line, no
29 * header. macOS's `comm` is the executable's full path; procps's is the name
30 * cut to 15 characters.
31 */
32export function processListArgv(platform: Platform): readonly string[] | null {
33 if (platform === 'darwin') return ['ps', '-axo', 'comm=']
34 if (platform === 'linux') return ['ps', '-eo', 'comm=']
35 return null
36}
37
38export type Recorder = {
39 /** The executable's name, as `ps` shows it (case-insensitive). */
40 process: string
41 /** What the toast calls it. */
42 label: string
43}
44
45export const RECORDERS: Readonly<Record<'darwin' | 'linux', readonly Recorder[]>> = {
46 darwin: [
47 { process: 'QuickTime Player', label: 'QuickTime Player' },
48 { process: 'screencaptureui', label: 'macOS screen recording' },
49 { process: 'screencapture', label: 'macOS screencapture' },
50 { process: 'OBS', label: 'OBS' },
51 { process: 'obs', label: 'OBS' },
52 { process: 'Loom', label: 'Loom' },
53 { process: 'CleanShot X', label: 'CleanShot X' },
54 { process: 'Kap', label: 'Kap' },
55 { process: 'ScreenFlow', label: 'ScreenFlow' },
56 { process: 'Screen Studio', label: 'Screen Studio' },
57 { process: 'Camtasia', label: 'Camtasia' },
58 { process: 'Camtasia 2023', label: 'Camtasia' },
59 { process: 'Camtasia 2024', label: 'Camtasia' },
60 { process: 'Camtasia 2025', label: 'Camtasia' },
61 { process: 'Rotato', label: 'Rotato' },
62 // Zoom starts CptHost only while you share your screen.
63 { process: 'CptHost', label: 'Zoom screen share' },
64 { process: 'caphost', label: 'Zoom screen share' },
65 ],
66 linux: [
67 { process: 'obs', label: 'OBS' },
68 { process: 'wf-recorder', label: 'wf-recorder' },
69 { process: 'simplescreenrecorder', label: 'SimpleScreenRecorder' },
70 { process: 'kooha', label: 'Kooha' },
71 { process: 'peek', label: 'Peek' },
72 { process: 'vokoscreenNG', label: 'vokoscreenNG' },
73 { process: 'kazam', label: 'Kazam' },
74 { process: 'gpu-screen-recorder', label: 'GPU Screen Recorder' },
75 { process: 'recordmydesktop', label: 'recordMyDesktop' },
76 { process: 'green-recorder', label: 'Green Recorder' },
77 { process: 'blue-recorder', label: 'Blue Recorder' },
78 { process: 'byzanz-record', label: 'Byzanz' },
79 { process: 'wl-screenrec', label: 'wl-screenrec' },
80 { process: 'CptHost', label: 'Zoom screen share' },
81 ],
82}
83
84/** The part after the last `/`: macOS `comm` is a full path. */
85function baseName(line: string): string {
86 const trimmed = line.trim()
87 const slash = trimmed.lastIndexOf('/')
88 return slash === -1 ? trimmed : trimmed.slice(slash + 1)
89}
90
91/** procps cuts a process name to 15 characters (TASK_COMM_LEN - 1). */
92const LINUX_COMM_LENGTH = 15
93
94/**
95 * The first known recorder in a process list (`ps` output), by its label, or
96 * null when none runs.
97 */
98export function findRecorder(processList: string, platform: Platform): string | null {
99 if (platform === 'other') return null
100 const known = RECORDERS[platform]
101 const running = new Set<string>()
102 for (const line of processList.split('\n')) {
103 const name = baseName(line).toLowerCase()
104 if (name.length > 0) running.add(name)
105 }
106 for (const recorder of known) {
107 const want = recorder.process.toLowerCase()
108 if (running.has(want)) return recorder.label
109 if (platform === 'linux' && want.length > LINUX_COMM_LENGTH && running.has(want.slice(0, LINUX_COMM_LENGTH))) {
110 return recorder.label
111 }
112 }
113 return null
114}
115
116/** OS usernames too generic to mask everywhere they appear as a word. */
117const GENERIC_USERNAMES = new Set([
118 'root',
119 'user',
120 'users',
121 'admin',
122 'administrator',
123 'ubuntu',
124 'debian',
125 'ec2-user',
126 'runner',
127 'vagrant',
128 'pi',
129 'guest',
130 'test',
131 'dev',
132 'developer',
133 'node',
134 'app',
135 'docker',
136 'nobody',
137 'www-data',
138 'default',
139 'me',
140])
141
142/** Whether an OS username is specific enough to mask as a word. */
143export function isMaskableUsername(name: string): boolean {
144 const n = name.trim().toLowerCase()
145 return n.length >= 3 && !GENERIC_USERNAMES.has(n)
146}
147hooks/redact.ts 684 lines1// pii-shield: the redaction engine. Pure (no `$`), so it is unit-tested.
2//
3// Every rule replaces what it matches with a placeholder made of private-use
4// characters, so a later rule never matches inside an earlier mask (a phone
5// rule inside a masked card, a name inside a masked e-mail). The placeholders
6// are swapped for the visible masks at the end.
7//
8// No rule matches across a line break and no mask holds one, so a redacted
9// text has exactly the line count of the original (tool renderers such as a
10// diff view rely on that).
11
12export type Category = 'contact' | 'network' | 'financial' | 'secrets' | 'names' | 'paths'
13
14export const CATEGORIES: readonly Category[] = [
15 'contact',
16 'network',
17 'financial',
18 'secrets',
19 'names',
20 'paths',
21]
22
23export type RedactOptions = {
24 /** Names or words to mask, matched case-insensitively on word boundaries. */
25 names?: readonly string[]
26 /** Categories to mask; a category left out is on. */
27 categories?: Partial<Record<Category, boolean>>
28}
29
30/** The block every mask is drawn with: fixed width, so lengths do not leak. */
31export const BLOCK = '████'
32
33/** The visible mask: the block, then the kind of value it hides. */
34export function mask(tag?: string): string {
35 return tag === undefined ? BLOCK : `${BLOCK}[${tag}]`
36}
37
38// ---------------------------------------------------------------------------
39// Checksums
40
41/** Luhn (mod 10) check of a string of digits. */
42export function luhn(digits: string): boolean {
43 if (!/^\d+$/.test(digits)) return false
44 let sum = 0
45 let double = false
46 for (let i = digits.length - 1; i >= 0; i -= 1) {
47 let d = digits.charCodeAt(i) - 48
48 if (double) {
49 d *= 2
50 if (d > 9) d -= 9
51 }
52 sum += d
53 double = !double
54 }
55 return sum % 10 === 0
56}
57
58/** Issuer prefixes of the card networks; keeps epoch-ms timestamps out. */
59const CARD_PREFIX = /^(?:4|5[1-5]|2[2-7]|3[47]|3[068]|35|6)/
60
61/** A 13 to 19 digit card number with a known issuer prefix and a valid Luhn digit. */
62export function isCardNumber(digits: string): boolean {
63 return digits.length >= 13 && digits.length <= 19 && CARD_PREFIX.test(digits) && luhn(digits)
64}
65
66/** ISO 13616 IBAN check: shape, then mod 97 of the rearranged number is 1. */
67export function isIban(raw: string): boolean {
68 const s = raw.replace(/[ \t]/g, '').toUpperCase()
69 if (!/^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/.test(s)) return false
70 const moved = s.slice(4) + s.slice(0, 4)
71 let rest = 0
72 for (const ch of moved) {
73 const code = ch.charCodeAt(0)
74 const value = code >= 65 ? String(code - 55) : ch
75 for (const digit of value) rest = (rest * 10 + (digit.charCodeAt(0) - 48)) % 97
76 }
77 return rest === 1
78}
79
80// ---------------------------------------------------------------------------
81// Secret-ish assignment names
82
83const SECRET_WORDS = new Set([
84 'password',
85 'passwd',
86 'pwd',
87 'pass',
88 'passphrase',
89 'secret',
90 'secrets',
91 'token',
92 'credential',
93 'credentials',
94 'creds',
95 'apikey',
96 'privatekey',
97 'secretkey',
98 'accesskey',
99 'clientsecret',
100 'dsn',
101 'cookie',
102 'auth',
103])
104
105/** Words that, beside `key`, make it an API or signing key. */
106const KEY_QUALIFIERS = new Set([
107 'api',
108 'access',
109 'secret',
110 'private',
111 'client',
112 'signing',
113 'sign',
114 'encryption',
115 'enc',
116 'master',
117 'license',
118 'licence',
119 'service',
120 'account',
121 'app',
122 'session',
123 'webhook',
124 'aws',
125 'openai',
126 'anthropic',
127 'stripe',
128 'gcp',
129 'google',
130 'gemini',
131 'deploy',
132 'ssh',
133 'admin',
134])
135
136/** Words that say the value is metadata about a secret, not the secret. */
137const NOT_SECRET_WORDS = new Set([
138 'max',
139 'min',
140 'count',
141 'limit',
142 'num',
143 'size',
144 'len',
145 'length',
146 'usage',
147 'budget',
148 'type',
149 'kind',
150 'name',
151 'names',
152 'id',
153 'ids',
154 'file',
155 'path',
156 'dir',
157 'url',
158 'uri',
159 'endpoint',
160 'host',
161 'port',
162 'header',
163 'prefix',
164 'expires',
165 'expiry',
166 'ttl',
167 'timeout',
168 'field',
169 'param',
170 'hint',
171 'label',
172 'placeholder',
173 'policy',
174 'format',
175 'mode',
176 'algorithm',
177 'alg',
178 'scope',
179 'scopes',
180 'provider',
181 'env',
182 'var',
183 'required',
184 'enabled',
185 'tokens',
186 'user',
187 'username',
188 'email',
189 'length',
190 'rotation',
191 'store',
192 'manager',
193])
194
195/** The parts of an identifier: `apiKey`, `API_KEY`, `api-key` → `api`, `key`. */
196export function nameParts(name: string): string[] {
197 return name
198 .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
199 .toLowerCase()
200 .split(/[_.\-]+/)
201 .filter(part => part.length > 0)
202}
203
204/** Whether an assignment's name says its value is a secret. */
205export function isSecretName(name: string): boolean {
206 const parts = nameParts(name)
207 if (parts.length === 0) return false
208 if (parts.some(part => NOT_SECRET_WORDS.has(part))) return false
209 if (parts.some(part => SECRET_WORDS.has(part))) return true
210 const hasKey = parts.includes('key')
211 if (!hasKey) return false
212 if (parts.some(part => KEY_QUALIFIERS.has(part))) return true
213 // `MY_SERVICE_KEY=...`: an all-caps environment name ending in KEY.
214 return /^[A-Z][A-Z0-9_]*_KEY$/.test(name)
215}
216
217const PLACEHOLDER_VALUE =
218 /^(?:\$\{?[\w.:-]*\}?|<[^>]*>?|\{\{[^}]*\}\}|%[\w]+%|\*+|x{3,}|X{3,}|\.\.\.|…|null|nil|none|undefined|true|false|string|number|boolean|bigint|any|unknown|object|str|int|bool|bytes|required|optional|redacted|secret|password|token|env|os\.environ.*|process\.env.*)$/i
219
220const PATH_VALUE = /^(?:\/|~\/|\.\.?\/|[A-Za-z]:\\)/
221
222/** Whether a value assigned to a secret-ish name is worth masking. */
223function isSecretValue(value: string, isQuoted: boolean, isEnvStyle: boolean): boolean {
224 if (value.length === 0) return false
225 if (value.includes(SENTINEL_OPEN)) return !isWholeSentinel(value)
226 if (PLACEHOLDER_VALUE.test(value)) return false
227 if (PATH_VALUE.test(value)) return false
228 if (value.includes(BLOCK)) return false
229 if (isQuoted || isEnvStyle) return true
230 // A bare value after `name:` or `name =` in code: skip expressions and
231 // short words, which are far more often types and variables than secrets.
232 if (value.length < 6) return false
233 if (/[([{]/.test(value)) return false
234 if (/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/.test(value)) return false
235 // A bare identifier is a variable unless it carries a digit (`hunter2`).
236 if (/^[A-Za-z_$][\w$]*$/.test(value) && !/\d/.test(value)) return false
237 return true
238}
239
240// ---------------------------------------------------------------------------
241// Placeholders
242
243const SENTINEL_OPEN = ''
244const SENTINEL_CLOSE = ''
245const SENTINEL_DIGIT_BASE = 0xe100
246const SENTINEL = /([-]+)/g
247
248function isWholeSentinel(value: string): boolean {
249 return /^(?:[-]+)+$/.test(value)
250}
251
252class Masks {
253 readonly list: string[] = []
254
255 put(visible: string): string {
256 const index = this.list.length
257 this.list.push(visible)
258 const digits = index
259 .toString(36)
260 .split('')
261 .map(ch => String.fromCharCode(SENTINEL_DIGIT_BASE + parseInt(ch, 36)))
262 .join('')
263 return SENTINEL_OPEN + digits + SENTINEL_CLOSE
264 }
265
266 restore(text: string): string {
267 return text.replace(SENTINEL, (whole, digits: string) => {
268 const index = parseInt(
269 digits
270 .split('')
271 .map(ch => (ch.charCodeAt(0) - SENTINEL_DIGIT_BASE).toString(36))
272 .join(''),
273 36,
274 )
275 return this.list[index] ?? whole
276 })
277 }
278}
279
280// ---------------------------------------------------------------------------
281// Rules
282
283type Rule = {
284 category: Category
285 /** Global, never matching a line break. */
286 pattern: RegExp
287 /** The replacement, or null to keep the match. */
288 replace: (masks: Masks, match: string, groups: readonly (string | undefined)[]) => string | null
289}
290
291function whole(tag: string, accept?: (match: string, groups: readonly (string | undefined)[]) => boolean): Rule['replace'] {
292 return (masks, match, groups) => (accept === undefined || accept(match, groups) ? masks.put(mask(tag)) : null)
293}
294
295const hasDigitAndLetter = (s: string): boolean => /\d/.test(s) && /[A-Za-z]/.test(s)
296
297const IMAGE_OR_CODE_TLD = new Set([
298 'png',
299 'jpg',
300 'jpeg',
301 'gif',
302 'svg',
303 'webp',
304 'avif',
305 'ico',
306 'js',
307 'mjs',
308 'cjs',
309 'ts',
310 'tsx',
311 'jsx',
312 'json',
313 'css',
314 'scss',
315 'txt',
316 'py',
317 'rb',
318 'rs',
319 'html',
320 'lock',
321 'yaml',
322 'yml',
323 'toml',
324])
325
326/** A valid IPv6 address with one `::` at most, 8 groups without one. */
327export function isIpv6(s: string): boolean {
328 if (!/^[0-9A-Fa-f:]+$/.test(s)) return false
329 const doubles = s.split('::').length - 1
330 if (doubles > 1) return false
331 if (doubles === 0) {
332 const groups = s.split(':')
333 return groups.length === 8 && groups.every(g => g.length >= 1 && g.length <= 4)
334 }
335 const [head = '', tail = ''] = s.split('::')
336 const left = head === '' ? [] : head.split(':')
337 const right = tail === '' ? [] : tail.split(':')
338 const groups = [...left, ...right]
339 return groups.length <= 7 && groups.every(g => g.length >= 1 && g.length <= 4)
340}
341
342function isBoringIpv6(s: string): boolean {
343 const lower = s.toLowerCase()
344 if (lower === '::' || lower === '::1') return true
345 // `Abc::Def` (a path in C++ or Rust) is hex too; an address has digits.
346 const groups = lower.split(':').filter(g => g.length > 0)
347 return groups.length < 2 || !groups.some(g => /\d/.test(g))
348}
349
350const RULES: readonly Rule[] = [
351 // --- secrets -------------------------------------------------------------
352 {
353 // A private key block: the BEGIN/END lines stay, each body line is masked.
354 category: 'secrets',
355 pattern:
356 /-----BEGIN ([A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?)-----([\s\S]*?)(-----END \1-----|$(?![\s\S]))/g,
357 replace: (masks, _match, groups) => {
358 const label = groups[0] ?? 'PRIVATE KEY'
359 const body = groups[1] ?? ''
360 const end = groups[2] ?? ''
361 let isFirst = true
362 const masked = body.replace(/[^\r\n]+/g, line => {
363 if (line.trim().length === 0) return line
364 const visible = isFirst ? mask('private key') : BLOCK
365 isFirst = false
366 return masks.put(visible)
367 })
368 return `-----BEGIN ${label}-----${masked}${end}`
369 },
370 },
371 {
372 category: 'secrets',
373 pattern: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]*/g,
374 replace: whole('jwt'),
375 },
376 {
377 category: 'secrets',
378 pattern: /(?<![\w-])sk-ant-[A-Za-z0-9_-]{16,}/g,
379 replace: whole('key'),
380 },
381 {
382 category: 'secrets',
383 pattern: /(?<![\w-])sk-(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{20,}/g,
384 replace: whole('key', hasDigitAndLetter),
385 },
386 {
387 category: 'secrets',
388 pattern:
389 /\b(?:[rsp]k_(?:live|test)_[A-Za-z0-9]{16,}|gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{22,}|glpat-[A-Za-z0-9_-]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}|(?:AKIA|ASIA)[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{35}|npm_[A-Za-z0-9]{36}|hf_[A-Za-z0-9]{30,})(?![\w-])/g,
390 replace: whole('key'),
391 },
392 {
393 // `Bearer <token>`, `Basic <base64>`: the scheme stays.
394 category: 'secrets',
395 pattern: /\b(Bearer|Basic)([ \t]+)([A-Za-z0-9._~+/-]+=*)/g,
396 replace: (masks, match, groups) => {
397 const token = groups[2] ?? ''
398 const isTokenLike = token.length >= 16 || (token.length >= 8 && /\d/.test(token))
399 if (!isTokenLike || token.includes(SENTINEL_OPEN)) return null
400 return `${groups[0] ?? ''}${groups[1] ?? ''}${masks.put(mask('token'))}`
401 },
402 },
403 {
404 // `scheme://user:password@host`: the password.
405 category: 'secrets',
406 pattern: /\b([a-z][a-z0-9+.-]*:\/\/)([^\s:@/?#]+):([^\s@/?#]+)@/gi,
407 replace: (masks, _match, groups) => {
408 const password = groups[2] ?? ''
409 if (PLACEHOLDER_VALUE.test(password) || isWholeSentinel(password)) return null
410 return `${groups[0] ?? ''}${groups[1] ?? ''}:${masks.put(mask('secret'))}@`
411 },
412 },
413 {
414 // `API_KEY=...`, `password: "..."`, `"client_secret": "..."`.
415 category: 'secrets',
416 pattern:
417 /(?<![\w.$])(["']?)([A-Za-z_][A-Za-z0-9_.-]*)\1([ \t]*)(=|:)([ \t]*)(?:"([^"\r\n]*)"|'([^'\r\n]*)'|([^\s'",;)}\]{[]+))/g,
418 replace: (masks, match, groups) => {
419 const [quote = '', name = '', before = '', sep = '', after = '', dq, sq, bare] = groups
420 if (!isSecretName(name)) return null
421 // `a:b` with no space is a URL scheme, a label or a namespace, not an assignment.
422 if (sep === ':' && after === '' && quote === '' && bare !== undefined) return null
423 const isEnvStyle = sep === '=' && /^[A-Z][A-Z0-9_]*$/.test(name)
424 const value = dq ?? sq ?? bare ?? ''
425 const isQuoted = dq !== undefined || sq !== undefined
426 if (!isSecretValue(value, isQuoted, isEnvStyle)) return null
427 const visible = masks.put(mask('secret'))
428 const head = `${quote}${name}${quote}${before}${sep}${after}`
429 if (dq !== undefined) return `${head}"${visible}"`
430 if (sq !== undefined) return `${head}'${visible}'`
431 return `${head}${visible}`
432 },
433 },
434
435 // --- contact: e-mail -----------------------------------------------------
436 {
437 category: 'contact',
438 pattern: /(?<![\w.%+-])([A-Za-z0-9._%+-]+)@((?:[A-Za-z0-9-]+\.)+([A-Za-z]{2,}))(?![\w-])/g,
439 replace: whole('email', (_match, groups) => {
440 const local = groups[0] ?? ''
441 const tld = (groups[2] ?? '').toLowerCase()
442 if (local === 'git') return false // git@github.com: an SSH login, not a person
443 return !IMAGE_OR_CODE_TLD.has(tld) // logo@2x.png
444 }),
445 },
446
447 // --- financial -----------------------------------------------------------
448 {
449 category: 'financial',
450 pattern: /(?<![\w.+-])(?:\d[ -]?){12,18}\d(?![\w-]|\.\d)/g,
451 replace: whole('card', match => {
452 const parts = match.split(/[ -]/)
453 if (parts.length > 1) {
454 // Grouped like a card (4-4-4-4, 4-6-5), not a list of small numbers.
455 const head = parts.slice(0, -1)
456 if (!head.every(part => part.length >= 4 && part.length <= 6)) return false
457 }
458 return isCardNumber(match.replace(/[ -]/g, ''))
459 }),
460 },
461 {
462 category: 'financial',
463 pattern: /\b[A-Z]{2}\d{2}(?:[ ]?[A-Z0-9]{4}){2,7}(?:[ ]?[A-Z0-9]{1,3})?\b/g,
464 replace: whole('iban', match => isIban(match)),
465 },
466 {
467 category: 'financial',
468 pattern: /(?<![\w-])(?!000|666|9\d\d)\d{3}-(?!00)\d{2}-(?!0000)\d{4}(?![\w-])/g,
469 replace: whole('ssn'),
470 },
471 {
472 // A number right after a banking word: account, routing, sort code, BSB.
473 category: 'financial',
474 pattern:
475 /\b(sort[ \t-]?code|routing(?:[ \t]+(?:number|no\.?|#))?|aba|(?:bank[ \t]+)?account(?:[ \t]+(?:number|no\.?|#))?|acct(?:[ \t]*(?:no\.?|#))?|bsb)([ \t]*[:#=]?[ \t]*)(\d[\d \t-]{4,22}\d)(?![\w-])/gi,
476 replace: (masks, _match, groups) => `${groups[0] ?? ''}${groups[1] ?? ''}${masks.put(mask('bank'))}`,
477 },
478
479 // --- contact: phone ------------------------------------------------------
480 {
481 category: 'contact',
482 pattern: /(?<![\w+])\+(?:\d[ \t.()-]{0,2}){7,14}\d(?!\w)/g,
483 replace: whole('phone', match => {
484 const digits = match.replace(/\D/g, '').length
485 return digits >= 8 && digits <= 15
486 }),
487 },
488 {
489 category: 'contact',
490 pattern: /(?<![\w.+-])\([2-9]\d{2}\)[ \t]?\d{3}[-. \t]\d{4}(?![\w-]|\.\d)/g,
491 replace: whole('phone'),
492 },
493 {
494 category: 'contact',
495 pattern: /(?<![\w.+-])(?:1-)?[2-9]\d{2}([-.])\d{3}\1\d{4}(?![\w-]|\.\d)/g,
496 replace: whole('phone'),
497 },
498
499 // --- network -------------------------------------------------------------
500 {
501 category: 'network',
502 pattern:
503 /(?<![\w.-])(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?![\w-]|\.\d)/g,
504 replace: whole('ip', match => !/^(?:127\.|0\.0\.0\.0$|255\.255\.)/.test(match)),
505 },
506 {
507 category: 'network',
508 pattern: /(?<![\w:.])(?:[0-9A-Fa-f]{0,4}:){2,7}[0-9A-Fa-f]{0,4}(?![\w:])/g,
509 replace: whole('ip', match => isIpv6(match) && !isBoringIpv6(match)),
510 },
511
512 // --- paths: the username in a home folder ---------------------------------
513 {
514 category: 'paths',
515 pattern:
516 /(?<=(?:^|[^\w.~-])(?:\/Users|\/home|[A-Za-z]:\\Users|[A-Za-z]:\\\\Users)(?:\/|\\\\|\\))([^/\\\s'"`:;,)\]}<>|*?]+)(?=[/\\\s'"`:;,)\]}<>]|$)/gm,
517 replace: (masks, match) => (/^(?:Shared|Public|Default|All Users)$/i.test(match) ? null : masks.put(BLOCK)),
518 },
519]
520
521// ---------------------------------------------------------------------------
522// Names
523
524function escapeRegExp(s: string): string {
525 return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
526}
527
528/** Clean a list of names: trimmed, deduplicated, two characters at least. */
529export function normalizeNames(names: readonly string[]): string[] {
530 const seen = new Set<string>()
531 const out: string[] = []
532 for (const raw of names) {
533 const name = raw.trim().replace(/\s+/g, ' ')
534 const key = name.toLowerCase()
535 if (name.length < 2 || seen.has(key)) continue
536 seen.add(key)
537 out.push(name)
538 }
539 return out.sort((a, b) => b.length - a.length)
540}
541
542/** Parse the `names` setting: comma or newline separated. */
543export function parseNameList(value: string | undefined): string[] {
544 if (value === undefined) return []
545 return normalizeNames(value.split(/[,\n;]/))
546}
547
548let namesCache: { key: string; pattern: RegExp | null } = { key: '', pattern: null }
549
550function namesPattern(names: readonly string[]): RegExp | null {
551 const clean = normalizeNames(names)
552 const key = clean.join('\u0000')
553 if (namesCache.key === key) return namesCache.pattern
554 const pattern =
555 clean.length === 0
556 ? null
557 : new RegExp(
558 `(?<![\\p{L}\\p{N}_])(?:${clean
559 .map(name => name.split(' ').map(escapeRegExp).join('[ \\t]+'))
560 .join('|')})(?![\\p{L}\\p{N}_])`,
561 'giu',
562 )
563 namesCache = { key, pattern }
564 return pattern
565}
566
567// ---------------------------------------------------------------------------
568// The engine
569
570function isOn(options: RedactOptions | undefined, category: Category): boolean {
571 return options?.categories?.[category] !== false
572}
573
574function applyRule(text: string, rule: Rule, masks: Masks): string {
575 rule.pattern.lastIndex = 0
576 return text.replace(rule.pattern, (...args: unknown[]) => {
577 const match = args[0] as string
578 // Arguments after the match: the groups, then offset, input (and groups object).
579 const tail = args.slice(1)
580 const hasNamed = typeof tail[tail.length - 1] === 'object'
581 const groupCount = tail.length - (hasNamed ? 3 : 2)
582 const groups = tail.slice(0, groupCount) as (string | undefined)[]
583 return rule.replace(masks, match, groups) ?? match
584 })
585}
586
587const cache = new Map<string, string>()
588const CACHE_LIMIT = 500
589const CACHE_MIN_LENGTH = 32
590
591function cacheKey(text: string, options: RedactOptions | undefined): string {
592 const flags = CATEGORIES.map(c => (isOn(options, c) ? '1' : '0')).join('')
593 return `${flags}\u0001${normalizeNames(options?.names ?? []).join('\u0000')}\u0001${text}`
594}
595
596/** Quick test: could this text hold anything a rule matches? */
597function mayHoldPii(text: string): boolean {
598 return /[\d@:=/\\]|eyJ|Bearer|Basic|-----BEGIN/.test(text)
599}
600
601/**
602 * Masks what looks like personal data or a secret in `text`. Lines are kept:
603 * the output has the same line breaks as the input.
604 */
605export function redactText(text: string, options?: RedactOptions): string {
606 if (text.length === 0) return text
607 const names = options?.names ?? []
608 const namesOn = isOn(options, 'names') && names.length > 0
609 if (!namesOn && !mayHoldPii(text)) return text
610
611 const useCache = text.length >= CACHE_MIN_LENGTH
612 const key = useCache ? cacheKey(text, options) : ''
613 if (useCache) {
614 const hit = cache.get(key)
615 if (hit !== undefined) return hit
616 }
617
618 const masks = new Masks()
619 let out = text
620 for (const rule of RULES) {
621 if (isOn(options, rule.category)) out = applyRule(out, rule, masks)
622 }
623 if (namesOn) {
624 const pattern = namesPattern(names)
625 if (pattern !== null) {
626 pattern.lastIndex = 0
627 out = out.replace(pattern, () => masks.put(BLOCK))
628 }
629 }
630 const result = masks.list.length === 0 ? text : masks.restore(out)
631
632 if (useCache) {
633 if (cache.size >= CACHE_LIMIT) {
634 const oldest = cache.keys().next()
635 if (oldest.done !== true) cache.delete(oldest.value)
636 }
637 cache.set(key, result)
638 }
639 return result
640}
641
642/** Keys whose string values are identifiers or enums, never drawn as text. */
643const KEEP_KEYS = new Set(['type', 'id', 'tool_use_id', 'toolUseId', 'agentId', 'kind', 'mimeType', 'media_type'])
644
645const MAX_DEPTH = 40
646
647/**
648 * Redacts every string inside a plain-data value (tool inputs and results),
649 * keeping its shape: same keys, same array lengths, numbers and booleans
650 * untouched. Returns the same object where nothing changed.
651 */
652export function redactDeep<T>(value: T, options?: RedactOptions, depth = 0): T {
653 if (typeof value === 'string') return redactText(value, options) as T
654 if (value === null || typeof value !== 'object' || depth > MAX_DEPTH) return value
655 if (Array.isArray(value)) {
656 let changed = false
657 const next = value.map(item => {
658 const red = redactDeep(item, options, depth + 1)
659 if (red !== item) changed = true
660 return red
661 })
662 return (changed ? next : value) as T
663 }
664 const proto = Object.getPrototypeOf(value) as unknown
665 if (proto !== Object.prototype && proto !== null) return value
666 let changed = false
667 const next: Record<string, unknown> = {}
668 for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
669 if (KEEP_KEYS.has(k)) {
670 next[k] = v
671 continue
672 }
673 const red = redactDeep(v, options, depth + 1)
674 if (red !== v) changed = true
675 next[k] = red
676 }
677 return (changed ? next : value) as T
678}
679
680/** Whether `redactText` would change `text`. */
681export function hasPii(text: string, options?: RedactOptions): boolean {
682 return redactText(text, options) !== text
683}
684types/index.d.ts 25 lines1// pii-shield: the values it keeps in $.state for the session.
2
3/** What `/redact` sets: `auto` polls for recorders, `on` and `off` force it. */
4export type PiiShieldMode = 'auto' | 'on' | 'off'
5
6export type PiiShieldState = {
7 /** Set by `/redact`; null means "use the configured mode". */
8 override: PiiShieldMode | null
9 /**
10 * The recorder or screen-sharing process seen this session (sticky), or
11 * null. Cleared only by `/redact off` or `/redact auto`.
12 */
13 recorder: string | null
14}
15
16declare module 'claude-code' {
17 interface PluginState {
18 'pii-shield': {
19 shield: PiiShieldState
20 /** Names found at session start: OS username, git user.name. */
21 identity: string[]
22 }
23 }
24}
25