SLOPSHOPPER

pii-shield

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

newpanebandspinnerrowscommand
v0.2.0MITupdated 2026-10-06mrjk05/modemon/mods/pii-shield
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pii-shield
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ 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 ╭─────────────────────────────────────────────────────────────╮ │ ○ pii-shield │ │ mode auto (from settings) │ │ recorder detection not available on this platform; use │ │ /redact on │ │ masking contact, network, financial, secrets, names, paths │ │ Display only: Claude and the transcript see real │ │ values. │ ╰─────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ pii-shield: ○ pii-shield

Draws

Command output
╭─────────────────────────────────────────────────────────────╮ │ ○ pii-shield │ │ mode auto (from settings) │ │ recorder detection not available on this platform; use │ │ /redact on │ │ masking contact, network, financial, secrets, names, paths │ │ Display only: Claude and the transcript see real │ │ values. │ ╰─────────────────────────────────────────────────────────────╯
README

pii-shield

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.

Install

/plugin install pii-shield --marketplace mrjk05/modemon

Answer y to add the marketplace, then pick a scope.

How it turns on

ModeWhat 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.
onAlways redacting.
offNever redacting, and no polling.

Detected recorders:

  • macOS: QuickTime Player, the screenshot/recording toolbar (screencaptureui, ⇧⌘5), screencapture, OBS, Loom, CleanShot X, Kap, ScreenFlow, Screen Studio, Camtasia, Rotato, and Zoom while it shares your screen (CptHost).
  • Linux: OBS, wf-recorder, SimpleScreenRecorder, Kooha, Peek, vokoscreenNG, Kazam, GPU Screen Recorder, recordMyDesktop, Green/Blue Recorder, Byzanz, wl-screenrec, and Zoom's 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).

Commands

CommandEffect
/redact onStart masking now. Run this before you start recording if you need to be sure.
/redact offStop masking and clear the "recorder seen" flag.
/redact autoGo 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.

What gets masked

CategoryMasked
ContactE-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.
NetworkIPv4 and IPv6 addresses. Loopback (127.*, ::1), 0.0.0.0 and 255.255.* netmasks are left alone.
FinancialCard 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).
Secretssk-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=….
NamesThe 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.
PathsThe 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>.

Where it applies

  • Transcript rows: assistant replies, your prompts (and other user-role rows), the output of every slash command (built-in or another plugin's), tool-call rows (their input, such as a Bash command or a file path, and their inline output), tool results (Bash output, file contents, MCP results), collapsed tool groups, and the option descriptions and previews in the AskUserQuestion dialog.
  • The working line (spinner), which on the desktop names the current step (Creating notes.md).
  • Other plugins' panes and the band above the prompt: the text in the tree they draw (Text, Markdown, Code, Button labels).
  • Other plugins' toasts, status entries, tool-call notices, transcript log lines and pane titles (anything sent through $.ui.toast, $.ui.status, $.ui.notice, $.ui.log and $.ui.open).

Surfaces: terminal, desktop and mobile

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.

TerminalDesktopMobile
Transcript rows, command output, AskUserQuestionmaskedmaskedmasked
Other plugins' panesmaskedmaskedmasked
Band above the prompt, spinnermaskedmaskednot shown on mobile
Toasts, status entriesmaskedmaskedmasked
Status entry ● REDACTING / ○ pii-shieldyesyesyes
/redact status cardyesyesyes (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 status card 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.

Config

Open /config (or the plugin's config screen at install time):

SettingDefaultMeaning
modeautoauto, on or off (see above).
namesemptyComma-separated extra names or words to mask: people, clients, project code names.
maskContactonE-mails and phone numbers.
maskNetworkonIP addresses.
maskFinancialonCards, IBANs, SSNs, bank numbers.
maskSecretsonKeys, tokens, private keys, secret assignments.
maskNamesonThe names above plus your OS username and git name.
maskPathsonYour username in home-folder paths.

Failure policy: fail closed

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.

Limitations

  • Detection is heuristic. macOS has no public "is the screen being captured" API, and Linux compositors don't expose one either. pii-shield looks for the processes of known recorders. A recorder it doesn't know about, a browser tab sharing your screen (Google Meet, Teams on the web, Discord in a browser), Microsoft Teams' desktop share, or capture done by another machine (a capture card, a phone camera) will not be detected. Run /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.
  • Display only. The model, the transcript file (~/.claude/projects/…), exports, --resume and anything that reads the session see the real values. Only the screen is masked.
  • What a plugin cannot reach, on any surface: the prompt you are typing, permission dialogs (the engine draws them alone), the engine's own toasts and notices, and the terminal's own scrollback from before redaction turned on. On the phone and the desktop, also:
  • the session title in the session list (generated from your first prompt by the engine; no plugin hook rewrites it);
  • push notifications the model sends with the 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;
  • the phone's own app chrome: the share sheet, the keyboard's suggestions, and text you copied.
  • Other plugins are reached only beneath pii-shield. Panes and the band are masked when the plugin that draws them sits beneath pii-shield in the hook chain (the order plugins load in). A plugin above it, or one that draws with a Client module, an Svg's markup, or the values of Input/Select fields, is not masked.
  • Pattern-based. Detection uses regular expressions and checksums, not a language model. Expect misses: a phone number written without separators, a secret in a variable with an unremarkable name, a name that isn't in your list. Expect occasional false positives too: a hex word pair like dead::beef read as IPv6, or a long random literal assigned to a *_token field.
  • Names are matched as whole words. That is plain regex matching, so it doesn't catch inflections, nicknames, or a name glued into an identifier (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.
  • Engine fallbacks. If the engine itself refuses a rewritten row, or one throws while it is drawn, the engine draws its own original row. pii-shield keeps every row's shape intact to avoid this, but cannot prevent it.

Development

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.

License

MIT

Source 4 files
hooks/register.tsx 563 lines
1// 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}
563
hooks/detect.ts 147 lines
1// 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}
147
hooks/redact.ts 684 lines
1// 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}
684
types/index.d.ts 25 lines
1// 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