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…

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.
Claude Code 2.1.287 or later (mods).
/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.
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...).hooks/register.tsx 202 lines1import 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}
202hooks/mask.ts 341 lines1const 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}
341types/index.d.ts 7 lines1declare module 'claude-code' {
2 // A missing or false value means masked (fail closed).
3 interface PluginState {
4 'display-redaction': { disabled?: boolean }
5 }
6}
7