Masks secrets (API keys, tokens, passwords, private keys) in the transcript; hover a masked value to reveal it.

Masks secrets in the Claude Code transcript. Hover a masked value with the mouse to reveal it.
● Your key is sk-a•••••••• ← hover → sk-ant-api03-AbCd…
Private key blocks, Anthropic / OpenAI / GitHub / GitLab / Slack / Google / Stripe / npm tokens, AWS access keys and secret keys, JWTs, Azure AccountKey= / SharedAccessKey= / sig=, passwords in URLs (postgres://user:REDACTED@host), Bearer tokens, and generic assignments (password=…, api_key: …, client_secret=…). References and placeholders (${TOKEN}, <key>, process.env.X, xxxx) are left alone. The rules live in hooks/patterns.ts.
| Row | Behaviour |
|---|---|
| Assistant replies, your prompts | Masked, with hover reveal (a reply that contains a secret is drawn as plain text, not markdown) |
| Bash output and other text results | Masked, with hover reveal (first 12 lines; ctrl+o shows the rest) |
| Tool call arguments, structured results, messages from other agents | Masked, no reveal |
Masking only changes what the screen shows. The model and the transcript file still see the real values.
Hover needs a surface that reports the pointer: the desktop app, or the terminal's fullscreen layout.
hooks/register.tsx 178 lines1import type { EngineInterface, Register, RenderNode } from 'claude-code'
2
3import { LOCALES, pickLocale } from './i18n'
4import type { Locale, Messages } from './i18n'
5import { hasSecretDeep, maskDeep, segmentLines } from './patterns'
6import type { Segment } from './patterns'
7
8const MASK_COLOR = '#E5A50A'
9const REVEAL_FG = '#1F1F1F'
10const REVEAL_BG = '#F6C744'
11/** Lines of a tool's output drawn before the rest is folded, as the engine folds them. */
12const OUTPUT_LINES = 12
13
14type Elements = ReturnType<EngineInterface['ui']['resolve']>
15
16/** One line of segments; each secret is a keyed Box whose reveal paints over the mask on hover. */
17function Line(ui: Elements, segments: Segment[], row: number, color?: string): RenderNode {
18 const { Box, Text } = ui
19
20 return (
21 <Box flexDirection="row" flexWrap="wrap">
22 {segments.length === 0 && <Text> </Text>}
23 {segments.map((segment, index) =>
24 segment.secret === undefined ? (
25 <Text color={color}>{segment.text}</Text>
26 ) : (
27 <Box key={`secret-${row}-${index}`}>
28 <Text color={MASK_COLOR} bold>
29 {segment.text}
30 </Text>
31 <Box position="absolute" top={0} left={0} display="none" hover={{ display: 'flex' }}>
32 <Text color={REVEAL_FG} backgroundColor={REVEAL_BG}>
33 {segment.secret}
34 </Text>
35 </Box>
36 </Box>
37 ),
38 )}
39 </Box>
40 )
41}
42
43function Lines(
44 ui: Elements,
45 m: Messages,
46 text: string,
47 options: { color?: string; limit?: number; offset?: number } = {},
48) {
49 const { Box, Text } = ui
50 const lines = segmentLines(text)
51 const shown = options.limit === undefined ? lines : lines.slice(0, options.limit)
52 const hidden = lines.length - shown.length
53
54 return (
55 <Box flexDirection="column">
56 {shown.map((segments, row) => Line(ui, segments, (options.offset ?? 0) + row, options.color))}
57 {hidden > 0 && <Text dimColor>{m.moreLines(hidden)}</Text>}
58 </Box>
59 )
60}
61
62function hasSecretText(text: string): boolean {
63 return hasSecretDeep(text)
64}
65
66/** The text a tool's output shows as, when it is text the transcript prints. */
67function printedOf(output: unknown): { stdout: string; stderr: string } | null {
68 if (typeof output === 'string') {
69 return { stdout: output, stderr: '' }
70 }
71 if (output !== null && typeof output === 'object' && 'stdout' in output) {
72 const { stdout, stderr } = output as { stdout?: unknown; stderr?: unknown }
73
74 return {
75 stdout: typeof stdout === 'string' ? stdout : '',
76 stderr: typeof stderr === 'string' ? stderr : '',
77 }
78 }
79
80 return null
81}
82
83/** The plugin's option, Claude Code's `language` setting, then the process locale. */
84async function resolveLocale($: EngineInterface, option: unknown): Promise<Locale> {
85 const settings = await $.settings.read()
86
87 return pickLocale(
88 option,
89 settings.language,
90 await $.env.get('LC_ALL'),
91 await $.env.get('LC_MESSAGES'),
92 await $.env.get('LANG'),
93 )
94}
95
96export const register: Register = (on, options) => {
97 let m: Messages = LOCALES.en
98
99 on('session.start', async ($, e, next) => {
100 m = LOCALES[await resolveLocale($, options.language)]
101
102 return next(e)
103 })
104
105 on('ui.render', { component: 'AssistantMessage' }, ($, e, next) => {
106 if (!hasSecretText(e.props.text)) {
107 return next(e)
108 }
109 const ui = $.ui.resolve(e)
110 const { Box, Text } = ui
111
112 return (
113 <Box flexDirection="row">
114 <Text>{e.props.isFirstOfReply ? '● ' : ' '}</Text>
115 <Box flexDirection="column" flexShrink={1}>
116 {Lines(ui, m, e.props.text)}
117 </Box>
118 </Box>
119 )
120 })
121
122 on('ui.render', { component: 'UserMessage' }, ($, e, next) => {
123 if (!hasSecretText(e.props.text)) {
124 return next(e)
125 }
126 if (e.props.origin.kind !== 'composer') {
127 return next({ ...e, props: { ...e.props, text: maskDeep(e.props.text) } })
128 }
129 const ui = $.ui.resolve(e)
130 const { Box, Text } = ui
131
132 return (
133 <Box flexDirection="row">
134 <Text dimColor>{'> '}</Text>
135 <Box flexDirection="column" flexShrink={1}>
136 {Lines(ui, m, e.props.text)}
137 </Box>
138 </Box>
139 )
140 })
141
142 // The call's row keeps the engine's drawing; its arguments are masked in place.
143 on('ui.render', { component: 'ToolUse' }, ($, e, next) => {
144 if (!hasSecretDeep(e.props.input) && !hasSecretDeep(e.props.output)) {
145 return next(e)
146 }
147
148 return next({
149 ...e,
150 props: { ...e.props, input: maskDeep(e.props.input), output: maskDeep(e.props.output) },
151 })
152 })
153
154 on('ui.render', { component: 'ToolResult' }, ($, e, next) => {
155 if (!hasSecretDeep(e.props.output)) {
156 return next(e)
157 }
158 const printed = e.props.isErrored ? null : printedOf(e.props.output)
159 if (!printed) {
160 return next({ ...e, props: { ...e.props, output: maskDeep(e.props.output) } })
161 }
162 const ui = $.ui.resolve(e)
163 const { Box, Text } = ui
164 const outLines = printed.stdout.replace(/\n$/, '')
165 const errLines = printed.stderr.replace(/\n$/, '')
166
167 return (
168 <Box flexDirection="row">
169 <Text dimColor>{' ⎿ '}</Text>
170 <Box flexDirection="column" flexShrink={1}>
171 {outLines && Lines(ui, m, outLines, { limit: OUTPUT_LINES })}
172 {errLines && Lines(ui, m, errLines, { color: '#F85149', limit: OUTPUT_LINES, offset: 10_000 })}
173 </Box>
174 </Box>
175 )
176 })
177}
178hooks/i18n.ts 40 lines1import { en } from '../locales/en'
2import { fr } from '../locales/fr'
3
4export type { Messages } from '../locales/en'
5
6export const LOCALES = { en, fr }
7
8export type Locale = keyof typeof LOCALES
9
10function match(value: unknown): Locale | null {
11 if (typeof value !== 'string') {
12 return null
13 }
14 const tag = value.trim().toLowerCase()
15 if (tag.startsWith('fr')) {
16 return 'fr'
17 }
18 if (tag.startsWith('en')) {
19 return 'en'
20 }
21
22 return null
23}
24
25/**
26 * The first candidate naming a supported locale, English otherwise. Callers pass,
27 * in order: the plugin's `language` option, Claude Code's `language` setting,
28 * then LC_ALL, LC_MESSAGES and LANG.
29 */
30export function pickLocale(...candidates: unknown[]): Locale {
31 for (const candidate of candidates) {
32 const locale = match(candidate)
33 if (locale) {
34 return locale
35 }
36 }
37
38 return 'en'
39}
40hooks/patterns.ts 153 lines1export type SecretRange = { start: number; end: number; kind: string }
2
3/** `group` names the capture holding the secret; without it the whole match is the secret. */
4type Rule = { kind: string; pattern: RegExp; group?: number }
5
6const RULES: Rule[] = [
7 {
8 kind: 'private key',
9 pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
10 },
11 { kind: 'anthropic', pattern: /sk-ant-[A-Za-z0-9_-]{20,}/g },
12 { kind: 'openai', pattern: /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}/g },
13 { kind: 'github', pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{50,})/g },
14 { kind: 'gitlab', pattern: /\bglpat-[A-Za-z0-9_-]{20,}/g },
15 { kind: 'aws key', pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
16 { kind: 'aws secret', pattern: /aws_secret_access_key["']?\s*[=:]\s*["']?([A-Za-z0-9/+=]{40})/gi, group: 1 },
17 { kind: 'slack', pattern: /\bxox[abposr]-[A-Za-z0-9-]{10,}/g },
18 { kind: 'google', pattern: /\bAIza[0-9A-Za-z_-]{35}/g },
19 { kind: 'stripe', pattern: /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{20,}/g },
20 { kind: 'npm', pattern: /\bnpm_[A-Za-z0-9]{36}\b/g },
21 { kind: 'jwt', pattern: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g },
22 { kind: 'azure', pattern: /\b(?:AccountKey|SharedAccessKey|sig)=([A-Za-z0-9%/+=]{20,})/g, group: 1 },
23 { kind: 'url password', pattern: /\b[a-z][a-z0-9+.-]*:\/\/[^\s:@/]+:([^\s@/]{3,})@/gi, group: 1 },
24 { kind: 'bearer', pattern: /\bBearer\s+([A-Za-z0-9._~+/-]{20,}=*)/g, group: 1 },
25 {
26 kind: 'secret',
27 pattern:
28 /\b[\w.-]*(?:password|passwd|pwd|secret|token|api[_-]?key|apikey|access[_-]?key|client[_-]?secret|private[_-]?key)[\w.-]*["']?\s*[:=]\s*["']?([^\s"'`,;)}\]]{8,})/gi,
29 group: 1,
30 },
31]
32
33/** Values the generic rule catches that are not secrets: references, placeholders, masks. */
34const PLACEHOLDER = /^(?:\$\{?|<|%|\{\{|process\.env|env\.|os\.environ|getenv|x{4,}|\*{3,}|•|\.{3}|null|undefined|true|false|none|changeme|your[_-])/i
35
36export function findSecrets(text: string): SecretRange[] {
37 const found: SecretRange[] = []
38
39 for (const rule of RULES) {
40 rule.pattern.lastIndex = 0
41 for (const match of text.matchAll(rule.pattern)) {
42 const whole = match[0]
43 const value = rule.group === undefined ? whole : match[rule.group]
44 if (!value || (rule.group !== undefined && PLACEHOLDER.test(value))) {
45 continue
46 }
47 const start = (match.index ?? 0) + (rule.group === undefined ? 0 : whole.lastIndexOf(value))
48 found.push({ start, end: start + value.length, kind: rule.kind })
49 }
50 }
51
52 found.sort((a, b) => a.start - b.start || b.end - a.end)
53 const merged: SecretRange[] = []
54 for (const range of found) {
55 const last = merged[merged.length - 1]
56 if (last && range.start < last.end) {
57 last.end = Math.max(last.end, range.end)
58 } else {
59 merged.push({ ...range })
60 }
61 }
62
63 return merged
64}
65
66/** What a secret shows as: a short hint of its start, then dots. */
67export function maskOf(value: string): string {
68 const hint = value.length >= 16 && !value.startsWith('-----') ? value.slice(0, 4) : ''
69
70 return `${hint}••••••••`
71}
72
73export function maskText(text: string): string {
74 const ranges = findSecrets(text)
75 let out = ''
76 let at = 0
77 for (const range of ranges) {
78 out += text.slice(at, range.start) + maskOf(text.slice(range.start, range.end))
79 at = range.end
80 }
81
82 return out + text.slice(at)
83}
84
85/** Every string inside a value masked, its shape kept. */
86export function maskDeep<T>(value: T): T {
87 if (typeof value === 'string') {
88 return maskText(value) as T
89 }
90 if (Array.isArray(value)) {
91 return value.map(item => maskDeep(item)) as T
92 }
93 if (value !== null && typeof value === 'object') {
94 return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, maskDeep(item)])) as T
95 }
96
97 return value
98}
99
100export function hasSecretDeep(value: unknown): boolean {
101 if (typeof value === 'string') {
102 return findSecrets(value).length > 0
103 }
104 if (Array.isArray(value)) {
105 return value.some(hasSecretDeep)
106 }
107 if (value !== null && typeof value === 'object') {
108 return Object.values(value).some(hasSecretDeep)
109 }
110
111 return false
112}
113
114export type Segment = { text: string; secret?: string; kind?: string }
115
116/** The text cut into lines, each a run of plain and secret segments. */
117export function segmentLines(text: string): Segment[][] {
118 const ranges = findSecrets(text)
119 const segments: Segment[] = []
120 let at = 0
121 for (const range of ranges) {
122 if (range.start > at) {
123 segments.push({ text: text.slice(at, range.start) })
124 }
125 const value = text.slice(range.start, range.end)
126 segments.push({ text: maskOf(value), secret: value, kind: range.kind })
127 at = range.end
128 }
129 if (at < text.length) {
130 segments.push({ text: text.slice(at) })
131 }
132
133 const lines: Segment[][] = [[]]
134 for (const segment of segments) {
135 if (segment.secret !== undefined) {
136 // A multi-line secret (a private key) stays one mark; its reveal shows the first line.
137 lines[lines.length - 1]?.push(segment)
138 continue
139 }
140 const parts = segment.text.split('\n')
141 parts.forEach((part, index) => {
142 if (index > 0) {
143 lines.push([])
144 }
145 if (part) {
146 lines[lines.length - 1]?.push({ text: part })
147 }
148 })
149 }
150
151 return lines
152}
153locales/en.ts 6 lines1export const en = {
2 moreLines: (count: number) => `… +${count} lines (ctrl+o to expand)`,
3}
4
5export type Messages = typeof en
6locales/fr.ts 6 lines1import type { Messages } from './en'
2
3export const fr: Messages = {
4 moreLines: (count: number) => `… +${count} ligne${count > 1 ? 's' : ''} (ctrl+o pour tout afficher)`,
5}
6