Recording mode: blurs emails, keys, IP addresses and user names on screen; hover over a blurred spot to reveal it. /rec turns it on and off.

Tryb nagrywania. Gdy jest włączony, na ekranie zamazuje maile, klucze API, tokeny, hasła zapisane jako password=..., adresy IP i nazwy użytkownika. Najedź kursorem na zamazane miejsce, żeby je podejrzeć. Zmienia się tylko to, co widać: Claude dalej czyta wszystko normalnie, a zapis rozmowy zostaje bez zmian.
/rec włącza i wyłącza tryb (albo /rec on, /rec off).● REC, dopóki tryb jest włączony. Stan zostaje zapamiętany do następnej sesji./config, w wierszu Extra names to blur, po przecinku.•••.USER/HOME (macOS, Linux) i USERNAME/USERPROFILE (Windows).Wymaga Claude Code 2.1.287+ w terminalu albo 2.1.286+ w aplikacji Desktop. Brak zależności.
hooks/register.tsx 125 lines1// rec-mode: a recording mode for Claude Code. While it is on, emails, keys, IP
2// addresses and user names are blurred on screen, and a pointer over a blurred
3// spot reveals it. Only the drawing changes: Claude reads everything as before
4// and the stored transcript is untouched. /rec turns it on and off.
5//
6// Replies, prompts and shell output are drawn by the mod, with the reveal on
7// hover. Rows the app draws itself (a tool call's input, a file Read, the
8// spinner, a command's output) get the same spots masked with dots instead.
9import type { EngineInterface, Register } from 'claude-code'
10
11import { findSpans, maskDeep, maskText } from './detect'
12import type { Context } from './detect'
13import { renderOutput, renderPrompt, renderReply } from './view'
14
15const COMMAND = 'rec'
16const STATUS = '● REC · private data blurred · hover to reveal'
17const STORE_KEY = 'isOn'
18
19// The module's state: a hot reload starts it over, and session.start reads it back.
20let isOn = false
21let ctx: Context = { users: [], names: [] }
22
23async function turn($: EngineInterface, on: boolean) {
24 isOn = on
25 await $.store.set(STORE_KEY, on).catch(() => {})
26 $.ui.status(on ? STATUS : undefined)
27 $.ui.invalidate('ui.render')
28}
29
30function hasSecret(text: string) {
31 return findSpans(text, ctx).length > 0
32}
33
34export const register: Register = (on, options) => {
35 const names = String(options.names ?? '')
36 .split(',')
37 .map(n => n.trim())
38 .filter(n => n.length > 0)
39 ctx = { users: [], names }
40
41 on('session.start', async ($, e, next) => {
42 const result = await next(e)
43 const user = await $.env.get('USER').catch(() => undefined)
44 const winUser = await $.env.get('USERNAME').catch(() => undefined)
45 const home = await $.env.get('HOME').catch(() => undefined)
46 const winHome = await $.env.get('USERPROFILE').catch(() => undefined)
47 const users = [user, winUser, ...[home, winHome].map(h => h?.split(/[\\/]/).filter(Boolean).pop())].filter(
48 (u): u is string => typeof u === 'string',
49 )
50 ctx = { users, names }
51 await $.command
52 .register({
53 name: COMMAND,
54 description: 'Recording mode: blur emails, keys, IP addresses and user names on screen',
55 argumentHint: '[on|off]',
56 })
57 .catch(() => {})
58 isOn = (await $.store.get(STORE_KEY).catch(() => false)) === true
59 $.ui.status(isOn ? STATUS : undefined)
60 $.ui.invalidate('ui.render')
61 return result
62 })
63
64 on('command.run', { command: COMMAND }, async ($, e) => {
65 const arg = e.args.trim().toLowerCase()
66 const want = arg === 'on' ? true : arg === 'off' ? false : !isOn
67 await turn($, want)
68 return {
69 text: want
70 ? 'Recording mode on: emails, keys, IP addresses and user names are blurred. Hover over a blurred spot to reveal it.'
71 : 'Recording mode off.',
72 }
73 })
74
75 on('ui.render', { component: 'AssistantMessage' }, ($, e, next) => {
76 if (!isOn || !hasSecret(e.props.text)) return next(e)
77 return renderReply($.ui.resolve(e), e.props.text, ctx, e.requestId)
78 })
79
80 on('ui.render', { component: 'UserMessage' }, ($, e, next) => {
81 if (!isOn || !hasSecret(e.props.text)) return next(e)
82 const kind = e.props.origin.kind
83 if (kind === 'composer' || kind === 'bridge') return renderPrompt($.ui.resolve(e), e.props.text, ctx, e.requestId)
84 return next({ ...e, props: { ...e.props, text: maskText(e.props.text, ctx) } })
85 })
86
87 on('ui.render', { component: 'ToolResult' }, ($, e, next) => {
88 if (!isOn) return next(e)
89 const out = e.props.output
90 if (e.props.tool === 'Bash' && !e.props.isErrored && out !== null && typeof out === 'object') {
91 const { stdout, stderr } = out as { stdout?: unknown; stderr?: unknown }
92 const text = [stdout, stderr].filter((s): s is string => typeof s === 'string' && s.length > 0).join('\n')
93 return hasSecret(text) ? renderOutput($.ui.resolve(e), text, ctx, e.requestId) : next(e)
94 }
95 const output = maskDeep(out, ctx)
96 return output === out ? next(e) : next({ ...e, props: { ...e.props, output } })
97 })
98
99 on('ui.render', { component: 'ToolUse' }, ($, e, next) => {
100 if (!isOn) return next(e)
101 const props = { ...e.props }
102 const input = maskDeep(e.props.input, ctx)
103 if (input !== e.props.input) props.input = input
104 if ('output' in e.props) {
105 const output = maskDeep(e.props.output, ctx)
106 if (output !== e.props.output) props.output = output
107 }
108 return props.input === e.props.input && props.output === e.props.output ? next(e) : next({ ...e, props })
109 })
110
111 on('ui.render', { component: 'CommandOutput' }, ($, e, next) => {
112 if (!isOn || e.props.command === COMMAND) return next(e)
113 const text = maskText(e.props.text, ctx)
114 return text === e.props.text ? next(e) : next({ ...e, props: { ...e.props, text } })
115 })
116
117 on('ui.render', { component: 'Spinner' }, ($, e, next) => {
118 if (!isOn) return next(e)
119 const word = maskText(e.props.word, ctx)
120 const message = e.props.message === null ? null : maskText(e.props.message, ctx)
121 if (word === e.props.word && message === e.props.message) return next(e)
122 return next({ ...e, props: { ...e.props, word, message } })
123 })
124}
125hooks/detect.ts 182 lines1// rec-mode's detector: finds private data in a text (emails, keys, IP addresses,
2// user names) as spans, and masks them. Pure: no `$`, tested on its own.
3
4export type Kind = 'email' | 'key' | 'ip' | 'user'
5export type Span = { start: number; end: number; kind: Kind }
6
7export type Context = {
8 /** Account names, matched as whole words in the case written (the OS user). */
9 users: readonly string[]
10 /** Extra words from the mod's options: any case, and words starting with them. */
11 names: readonly string[]
12}
13
14// A rule matches the whole span, or `(prefix)(span)` when `isGrouped`: group 1
15// is what comes before, group 2 the span, and only lookaheads follow it.
16type Rule = { kind: Kind; re: RegExp; isGrouped?: boolean; isValue?: boolean }
17
18const OCTET = '(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)'
19const HEX = '[0-9A-Fa-f]{1,4}'
20const VALUE = '[^\\s"\'`,;)}\\]]'
21
22const RULES: Rule[] = [
23 // PEM private keys, whole.
24 { kind: 'key', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g },
25 // Provider keys, by their prefixes.
26 {
27 kind: 'key',
28 re: /(?:sk-ant-[\w-]{16,}|sk-(?:proj-|svcacct-|live-|test-)?[\w-]{20,}|gh[pousr]_[A-Za-z0-9]{30,}|github_pat_\w{30,}|xox[abprs]-[\w-]{10,}|AKIA[0-9A-Z]{16}|AIza[\w-]{35}|glpat-[\w-]{20,}|hf_[A-Za-z0-9]{30,}|(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{20,})/g,
29 },
30 // JSON Web Tokens.
31 { kind: 'key', re: /eyJ[\w-]{8,}\.[\w-]{8,}\.[\w-]{8,}/g },
32 // Authorization: Bearer <token>.
33 { kind: 'key', re: /(\bBearer\s+)([\w.~+/-]{16,}=*)/g, isGrouped: true },
34 // API_KEY=..., "token": "...", password: ...
35 {
36 kind: 'key',
37 re: new RegExp(
38 `([A-Za-z0-9_.-]*(?:api[_-]?key|apikey|secret|token|passw(?:or)?d|pwd|access[_-]?key|private[_-]?key|credential)[A-Za-z0-9_]*["']?\\s*[:=]\\s*["']?)(${VALUE}{6,})`,
39 'gi',
40 ),
41 isGrouped: true,
42 isValue: true,
43 },
44 { kind: 'email', re: /[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}/g },
45 { kind: 'ip', re: new RegExp(`(^|[^\\w.])((?:${OCTET}\\.){3}${OCTET})(?![\\w]|\\.\\d)`, 'g'), isGrouped: true },
46 {
47 kind: 'ip',
48 re: new RegExp(`(^|[^\\w:.])((?:${HEX}:){4,7}${HEX}|(?:${HEX}:){1,6}:(?:${HEX}(?::${HEX}){0,5})?)(?![\\w:])`, 'g'),
49 isGrouped: true,
50 },
51 // The account in a home path: /Users/<name>, /home/<name>, C:\Users\<name>.
52 { kind: 'user', re: /((?:\/Users\/|\/home\/|[A-Za-z]:\\Users\\))([^/\\\s"'`:]+)/g, isGrouped: true },
53 // user=..., "username": "...", login: ..., user.name=...
54 {
55 kind: 'user',
56 re: new RegExp(`(\\b(?:user(?:[_-]?name)?|login|user\\.name|github[_-]?user)["']?\\s*[:=]\\s*["']?)(${VALUE}{2,})`, 'gi'),
57 isGrouped: true,
58 isValue: true,
59 },
60 // @handles, but not a scoped package (@scope/pkg), a decorator call or a doc tag.
61 { kind: 'user', re: /(^|[^\w@./-])(@[A-Za-z0-9_][A-Za-z0-9_.-]*[A-Za-z0-9_])(?![\w/(]|[.-][\w/])/g, isGrouped: true },
62]
63
64// @-words that are code, not people.
65const NOT_HANDLES = new Set(
66 (
67 'param returns return example remarks see type typedef deprecated media import keyframes supports override throws ' +
68 'link todo default template internal public private since version author property container layer tailwind apply ' +
69 'font-face charset namespace page each include mixin extend use forward if else for while function ts-ignore ' +
70 'ts-expect-error ts-nocheck jsx class interface module'
71 )
72 .split(' ')
73 .map(w => '@' + w),
74)
75
76const NOT_SECRET_VALUES = new Set(['true', 'false', 'null', 'undefined', 'none', 'required', 'optional', 'string', 'number'])
77const GENERIC_USERS = new Set(['root', 'user', 'admin', 'runner', 'ubuntu', 'users', 'home'])
78
79function escape(s: string) {
80 return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
81}
82
83// The rules for the context's own names, compiled once per context.
84const compiled = new WeakMap<Context, Rule[]>()
85
86function contextRules(ctx: Context): Rule[] {
87 const known = compiled.get(ctx)
88 if (known) return known
89 const rules: Rule[] = []
90 for (const user of new Set(ctx.users)) {
91 if (user.length < 3 || GENERIC_USERS.has(user.toLowerCase())) continue
92 rules.push({ kind: 'user', re: new RegExp(`(^|[^A-Za-z0-9_])(${escape(user)})(?![A-Za-z0-9_])`, 'g'), isGrouped: true })
93 }
94 for (const name of new Set(ctx.names.map(n => n.trim()).filter(n => n.length >= 2))) {
95 rules.push({ kind: 'user', re: new RegExp(`(^|[^\\p{L}\\p{N}_])(${escape(name)}[\\p{L}\\p{N}_]*)`, 'giu'), isGrouped: true })
96 }
97 compiled.set(ctx, rules)
98 return rules
99}
100
101/** Every private span in `text`, in order, none overlapping (the longer wins). */
102export function findSpans(text: string, ctx: Context): Span[] {
103 if (text.length === 0) return []
104 const found: Span[] = []
105 for (const rule of [...RULES, ...contextRules(ctx)]) {
106 rule.re.lastIndex = 0
107 for (let m = rule.re.exec(text); m !== null; m = rule.re.exec(text)) {
108 if (m[0].length === 0) {
109 rule.re.lastIndex += 1
110 continue
111 }
112 const prefix = rule.isGrouped ? (m[1] ?? '') : ''
113 const value = rule.isGrouped ? (m[2] ?? '') : m[0]
114 if (value.length === 0) continue
115 if (rule.isValue && (/^\d+$/.test(value) || NOT_SECRET_VALUES.has(value.toLowerCase()))) continue
116 if (value.startsWith('@') && NOT_HANDLES.has(value.toLowerCase())) continue
117 const start = m.index + prefix.length
118 found.push({ start, end: start + value.length, kind: rule.kind })
119 }
120 }
121 found.sort((a, b) => a.start - b.start || b.end - b.start - (a.end - a.start))
122 const spans: Span[] = []
123 for (const s of found) {
124 const last = spans[spans.length - 1]
125 if (last && s.start < last.end) continue
126 spans.push(s)
127 }
128 return spans
129}
130
131export const MASK = '•'
132
133/** The text with every private character swapped for a dot; line breaks stay. */
134export function maskText(text: string, ctx: Context): string {
135 const spans = findSpans(text, ctx)
136 if (spans.length === 0) return text
137 let out = ''
138 let at = 0
139 for (const s of spans) {
140 out += text.slice(at, s.start) + text.slice(s.start, s.end).replace(/[^\n]/g, MASK)
141 at = s.end
142 }
143 return out + text.slice(at)
144}
145
146/** A value with every string in it masked; the same value back when nothing changed. */
147export function maskDeep<T>(value: T, ctx: Context): T {
148 if (typeof value === 'string') return maskText(value, ctx) as T
149 if (Array.isArray(value)) {
150 const items = value.map(v => maskDeep(v, ctx))
151 return (items.some((v, i) => v !== value[i]) ? items : value) as T
152 }
153 if (value !== null && typeof value === 'object') {
154 let changed = false
155 const out: Record<string, unknown> = {}
156 for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
157 const masked = maskDeep(v, ctx)
158 if (masked !== v) changed = true
159 out[k] = masked
160 }
161 return (changed ? out : value) as T
162 }
163 return value
164}
165
166/** One piece of a line as drawn: plain text, or a private span to blur. */
167export type Piece = { text: string; isSecret: boolean }
168
169/** A line cut into plain and private pieces. */
170export function pieces(text: string, ctx: Context): Piece[] {
171 const spans = findSpans(text, ctx)
172 const out: Piece[] = []
173 let at = 0
174 for (const s of spans) {
175 if (s.start > at) out.push({ text: text.slice(at, s.start), isSecret: false })
176 out.push({ text: text.slice(s.start, s.end), isSecret: true })
177 at = s.end
178 }
179 if (at < text.length || out.length === 0) out.push({ text: text.slice(at), isSecret: false })
180 return out
181}
182hooks/view.tsx 133 lines1// rec-mode's drawing: text whose private spots are grey bars the length of what
2// they hide; a pointer over a bar reveals it. Each bar has a hover scope of its
3// own, so only the spot under the pointer opens.
4import type { EngineInterface } from 'claude-code'
5
6import { findSpans, pieces } from './detect'
7import type { Context } from './detect'
8import { lineSegments, splitBlocks } from './md'
9import type { Segment, Style } from './md'
10
11type UI = ReturnType<EngineInterface['ui']['resolve']>
12
13export const COLOR = {
14 bar: '#6e7681', // the blur: text and background in one grey
15 revealText: '#f0f6fc',
16 revealBack: '#30363d',
17 code: '#79c0ff',
18}
19
20export const MAX_OUTPUT_LINES = 40
21
22function hash(s: string) {
23 let h = 5381
24 for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0
25 return (h >>> 0).toString(36)
26}
27
28/** Hover scopes for one drawing: `rec<instance>-<n>`, well under the 64-character limit. */
29export function scopes(requestId: string) {
30 const base = `rec${hash(requestId)}`
31 let n = 0
32 return () => `${base}-${n++}`
33}
34
35type NextScope = () => string
36
37function styleOf(style: Style) {
38 if (style === 'bold') return { bold: true }
39 if (style === 'code') return { color: COLOR.code }
40 if (style === 'dim') return { dimColor: true }
41 return {}
42}
43
44/** One line: plain text, styled segments, and blurred spots a pointer reveals. */
45function line(ui: UI, segs: Segment[], ctx: Context, scope: NextScope) {
46 const { Text } = ui
47 const children = []
48 for (const seg of segs) {
49 for (const p of pieces(seg.text, ctx)) {
50 if (p.isSecret) {
51 children.push(
52 <Text
53 {...(seg.style === 'bold' ? { bold: true } : {})}
54 color={COLOR.bar}
55 backgroundColor={COLOR.bar}
56 hover={{ scope: scope(), color: COLOR.revealText, backgroundColor: COLOR.revealBack }}
57 >
58 {p.text}
59 </Text>,
60 )
61 } else if (seg.style === 'plain') {
62 children.push(p.text)
63 } else {
64 children.push(<Text {...styleOf(seg.style)}>{p.text}</Text>)
65 }
66 }
67 }
68 return <Text>{children}</Text>
69}
70
71function plainLines(text: string): Segment[][] {
72 return text.split('\n').map(l => [{ text: l.length > 0 ? l : ' ', style: 'plain' as const }])
73}
74
75/** A reply: blocks without private data stay Markdown, the others are drawn line by line. */
76export function renderReply(ui: UI, text: string, ctx: Context, requestId: string) {
77 const { Box, Markdown } = ui
78 const scope = scopes(requestId)
79 const parts = []
80 let clean: string[] = []
81 const flush = () => {
82 if (clean.length > 0) parts.push(<Markdown text={clean.join('\n\n')} />)
83 clean = []
84 }
85 for (const block of splitBlocks(text)) {
86 if (findSpans(block.text, ctx).length === 0) {
87 clean.push(block.text)
88 continue
89 }
90 flush()
91 if (block.kind === 'fence') {
92 parts.push(
93 <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
94 {plainLines(block.body.join('\n')).map(segs => line(ui, segs, ctx, scope))}
95 </Box>,
96 )
97 } else {
98 parts.push(<Box flexDirection="column">{block.text.split('\n').map(l => line(ui, lineSegments(l), ctx, scope))}</Box>)
99 }
100 }
101 flush()
102 return (
103 <Box flexDirection="column" gap={1}>
104 {parts}
105 </Box>
106 )
107}
108
109/** The person's prompt, as typed, in a frame. */
110export function renderPrompt(ui: UI, text: string, ctx: Context, requestId: string) {
111 const { Box } = ui
112 const scope = scopes(requestId)
113 return (
114 <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
115 {plainLines(text).map(segs => line(ui, segs, ctx, scope))}
116 </Box>
117 )
118}
119
120/** A shell command's output, its first lines. */
121export function renderOutput(ui: UI, text: string, ctx: Context, requestId: string) {
122 const { Box, Text } = ui
123 const scope = scopes(requestId)
124 const lines = plainLines(text.replace(/\n+$/, ''))
125 const more = lines.length - MAX_OUTPUT_LINES
126 return (
127 <Box flexDirection="column">
128 {lines.slice(0, MAX_OUTPUT_LINES).map(segs => line(ui, segs, ctx, scope))}
129 {more > 0 && <Text dimColor>{`… +${more} lines`}</Text>}
130 </Box>
131 )
132}
133hooks/md.ts 82 lines1// rec-mode's Markdown helpers: a reply cut into blocks, so the blocks without
2// private data stay the app's own Markdown, and a line of Markdown read as plain
3// styled segments, for the blocks rec-mode draws itself. Pure: no `$`.
4
5export type Block = { kind: 'text'; text: string } | { kind: 'fence'; text: string; body: string[] }
6
7const FENCE_OPEN = /^\s{0,3}(`{3,}|~{3,})/
8const FENCE_CLOSE = /^\s{0,3}(`{3,}|~{3,})\s*$/
9
10/** Paragraphs, lists and tables split on blank lines; a code fence is one block, blank lines and all. */
11export function splitBlocks(text: string): Block[] {
12 const out: Block[] = []
13 let lines: string[] = []
14 let fence: string | null = null
15 const flush = () => {
16 if (lines.length > 0) out.push({ kind: 'text', text: lines.join('\n') })
17 lines = []
18 }
19 for (const line of text.split('\n')) {
20 if (fence !== null) {
21 lines.push(line)
22 const close = FENCE_CLOSE.exec(line)
23 if (close?.[1] && close[1][0] === fence[0] && close[1].length >= fence.length) {
24 out.push({ kind: 'fence', text: lines.join('\n'), body: lines.slice(1, -1) })
25 lines = []
26 fence = null
27 }
28 continue
29 }
30 const open = FENCE_OPEN.exec(line)
31 if (open?.[1]) {
32 flush()
33 fence = open[1]
34 lines.push(line)
35 continue
36 }
37 if (line.trim() === '') flush()
38 else lines.push(line)
39 }
40 // A fence still open (a reply mid-stream) is a fence too.
41 if (fence !== null) out.push({ kind: 'fence', text: lines.join('\n'), body: lines.slice(1) })
42 else flush()
43 return out
44}
45
46export type Style = 'plain' | 'bold' | 'code' | 'dim'
47export type Segment = { text: string; style: Style }
48
49/** One Markdown line as styled segments: its marker drawn simply; bold, code and link text unwrapped. */
50export function lineSegments(line: string): Segment[] {
51 let rest = line
52 let base: Style = 'plain'
53 const segs: Segment[] = []
54 let m: RegExpExecArray | null
55 if ((m = /^\s{0,3}#{1,6}\s+(.*)$/.exec(line))) {
56 base = 'bold'
57 rest = m[1] ?? ''
58 } else if ((m = /^(\s*)[-*+]\s+(.*)$/.exec(line))) {
59 segs.push({ text: `${m[1] ?? ''}• `, style: 'plain' })
60 rest = m[2] ?? ''
61 } else if ((m = /^(\s*)(\d+[.)])\s+(.*)$/.exec(line))) {
62 segs.push({ text: `${m[1] ?? ''}${m[2] ?? ''} `, style: 'plain' })
63 rest = m[3] ?? ''
64 } else if ((m = /^\s*>\s?(.*)$/.exec(line))) {
65 segs.push({ text: '│ ', style: 'dim' })
66 rest = m[1] ?? ''
67 } else if (/^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$/.test(line)) {
68 return [{ text: line, style: 'dim' }] // a table's rule
69 }
70 const inline = /\*\*([^*]+)\*\*|`([^`]+)`|\[([^\]]+)\]\([^)]*\)/g
71 let at = 0
72 for (m = inline.exec(rest); m !== null; m = inline.exec(rest)) {
73 if (m.index > at) segs.push({ text: rest.slice(at, m.index), style: base })
74 if (m[1] !== undefined) segs.push({ text: m[1], style: 'bold' })
75 else if (m[2] !== undefined) segs.push({ text: m[2], style: 'code' })
76 else segs.push({ text: m[3] ?? '', style: base })
77 at = m.index + m[0].length
78 }
79 if (at < rest.length) segs.push({ text: rest.slice(at), style: base })
80 return segs.length > 0 ? segs : [{ text: ' ', style: 'plain' }]
81}
82