SLOPSHOPPER

screen-mask

For screen sharing and recordings: hides API keys, tokens, private keys, e-mail addresses, IP addresses, your login name in paths and any names you list. Only…

newbandrowscommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · screen-mask
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ screen-mask │ ⏺ Read(src/auth.ts) │ 🙈 Screen mask on: secrets and names are │ ⎿ Read 6 lines │ hidden on screen. │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /mask ⎿ screen-mask: Screen mask on. ⟨Claude Code's own drawing⟩ 🙈 Screen mask on: secrets, e-mails, IPs, login name Off ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ 🙈 Screen mask on: secrets, e-mails, IPs, login name Off
README

Screen Mask

For screen sharing and recordings: hides API keys, tokens, private keys, e-mail addresses, IP addresses, your login name in paths and any names you list. Only the display changes; Claude still reads the original. Nothing leaves the machine. /mask switches it.

Part of the Mod Box, which bundles it with other mods and one window to switch and configure them all. This is the same mod on its own.

Install

In a terminal Claude Code session (2.1.287 or newer):

/plugin install screen-mask --marketplace LupusMalusDeviant/claude-mods

Commands

CommandWhat it does
`/mask [on\off]`hide secrets and names on screen

Options

Set them in /config (or when installing).

OptionDefaultWhat it does
terms``Comma separated, e.g. customer names, project code names, a server name. Case does not matter.
emailsonEvery address becomes [e-mail].
ipsonIPv4 addresses except loopback and documentation ranges become [ip].
languageenLanguage of the texts this mod shows and writes.

License

MIT, see LICENSE.

Source 6 files
hooks/entry.tsx 30 lines
1// Entry of the standalone Screen Mask. The mod hands its display to a band (in the Mod Box the
2// band manager draws it); here this hook, registered first and so the outermost, draws it below
3// whatever else is above the prompt.
4import type { Register } from 'claude-code'
5
6import { register as inner } from '../mods/screen-mask/hooks/register'
7import { openBand, takeBand } from '../mods/settings/hooks/band'
8
9export const register: Register = (on, options) => {
10  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
11    openBand(e)
12    const rest = await next(e)
13    const parts = takeBand(e)
14    if (e.props.hasSurvey || parts.length === 0) return rest
15    const { Box, Text } = $.ui.resolve(e)
16    return (
17      <Box flexDirection="column">
18        {rest}
19        {parts.map(p => (
20          <Box key={`band-${p.id}`} flexDirection="row" paddingX={1}>
21            <Box flexShrink={1}>{p.line as any}</Box>
22            {(p.actions as any[]).flatMap((a, i) => [<Text key={`gap-${i}`}>{'  '}</Text>, a])}
23          </Box>
24        ))}
25      </Box>
26    )
27  })
28  ;(inner as any)(on, options)
29}
30
mods/screen-mask/hooks/register.tsx 105 lines
1// Screen Mask: while you share your screen, the transcript hides API keys, tokens, private keys,
2// e-mail addresses, IP addresses, your login name in paths and any names you list. Only the
3// drawing changes: Claude still reads and writes the original text. Nothing leaves the machine.
4// /mask switches it; the band shows when it is on.
5import { atom, read, update } from 'claude-code'
6import type { EngineInterface, Register } from 'claude-code'
7
8import { offer as offerBand } from '../../settings/hooks/band'
9import type { BandPart } from '../../settings/hooks/band'
10import { type MaskRules, maskDeep, maskText, parseTerms } from './mask'
11
12const isOn = atom({ plugin: 'screen-mask', key: 'on' } as const, false)
13// Kept across sessions: a restart in the middle of a screen share must not uncover anything.
14const STORE_ON = 'sm:on'
15
16let lang: 'de' | 'en' = 'en'
17const t = (de: string, en: string) => (lang === 'en' ? en : de)
18const rules: MaskRules = { terms: [], user: '', emails: true, ips: true }
19
20const mask = (s: string) => maskText(s, rules)
21
22async function setOn($: EngineInterface, on: boolean) {
23  await update($, isOn, () => on)
24  await $.store.set(STORE_ON, on).catch(() => {})
25  $.ui.toast(on ? t('🙈 Bildschirmschutz an: Geheimnisse und Namen sind in der Anzeige verdeckt.', '🙈 Screen mask on: secrets and names are hidden on screen.') : t('Bildschirmschutz aus.', 'Screen mask off.'))
26}
27
28export const register: Register = (on, options) => {
29  lang = options.language === 'de' ? 'de' : 'en'
30  rules.terms = parseTerms(String(options.terms ?? ''))
31  rules.emails = String(options.emails ?? 'on') !== 'off'
32  rules.ips = String(options.ips ?? 'on') !== 'off'
33
34  on('session.start', {}, async ($, e, next) => {
35    const r = await next(e)
36    rules.user = String((await $.env.get('USERNAME')) || (await $.env.get('USER')) || '')
37    await $.command.register({ name: 'mask', description: t('Bildschirmschutz: Geheimnisse und Namen in der Anzeige verdecken', 'Screen mask: hide secrets and names on screen'), argumentHint: '[on|off]' })
38    if ((await $.store.get(STORE_ON).catch(() => false)) === true) await update($, isOn, () => true)
39    return r
40  })
41
42  on('command.run', { command: 'mask' }, async ($, e) => {
43    const arg = String(e.args ?? '').trim().toLowerCase()
44    const want = arg === 'on' || arg === 'an' ? true : arg === 'off' || arg === 'aus' ? false : !(await read($, isOn))
45    await setOn($, want)
46    return { text: want ? t('Bildschirmschutz an.', 'Screen mask on.') : t('Bildschirmschutz aus.', 'Screen mask off.') }
47  })
48
49  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
50    if (!(await read($, isOn))) return next(e)
51    return next({ ...e, props: { ...e.props, text: mask(e.props.text) } })
52  })
53
54  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
55    if (!(await read($, isOn))) return next(e)
56    return next({ ...e, props: { ...e.props, text: mask(e.props.text) } })
57  })
58
59  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
60    if (!(await read($, isOn))) return next(e)
61    const props = e.props as Record<string, unknown>
62    return next({ ...e, props: { ...e.props, input: maskDeep(props.input, mask), ...(props.output === undefined ? {} : { output: maskDeep(props.output, mask) }) } } as typeof e)
63  })
64
65  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
66    if (!(await read($, isOn))) return next(e)
67    const props = e.props as Record<string, unknown>
68    return next({ ...e, props: { ...e.props, output: maskDeep(props.output, mask) } } as typeof e)
69  })
70
71  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
72    if (!(await read($, isOn))) return next(e)
73    return next({ ...e, props: { ...e.props, calls: e.props.calls.map(c => ({ ...c, input: maskDeep((c as Record<string, unknown>).input, mask) })) } } as typeof e)
74  })
75
76  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
77    const rest = await next(e)
78    if (!e.props.hasSurvey) {
79      const part = await band($, e).catch(() => null)
80      if (part) offerBand(e, part)
81    }
82    return rest
83  })
84}
85
86export async function band($: EngineInterface, e: any): Promise<BandPart | null> {
87  if (!(await read($, isOn))) return null
88  const { Text, Button } = $.ui.resolve(e)
89  const what = [t('Geheimnisse', 'secrets'), rules.emails ? t('E-Mails', 'e-mails') : '', rules.ips ? 'IPs' : '', rules.user ? t('Benutzername', 'login name') : '', rules.terms.length ? t(`${rules.terms.length} Begriffe`, `${rules.terms.length} terms`) : ''].filter(Boolean).join(', ')
90  return {
91    id: 'screenMask',
92    urgent: false,
93    short: <Text color="yellow">🙈</Text>,
94    line: (
95      <Text>
96        <Text color="yellow" bold>{'🙈 '}</Text>
97        <Text>{t('Bildschirmschutz an: ', 'Screen mask on: ')}</Text>
98        <Text dimColor>{what}</Text>
99      </Text>
100    ),
101    actions: [<Button key="sm-off" label={t('Aus', 'Off')} plain onPress={() => void setOn($, false)} />],
102    status: `🙈 ${t('Bildschirmschutz', 'Screen mask')}`,
103  }
104}
105
mods/settings/hooks/band.ts 44 lines
1// The band manager's mailbox. A band mod computes its display in its own AbovePrompt hook (with its
2// own $; the validator does not let $ cross an import) and drops it here with offer(). The settings
3// hook, registered first and so the outermost, runs after all of them, takes the parts and draws
4// them ordered, compact or one line each, or sends them to the status line.
5export type BandPart = {
6  /** The mod's switch key, e.g. "cacheMeter". */
7  id: string
8  /** Wants attention (CI red, sign offer …): always its own line with its buttons. */
9  urgent: boolean
10  /** One short element for the compact line. */
11  short: unknown
12  /** The full element for its own line. */
13  line: unknown
14  /** Buttons that belong to the part. */
15  actions: unknown[]
16  /** Plain text for the status line. */
17  status: string
18}
19
20const parts = new Map<string, BandPart[]>()
21
22function keyOf(e: { requestId?: string }): string {
23  return e.requestId ?? 'band'
24}
25
26/** Called by the manager before the inner hooks run. */
27export function openBand(e: { requestId?: string }) {
28  parts.set(keyOf(e), [])
29}
30
31/** Called by a band mod with its display for this render. */
32export function offer(e: { requestId?: string }, part: BandPart) {
33  const list = parts.get(keyOf(e))
34  if (list) list.push(part)
35  else parts.set(keyOf(e), [part])
36}
37
38/** Called by the manager after the inner hooks ran. */
39export function takeBand(e: { requestId?: string }): BandPart[] {
40  const list = parts.get(keyOf(e)) ?? []
41  parts.delete(keyOf(e))
42  return list
43}
44
mods/screen-mask/hooks/mask.ts 57 lines
1// Screen Mask: what to hide while the screen is shared, and how. No $ here.
2import { PATTERNS, isPlaceholder } from '../../secret-guard/hooks/patterns'
3
4export type MaskRules = {
5  /** Words or names to hide wherever they appear (case-insensitive). */
6  terms: string[]
7  /** The person's login name, hidden in paths like C:\Users\<name> or /home/<name>. */
8  user: string
9  emails: boolean
10  ips: boolean
11}
12
13const EMAIL = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g
14// IPv4 outside the loopback and documentation ranges.
15const IPV4 = /\b(?!127\.|0\.0\.0\.0\b|192\.0\.2\.|198\.51\.100\.|203\.0\.113\.)(?:25[0-5]|2[0-4]\d|1?\d?\d)(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3}\b/g
16const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
17
18/** The text with every secret, address and listed term replaced by a short label. */
19export function maskText(text: string, rules: MaskRules): string {
20  if (!text) return text
21  let out = text
22  for (const [what, re] of PATTERNS) {
23    out = out.replace(new RegExp(re.source, re.flags), m => (isPlaceholder(m) ? m : `[${what}]`))
24  }
25  if (rules.emails) out = out.replace(EMAIL, '[e-mail]')
26  if (rules.ips) out = out.replace(IPV4, '[ip]')
27  if (rules.user.length >= 2) {
28    const u = escape(rules.user)
29    out = out.replace(new RegExp(`([\\\\/](?:Users|home)[\\\\/])${u}(?=[\\\\/]|\\b)`, 'gi'), '$1•••')
30  }
31  for (const term of rules.terms) {
32    if (term.length < 2) continue
33    out = out.replace(new RegExp(`(?<![\\w])${escape(term)}(?![\\w])`, 'gi'), '•••')
34  }
35  return out
36}
37
38/** Every string inside a value (tool inputs and outputs are objects), masked. */
39export function maskDeep<T>(value: T, mask: (s: string) => string, depth = 0): T {
40  if (depth > 12) return value
41  if (typeof value === 'string') return mask(value) as T
42  if (Array.isArray(value)) return value.map(v => maskDeep(v, mask, depth + 1)) as T
43  if (value && typeof value === 'object') {
44    const out: Record<string, unknown> = {}
45    for (const [k, v] of Object.entries(value as Record<string, unknown>)) out[k] = maskDeep(v, mask, depth + 1)
46    return out as T
47  }
48  return value
49}
50
51/** The terms option: comma or newline separated. */
52export const parseTerms = (raw: string) =>
53  raw
54    .split(/[,\n]/)
55    .map(s => s.trim())
56    .filter(s => s.length >= 2)
57
mods/secret-guard/hooks/patterns.ts 27 lines
1// The patterns of known secrets, on their own: Screen Mask uses them too, and a standalone
2// export of it then carries only these, not Secret Guard's hooks and state.
3
4// Patterns of known secrets. Kept narrow, so examples and placeholders pass.
5export const PATTERNS: Array<[string, RegExp]> = [
6  ['Anthropic-API-Key', /sk-ant-[A-Za-z0-9_-]{20,}/g],
7  ['OpenAI-API-Key', /\bsk-(?!ant-)(?:proj-)?[A-Za-z0-9_-]{32,}/g],
8  ['GitHub-Token', /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{60,})/g],
9  ['GitLab-Token', /\bglpat-[A-Za-z0-9_-]{20,}/g],
10  ['AWS-Zugangsschlüssel', /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g],
11  ['Google-API-Key', /\bAIza[0-9A-Za-z_-]{35}\b/g],
12  ['Slack-Token', /\bxox[abprs]-[A-Za-z0-9-]{10,}/g],
13  ['Stripe-Live-Key', /\b(?:sk|rk)_live_[A-Za-z0-9]{20,}/g],
14  ['Hugging-Face-Token', /\bhf_[A-Za-z0-9]{30,}/g],
15  ['privater Schlüssel', /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY(?: BLOCK)?-----/g],
16  ['JWT', /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g],
17  [
18    'credentials in an assignment',
19    /\b(?:api[_-]?key|secret|token|passw(?:or)?d|client[_-]?secret|access[_-]?key)\b["']?\s*[:=]\s*["'](?![^"']*(?:example|beispiel|changeme|placeholder|your[_-]|xxx|<|\$\{|\{\{))[A-Za-z0-9+/_\-.=]{20,}["']/gi,
20  ],
21]
22
23/** Placeholders and documentation examples: they look like keys but are none. */
24export function isPlaceholder(value: string): boolean {
25  return /example|dummy|fake|placeholder|redacted|sample|x{6,}|0{10,}|\*{4,}|your[_-]?(?:api|key|token|secret)/i.test(value)
26}
27
mods/screen-mask/types/contract.d.ts 6 lines
1declare module 'claude-code' {
2  interface PluginState {
3    'screen-mask': { on: boolean }
4  }
5}
6