SLOPSHOPPER

display-redaction

A mod that masks email addresses and secret-shaped text in what Claude Code draws, for recordings and screen shares. It changes the screen only; the…

newspinnerrowscommand
A shopper browsing a rack in a slop shop
README

display-redaction

A Claude Code mod that masks email addresses and secret-shaped text in what Claude Code draws, so a recording or screen share does not show them.

By default each match in Claude's replies, your prompts and command output draws as a solid dark bar, and hovering the pointer over it shows the value. This reproduces the hide-email mod in Anthropic's "Mods in Claude Code" video, colors included on a full-color terminal. Tool rows, question prompts and notices draw each match as [hidden]. The mask_style option switches the text surfaces to [hidden] too.

It masks the screen only. The model, the transcript stored on disk and anything you copy out keep the real text. This is not a privacy, DLP or data control, and its patterns can miss a secret. Check a recording before you share it.

Requirements

Claude Code 2.1.287 or later (mods).

Install

/plugin marketplace add melodic-software/claude-code-plugins
/plugin install display-redaction@melodic-software

The marketplace lists the plugin as off by default; enable it after installing.

What is masked

  • Email addresses, including ones with letters outside ASCII and one still streaming at the end of the text.
  • sk- keys, GitHub ghp_, gho_, ghu_, ghs_, ghr_ and github_pat_ tokens (also with markdown-escaped \_), xox* Slack tokens, AKIA AWS key IDs, and JWTs (eyJ...).
  • PEM private-key blocks and PGP private-key blocks (`REDACTED-PRIVATE-KEY-BY-SLOPSHOPPER
Source 3 files
hooks/register.tsx 202 lines
1import type { EngineInterface, Register, RenderElement, RenderPropsOf } from 'claude-code'
2import { blankProps, compilePatterns, distinct, maskProps, splitBlocks } from './mask.ts'
3
4const MASK = '[hidden]'
5// Block style, as the hide-email mod in Anthropic's "Mods in Claude Code" video draws it: a bar the
6// color of its own text, readable while the pointer is over its row.
7const VIDEO = {
8  masked: { backgroundColor: '#3B4252', color: '#3B4252' },
9  shown: { backgroundColor: '#4C566A', color: '#ECEFF4', bold: true },
10} as const
11// A terminal without full color approximates hex to its palette, so it gets theme keys, which
12// follow the person's light or dark theme: a mid-gray bar, and the theme's own text inverted.
13const THEME = {
14  masked: { backgroundColor: 'inactive', color: 'inactive' },
15  shown: { backgroundColor: 'text', color: 'inverseText', bold: true },
16} as const
17type Palette = typeof VIDEO | typeof THEME
18const FULL_COLOR = new Set(['truecolor', '24bit'])
19// A failed read falls back to theme colors, never to unmasked text.
20// Read once per render, and only in block mode; undefined in text mode.
21const palette = async ($: EngineInterface, isBlock: boolean): Promise<Palette | undefined> => {
22  if (!isBlock) return undefined
23  try {
24    return FULL_COLOR.has(((await $.env.get('COLORTERM')) ?? '').toLowerCase()) ? VIDEO : THEME
25  } catch {
26    return THEME
27  }
28}
29// A drawn Text or Markdown refuses these, and a refused tree draws the original text.
30const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/
31// The engine refuses a tree over 20,000 nodes and draws the original text, so a larger drawing goes
32// to the text rewrite instead. Each block costs at most two nodes, each match row five.
33const MAX_NODES = 10_000
34const DISABLED = { plugin: 'display-redaction', key: 'disabled' } as const
35const COMMAND = {
36  name: 'display-redaction',
37  description: 'Mask emails and secrets on screen (on by default); off shows them',
38  argumentHint: 'on|off',
39  immediate: true,
40} as const
41
42// The text-bearing props of each masked component; every other prop is passed on as received.
43const KEYS = {
44  AssistantMessage: ['text'],
45  UserMessage: ['text'],
46  CommandOutput: ['text'],
47  ToolUse: ['input', 'output'],
48  ToolResult: ['output'],
49  AskUserQuestion: ['questions'],
50  InfoNotice: ['text'],
51} as const satisfies { [C in keyof RenderPropsOf]?: readonly (keyof RenderPropsOf[C])[] }
52
53const extraPatterns = (value: unknown): string[] =>
54  (Array.isArray(value) ? value : typeof value === 'string' ? value.split('\n') : []).filter((s) => s !== '')
55
56type Question = { question: string; options?: { label: string }[] }
57
58const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null
59
60const isQuestion = (q: unknown): q is Question =>
61  isRecord(q) &&
62  typeof q.question === 'string' &&
63  (q.options === undefined ||
64    (Array.isArray(q.options) && q.options.every((o) => isRecord(o) && typeof o.label === 'string')))
65
66// The AskUserQuestion schema refuses equal question texts and equal option labels within one
67// question, and a refused rewrite draws the original; so masked values are kept distinct.
68const distinctQuestions = (questions: unknown): unknown => {
69  if (!Array.isArray(questions) || !questions.every(isQuestion)) return questions
70  const texts = distinct(questions.map((q) => q.question), MASK)
71  return questions.map((q, i) => {
72    if (!q.options) return { ...q, question: texts[i] }
73    const labels = distinct(q.options.map((o) => o.label), MASK)
74    return { ...q, question: texts[i], options: q.options.map((o, j) => ({ ...o, label: labels[j] })) }
75  })
76}
77
78// An AskUserQuestion record (the call's input, or its result) with its questions kept distinct.
79const distinctAsked = (tool: string, record: unknown): unknown =>
80  tool === 'AskUserQuestion' && isRecord(record) && 'questions' in record
81    ? { ...record, questions: distinctQuestions(record.questions) }
82    : record
83
84type Elements = Pick<ReturnType<EngineInterface['ui']['resolve']>, 'Box' | 'Markdown' | 'Text'>
85
86// The mod's own tree for a text with a match: blocks without one drawn as Markdown, a block with one
87// as wrapping Text with each match a bar. Undefined where the text rewrite should draw instead.
88function drawMasked(
89  { Box, Markdown, Text }: Elements,
90  text: string,
91  patterns: readonly RegExp[],
92  { masked, shown }: Palette,
93): RenderElement | undefined {
94  if (CONTROL.test(text)) return undefined
95  const split = splitBlocks(text, patterns)
96  if (split.every((s) => s.hidden.length === 0)) return undefined
97  if (split.reduce((n, s) => n + 2 + 5 * s.parts.length, 1) > MAX_NODES) return undefined
98  const drawBlock = ({ block, parts, hidden }: (typeof split)[number], b: number) => {
99    if (hidden.length === 0) return <Markdown text={block} />
100    const rows = parts.flatMap((part, i) => {
101      const value = hidden[i]
102      if (part === '' && value === undefined) return []
103      const spans = [
104        ...(part === '' ? [] : [<Text>{part}</Text>]),
105        ...(value === undefined ? [] : [<Text {...masked} hover={shown}>{' ' + value + ' '}</Text>]),
106      ]
107      return [<Box key={`b${b}m${i}`}>{spans}</Box>]
108    })
109    return <Box flexWrap="wrap">{rows}</Box>
110  }
111  return <Box flexDirection="column" gap={1}>{split.map(drawBlock)}</Box>
112}
113
114const isDisabled = async ($: EngineInterface): Promise<boolean> => (await $.state.get(DISABLED)).value === true
115
116export const register: Register = (on, options) => {
117  const { patterns, invalid } = compilePatterns(extraPatterns(options.extra_patterns))
118  // `mask` runs the patterns; `blank`, for a .catch, replaces every string whole and runs none, so
119  // it finishes within the handler's grace whatever the patterns cost.
120  type Rewrite = (props: Record<string, unknown>, keys: readonly string[]) => Record<string, unknown>
121  const mask: Rewrite = (props, keys) => maskProps(props, keys, patterns, MASK)
122  const blank: Rewrite = (props, keys) => blankProps(props, keys, patterns, MASK)
123  const hide = <E extends { props: object }>(e: E, keys: readonly string[], rewrite = mask): E => ({
124    ...e,
125    props: rewrite(e.props as Record<string, unknown>, keys) as E['props'],
126  })
127  const hideQuestions = <E extends { props: RenderPropsOf['AskUserQuestion'] }>(e: E, rewrite = mask): E => {
128    const m = hide(e, KEYS.AskUserQuestion, rewrite)
129    return { ...m, props: { ...m.props, questions: distinctQuestions(m.props.questions) } }
130  }
131  const hideToolUse = <E extends { props: RenderPropsOf['ToolUse'] }>(e: E, rewrite = mask): E => {
132    const m = hide(e, KEYS.ToolUse, rewrite)
133    const { tool, input, output } = m.props
134    if (tool !== 'AskUserQuestion') return m
135    const props = { ...m.props, input: distinctAsked(tool, input) }
136    return { ...m, props: 'output' in m.props ? { ...props, output: distinctAsked(tool, output) } : props }
137  }
138  const hideToolResult = <E extends { props: RenderPropsOf['ToolResult'] }>(e: E, rewrite = mask): E => {
139    const m = hide(e, KEYS.ToolResult, rewrite)
140    return { ...m, props: { ...m.props, output: distinctAsked(m.props.tool, m.props.output) } }
141  }
142
143  const isBlock = options.mask_style !== 'text'
144  const draw = (elements: Elements, text: string, colors: Palette | undefined) =>
145    colors && drawMasked(elements, text, patterns, colors)
146
147  // Each .catch (a hook that threw or overran) draws its text-bearing props as the mask whole,
148  // running no pattern: a fault never draws the original props.
149  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
150    if (await isDisabled($)) return next(e)
151    return draw($.ui.resolve(e), e.props.text, await palette($, isBlock)) ?? next(hide(e, KEYS.AssistantMessage))
152  }).catch(($, e, next) => next(hide(e, KEYS.AssistantMessage, blank)))
153  // A task notification or another party's message the engine draws as a compact row; only the
154  // person's own prompt is drawn whole.
155  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
156    if (await isDisabled($)) return next(e)
157    const isOwn = e.props.task === undefined && e.props.from === undefined
158    return (isOwn ? draw($.ui.resolve(e), e.props.text, await palette($, isBlock)) : undefined) ??
159      next(hide(e, KEYS.UserMessage))
160  }).catch(($, e, next) => next(hide(e, KEYS.UserMessage, blank)))
161  on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
162    if (await isDisabled($)) return next(e)
163    return draw($.ui.resolve(e), e.props.text, await palette($, isBlock)) ?? next(hide(e, KEYS.CommandOutput))
164  }).catch(($, e, next) => next(hide(e, KEYS.CommandOutput, blank)))
165  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => next((await isDisabled($)) ? e : hideToolUse(e)))
166    .catch(($, e, next) => next(hideToolUse(e, blank)))
167  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => next((await isDisabled($)) ? e : hideToolResult(e)))
168    .catch(($, e, next) => next(hideToolResult(e, blank)))
169  on('ui.render', { component: 'AskUserQuestion' }, async ($, e, next) => next((await isDisabled($)) ? e : hideQuestions(e)))
170    .catch(($, e, next) => next(hideQuestions(e, blank)))
171  on('ui.render', { component: 'InfoNotice' }, async ($, e, next) => next((await isDisabled($)) ? e : hide(e, KEYS.InfoNotice)))
172    .catch(($, e, next) => next(hide(e, KEYS.InfoNotice, blank)))
173
174  on('session.start', async ($, e, next) => {
175    await $.command.register(COMMAND)
176    if (invalid.length > 0) {
177      const noun = invalid.length === 1 ? 'entry' : 'entries'
178      await $.ui.log(`display-redaction: skipped ${invalid.length} invalid extra_patterns ${noun}`)
179    }
180    return next(e)
181  }).catch(($, e, next) => next(e))
182
183  // `/clear` starts a new session with no session.start, so the command is declared again.
184  on('session.end', async ($, e, next) => {
185    const result = await next(e)
186    if (e.reason === 'clear') await $.command.register(COMMAND)
187    return result
188  }).catch(($, e, next) => next(e))
189
190  // The answer carries no text: a command's output row is stored where the model reads it.
191  on('command.run', { command: 'display-redaction' }, async ($, e) => {
192    const arg = e.args.trim().toLowerCase()
193    if (arg === 'off' || arg === 'on') {
194      await $.state.set(DISABLED, arg === 'off')
195      await $.ui.log(`display-redaction: masking is ${arg} for this session`)
196    } else {
197      await $.ui.log(`display-redaction: masking is ${(await isDisabled($)) ? 'off' : 'on'}`)
198    }
199    return {}
200  }).catch(() => ({}))
201}
202
hooks/mask.ts 341 lines
1const MAX_PATTERN_LENGTH = 200
2const MAX_DEPTH = 64
3// A user pattern runs over windows of this many characters, each overlapping the next by half, so
4// its cost grows with the text's length, not its square; a match up to half a window is whole in one.
5const WINDOW = 1024
6// The guard's bound on the lengths a pattern tries at each start: one unbounded quantifier (a
7// window) times a small factor.
8const MAX_COST = 4 * WINDOW
9const QUANTIFIER_BRACE = /^\{(\d+)(?:(,)(\d*))?\}/
10
11// Each built-in is anchored on a run start (a lookbehind) so a long run of word characters is
12// scanned once, not once per start position. Open-ended tails (`*`) mask a token still streaming.
13const BUILTIN_SOURCES = [
14  // An address with its TLD anywhere, or one still streaming at the end of the text.
15  '(?<![\\p{L}\\p{M}\\p{N}._%+-])[\\p{L}\\p{M}\\p{N}._%+-]+@(?:[\\p{L}\\p{M}\\p{N}-]+(?:\\.[\\p{L}\\p{M}\\p{N}-]+)*\\.\\p{L}[\\p{L}\\p{M}]+|[\\p{L}\\p{M}\\p{N}-]*(?:\\.[\\p{L}\\p{M}\\p{N}-]*)*(?![\\s\\S]))',
16  '(?<![A-Za-z0-9])sk-[\\w-]*',
17  // Markdown source can escape an underscore that the screen draws plain.
18  '(?<![A-Za-z0-9_])gh[pousr]\\\\?_(?:\\w|\\\\_)*',
19  '(?<![A-Za-z0-9_])github\\\\?_pat\\\\?_(?:\\w|\\\\_)*',
20  '(?<![A-Za-z0-9])xox[abprs]-[\\w-]*',
21  '(?<![A-Za-z0-9])AKIA[0-9A-Z]*',
22  '(?<![A-Za-z0-9_])eyJ[\\w-]*(?:\\.[\\w-]*){0,2}',
23  '-----BEGIN [A-Z ]*PRIVATE KEY(?: BLOCK)?-----[\\s\\S]*?(?:-----END [A-Z ]*PRIVATE KEY(?: BLOCK)?-----|(?![\\s\\S]))',
24]
25
26// The user patterns, which run per window.
27const EXTRA = new WeakSet<RegExp>()
28
29// A CSI sequence (colors, erase-line) or a two-character escape; OSC text such as a hyperlink
30// target is matched like any other text.
31const ANSI = /\u001b(?:\[[0-?]*[ -/]*[@-~]|[@-Z\\^_])/g
32
33const NOREPLY = /^noreply@/i
34const GITHUB_NOREPLY = /^\d+\+[^@]+@users\.noreply\.github\.com$/i
35
36const isAllowed = (match: string, offset: number, text: string): boolean =>
37  NOREPLY.test(match) || GITHUB_NOREPLY.test(match) || (/^git@/i.test(match) && text[offset + match.length] === ':')
38
39// How many lengths a pattern can try at one start: the product of its quantifiers' ranges, an
40// unbounded one counted as a window. Infinity for a shape that backtracks without bound: a
41// quantified group holding a quantifier or an alternation, or a backreference.
42const backtrackCost = (source: string): number => {
43  const groups: { quantified: boolean; alternates: boolean }[] = [{ quantified: false, alternates: false }]
44  let cost = 1
45  let inClass = false
46  // The range of the quantifier at `i`, its length, and whether it repeats without a small bound.
47  const quantifierAt = (i: number): { range: number; length: number; repeats: boolean } | undefined => {
48    const c = source[i]
49    let q: { range: number; length: number; repeats: boolean } | undefined
50    if (c === '*' || c === '+') q = { range: WINDOW, length: 1, repeats: true }
51    else if (c === '?') q = { range: 2, length: 1, repeats: false }
52    else if (c === '{') {
53      const m = QUANTIFIER_BRACE.exec(source.slice(i))
54      if (!m) return undefined
55      const min = Number(m[1])
56      const max = m[2] === undefined ? min : m[3] === '' ? Infinity : Number(m[3])
57      q = { range: Math.min(max - min + 1, WINDOW), length: m[0].length, repeats: true }
58    }
59    // A lazy marker belongs to the quantifier.
60    if (q && source[i + q.length] === '?') q.length++
61    return q
62  }
63  for (let i = 0; i < source.length; i++) {
64    const c = source[i]
65    const top = groups[groups.length - 1] as { quantified: boolean; alternates: boolean }
66    if (c === '\\') {
67      if (!inClass && /[1-9k]/.test(source[i + 1] ?? '')) return Infinity
68      i++
69    } else if (inClass) {
70      if (c === ']') inClass = false
71    } else if (c === '[') {
72      inClass = true
73    } else if (c === '(') {
74      groups.push({ quantified: false, alternates: false })
75      if (source[i + 1] === '?') i++
76    } else if (c === ')') {
77      const inner = groups.pop() ?? { quantified: false, alternates: false }
78      const q = quantifierAt(i + 1)
79      if (q?.repeats && (inner.quantified || inner.alternates)) return Infinity
80      const parent = groups[groups.length - 1] ?? top
81      parent.quantified ||= inner.quantified || q !== undefined
82      parent.alternates ||= inner.alternates
83      if (q) {
84        cost *= q.range
85        i += q.length
86      }
87    } else if (c === '|') {
88      top.alternates = true
89    } else {
90      const q = quantifierAt(i)
91      if (q) {
92        top.quantified = true
93        cost *= q.range
94        i += q.length - 1
95      }
96    }
97  }
98  return cost
99}
100
101const compileExtra = (source: string): RegExp | undefined => {
102  if (typeof source !== 'string' || source.length === 0 || source.length > MAX_PATTERN_LENGTH) return undefined
103  if (backtrackCost(source) > MAX_COST) return undefined
104  try {
105    if (new RegExp(source).test('')) return undefined
106    const re = new RegExp(source, 'g')
107    EXTRA.add(re)
108    return re
109  } catch {
110    return undefined
111  }
112}
113
114export function compilePatterns(extra: readonly string[]): { patterns: RegExp[]; invalid: string[] } {
115  const patterns = BUILTIN_SOURCES.map((s) => new RegExp(s, 'gu'))
116  const invalid: string[] = []
117  for (const source of extra) {
118    const re = compileExtra(source)
119    if (re) patterns.push(re)
120    else invalid.push(source)
121  }
122  return { patterns, invalid }
123}
124
125type Range = [number, number]
126
127// The spans every pattern matches, overlapping or touching ones joined, sorted. Patterns run over
128// the text with each escape sequence replaced by as many NUL characters, so a token right after a
129// color code starts a match and no escape becomes part of one; and, when it has escapes, over the
130// text without them, so an escape inside a token does not cut its match short.
131function findRanges(text: string, patterns: readonly RegExp[]): Range[] {
132  const ranges: Range[] = []
133  // `origin(i)` is the offset in `text` of the view's character `i`.
134  const scan = (view: string, origin: (i: number) => number) => {
135    const collect = (pattern: RegExp, from: number, to: number) => {
136      for (const m of view.slice(from, to).matchAll(pattern)) {
137        const at = from + m.index
138        if (m[0] !== '' && !isAllowed(m[0], at, view)) {
139          ranges.push([origin(at), origin(at + m[0].length - 1) + 1])
140        }
141      }
142    }
143    for (const pattern of patterns) {
144      if (!EXTRA.has(pattern)) {
145        collect(pattern, 0, view.length)
146        continue
147      }
148      for (let from = 0; ; from += WINDOW / 2) {
149        collect(pattern, from, from + WINDOW)
150        if (from + WINDOW >= view.length) break
151      }
152    }
153  }
154  scan(text.replace(ANSI, (s) => '\u0000'.repeat(s.length)), (i) => i)
155  const kept: number[] = []
156  let i = 0
157  for (const m of text.matchAll(ANSI)) {
158    for (; i < m.index; i++) kept.push(i)
159    i = m.index + m[0].length
160  }
161  for (; i < text.length; i++) kept.push(i)
162  if (kept.length < text.length) scan(kept.map((k) => text[k]).join(''), (k) => kept[k] as number)
163  ranges.sort((a, b) => a[0] - b[0])
164  const merged: Range[] = []
165  for (const [start, end] of ranges) {
166    const last = merged[merged.length - 1]
167    if (last && start <= last[1]) last[1] = Math.max(last[1], end)
168    else merged.push([start, end])
169  }
170  return merged
171}
172
173export function maskText(text: string, patterns: readonly RegExp[], mask: string): string {
174  const ranges = findRanges(text, patterns)
175  if (ranges.length === 0) return text
176  // Escape sequences inside a match are kept, so the colors after it stay as the text set them.
177  const escapes = [...text.matchAll(ANSI)].map((m): Range => [m.index, m.index + m[0].length])
178  let out = ''
179  let at = 0
180  let j = 0
181  for (const [start, end] of ranges) {
182    out += text.slice(at, start)
183    let masked = false
184    let i = start
185    while (j < escapes.length && (escapes[j] as Range)[1] <= start) j++
186    for (let k = j; k < escapes.length && (escapes[k] as Range)[0] < end; k++) {
187      const s = Math.max((escapes[k] as Range)[0], start)
188      const e = Math.min((escapes[k] as Range)[1], end)
189      if (s > i && !masked) {
190        out += mask
191        masked = true
192      }
193      out += text.slice(s, e)
194      i = e
195    }
196    if (i < end && !masked) out += mask
197    at = end
198  }
199  return out + text.slice(at)
200}
201
202// The text around each range and the ranges, as `text.split(re)` and `text.match(re)` give them.
203const cut = (text: string, ranges: readonly Range[]): { parts: string[]; hidden: string[] } => {
204  const parts: string[] = []
205  let at = 0
206  for (const [start, end] of ranges) {
207    parts.push(text.slice(at, start))
208    at = end
209  }
210  parts.push(text.slice(at))
211  return { parts, hidden: ranges.map(([start, end]) => text.slice(start, end)) }
212}
213
214// The text around each match and the matches: `parts` has one more entry than `hidden`.
215// Overlapping or touching matches join into one.
216export function splitMatches(text: string, patterns: readonly RegExp[]): { parts: string[]; hidden: string[] } {
217  return cut(text, findRanges(text, patterns))
218}
219
220const FENCE = /^ {0,3}(```|~~~)/
221const BLANK = /^[ \t]*$/
222
223// Markdown blocks as [start, end) offsets: runs of lines split at blank lines, a fenced block kept
224// whole (to the end of the text when it is not closed).
225function blockSpans(text: string): Range[] {
226  const out: Range[] = []
227  let start = -1
228  let end = 0
229  let at = 0
230  let fence: string | undefined
231  for (const line of text.split('\n')) {
232    if (fence === undefined && BLANK.test(line)) {
233      if (start >= 0) out.push([start, end])
234      start = -1
235    } else {
236      const marker = FENCE.exec(line)?.[1]
237      if (marker !== undefined) fence = fence === undefined ? marker : fence === marker ? undefined : fence
238      if (start < 0) start = at
239      end = at + line.length
240    }
241    at += line.length + 1
242  }
243  if (start >= 0) out.push([start, end])
244  return out
245}
246
247export function blocks(text: string): string[] {
248  return blockSpans(text).map(([start, end]) => text.slice(start, end))
249}
250
251// Each block with the text around its matches and the matches. Matching runs over the whole text,
252// so a match that crosses a blank line is hidden in every block it reaches.
253export function splitBlocks(
254  text: string,
255  patterns: readonly RegExp[],
256): { block: string; parts: string[]; hidden: string[] }[] {
257  const ranges = findRanges(text, patterns)
258  return blockSpans(text).map(([start, end]) => {
259    const inside = ranges
260      .filter(([s, e]) => s < end && e > start)
261      .map(([s, e]): Range => [Math.max(s, start) - start, Math.min(e, end) - start])
262    const block = text.slice(start, end)
263    return { block, ...cut(block, inside) }
264  })
265}
266
267const isPlain = (value: object): boolean => {
268  const proto = Object.getPrototypeOf(value)
269  return proto === null || proto === Object.prototype
270}
271
272// Every string in `value` passed through `str` and every object key through `key`; arrays and plain
273// objects are walked (their keys kept distinct), any other object is replaced whole by the mask, so
274// nothing in it draws.
275const walk = (
276  value: unknown,
277  str: (s: string) => string,
278  key: (k: string) => string,
279  mask: string,
280  depth: number,
281): unknown => {
282  if (typeof value === 'string') return str(value)
283  if (value === null || typeof value !== 'object') return value
284  if (depth >= MAX_DEPTH || !(Array.isArray(value) || isPlain(value))) return mask
285  if (Array.isArray(value)) return value.map((v) => walk(v, str, key, mask, depth + 1))
286  const entries = Object.entries(value)
287  const keys = distinct(entries.map(([k]) => key(k)), mask)
288  return Object.fromEntries(entries.map(([, v], i) => [keys[i], walk(v, str, key, mask, depth + 1)]))
289}
290
291// Keeps values distinct where a schema requires it: when masking made two equal, each value's masks
292// are numbered by its position, and if that is not enough the position is appended to every value.
293export function distinct(values: readonly string[], mask: string): string[] {
294  const isDistinct = (list: readonly string[]) => new Set(list).size === list.length
295  if (isDistinct(values)) return [...values]
296  const token = (n: number) => (mask.endsWith(']') ? `${mask.slice(0, -1)} ${n}]` : `${mask} ${n}`)
297  const numbered = values.map((v, i) => v.split(mask).join(token(i + 1)))
298  return isDistinct(numbered) ? numbered : numbered.map((v, i) => `${v} (${i + 1})`)
299}
300
301const mapProps = <P extends object>(
302  props: P,
303  keys: readonly (keyof P)[],
304  str: (s: string) => string,
305  mask: string,
306  key: (k: string) => string = str,
307): P => {
308  const out = { ...props }
309  for (const k of keys) {
310    if (Object.prototype.hasOwnProperty.call(props, k)) {
311      out[k] = walk(props[k], str, key, mask, 0) as P[keyof P]
312    }
313  }
314  return out
315}
316
317export function maskProps<P extends object>(
318  props: P,
319  keys: readonly (keyof P)[],
320  patterns: readonly RegExp[],
321  mask: string,
322): P {
323  return mapProps(props, keys, (s) => maskText(s, patterns, mask), mask)
324}
325
326const FIELD_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/
327
328// The fail-closed rendering: every string under the named keys replaced whole by the mask, no user
329// pattern run, so it finishes in time whatever they cost. An object key stays when it reads as a
330// field name the built-ins find nothing in (a schema needs it); any other key is masked.
331export function blankProps<P extends object>(
332  props: P,
333  keys: readonly (keyof P)[],
334  patterns: readonly RegExp[],
335  mask: string,
336): P {
337  const builtins = patterns.filter((p) => !EXTRA.has(p))
338  return mapProps(props, keys, () => mask, mask, (k) =>
339    FIELD_NAME.test(k) && findRanges(k, builtins).length === 0 ? k : mask)
340}
341
types/index.d.ts 7 lines
1declare module 'claude-code' {
2  // A missing or false value means masked (fail closed).
3  interface PluginState {
4    'display-redaction': { disabled?: boolean }
5  }
6}
7