SLOPSHOPPER

secret-mask

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

newrows
★ 1v0.2.0MITupdated 2026-10-05Fazzani/claude-mods/plugins/secret-mask
A shopper browsing a rack in a slop shop
README

secret-mask

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…

What is detected

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.

Where

RowBehaviour
Assistant replies, your promptsMasked, with hover reveal (a reply that contains a secret is drawn as plain text, not markdown)
Bash output and other text resultsMasked, with hover reveal (first 12 lines; ctrl+o shows the rest)
Tool call arguments, structured results, messages from other agentsMasked, 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.

Source 5 files
hooks/register.tsx 178 lines
1import 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}
178
hooks/i18n.ts 40 lines
1import { 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}
40
hooks/patterns.ts 153 lines
1export 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}
153
locales/en.ts 6 lines
1export const en = {
2  moreLines: (count: number) => `… +${count} lines (ctrl+o to expand)`,
3}
4
5export type Messages = typeof en
6
locales/fr.ts 6 lines
1import 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