SLOPSHOPPER

screen-guard

Masks sensitive names, orgs and secrets in the transcript for screen sharing; /mask toggles, click a mask to reveal

newrowsguardcommandstatusprompt
★ 3v0.1.0MITupdated 2026-10-06danyuchn/claude-mods/screen-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · screen-guard
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ 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-guard: 螢幕遮蔽已開啟。點遮罩可解遮該詞,/mask reset 全部重新遮住。 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ screen-guard: 遮蔽 ON · JEV
README

screen-guard

屬於 claude-mods 合集。

上課或開會分享螢幕時,把 Claude Code 對話裡的人名、公司名、金鑰、聯絡方式換成黑色遮罩條,點一下才解開。

English summary — A Claude Code mod that redacts names, organisations, secrets and contact details in the transcript while you screen-share. It only changes what is drawn: the model still reads the original text. Masks are solid bars you can click to reveal. Detection is local regex first; ambiguous names are judged by TypeSafe JEV in the cloud, or by a local Ollama model when the text must not leave the machine. Tuned for Traditional Chinese (Taiwan) mixed with English.

screen-guard 實際畫面

上圖是實際畫面:人名、公司、Email、帳號都換成黑條,一般說明文字照常顯示。


做什麼

回覆要畫到畫面上
   |
   v
本機規則偵測(約 1.5 萬字 5ms)
   |-- 一定要遮:金鑰、Email、電話、身分證、地址、詞表裡的名字 --> 直接遮
   |-- 可能是名字:中文姓名、「王經理」、公司全名、夾在中文旁的英文名 --> 先遮
   |                                                   |
   |                       背景批次送去判讀 <-----------+
   |                       判定不是敏感資訊才放出來,結果存快取
   v
畫面:黑色遮罩條,點一下解開那個詞
  • 只改畫面:模型讀到的、存進對話紀錄的都是原文。學員看不到,你的工作不受影響。
  • Claude 自己也會標:遮蔽開啟時,mod 在系統提示加一段,請 Claude 把回覆裡的私人姓名與組織包成 ⟦…⟧。畫面上這些一律變黑條,括號不會出現;寫檔、跑指令前會自動剝掉括號,不會寫進檔案。模型讀得懂上下文,綽號、簡稱這類規則抓不到的也能遮。工具輸出(讀檔、指令結果)模型碰不到,那部分仍靠下面的偵測。
  • 不確定就遮:第一時間就是遮好的,不等網路;判讀失敗、逾時都維持遮蔽。
  • 遮的範圍:Claude 的回覆、你的提問、工具呼叫的參數與結果、折疊的工具群組。

安裝

claude plugin marketplace add danyuchn/claude-mods
claude plugin install screen-guard@dustin-mods

在 Claude Code 裡打 /mask on 開啟。開關狀態會保留到下一次 session。

指令

指令作用
/mask開/關切換
/mask on、/mask off直接開或關
/mask local只用本機模型判讀,文字不出這台機器
/mask cloud改回 JEV 判讀(失敗時自動退回本機)
/mask reset把點開過的詞全部遮回去
/mask reload重讀詞表與 local-only 清單

設定

兩份設定都放在你自己的家目錄,不在 repo 裡。範本在 examples/。

檔案內容
~/.config/screen-guard/terms.txt一行一個詞,一律遮;! 開頭的永遠不遮(例如 !Claude Code)
~/.config/screen-guard/local-only.txt一行一個資料夾路徑;在這些資料夾裡開的 session 只用本機模型判讀

環境變數:

變數用途
TYPESAFE_API_KEYTypeSafe JEV 金鑰。沒有就全部走本機模型
SCREEN_GUARD_OLLAMA_MODEL本機判讀用的 Ollama 模型,預設 qwen3.6:35b-a3b

判讀與隱私

路徑速度(實測)資料去哪
JEV(預設)40 個候選詞一批約 0.5 秒候選詞與前後各 60 字送到 TypeSafe
本機 Ollama40 個候選詞約 7.8 秒(M4 Pro)不出這台機器

客戶資料不能送第三方時,把那個客戶的資料夾加進 local-only.txt。詞表比對永遠在本機進行,最敏感的名字直接寫進詞表,不必靠模型判斷。

已知限制

  • 點擊解開只在全螢幕模式有效:一般模式下黑條照樣會畫,但點不開。
  • 表格或程式碼區塊裡有遮罩時,整塊會改成原始文字逐行列出,表格框線不會畫出來。
  • 你自己的輸入框、權限確認對話框、其他 App 都遮不到。
  • 工具呼叫列與你的提問也會遮,但黑條點不開(那幾處是 Claude Code 自己畫的)。
  • 偶爾回覆剛串流完的那一刻沒套用遮蔽,下一次重畫就正常。上課前先問一題,確認黑條有出來。
  • 中文人名偵測靠姓氏與稱謂規則,罕見姓氏可能漏抓。重要的名字請寫進詞表。

未來開發路線

  • 支援 Claude Desktop App:遮罩元件已在 desktop surface 跑過自動測試,尚未在 Desktop App 實機驗證點擊與排版。
  • 表格內的遮罩維持表格排版。
  • 中文人名改用本機 NER 模型偵測,降低對姓氏規則的依賴。
  • 剛開啟遮蔽後的第一則回覆,Claude 可能還沒套用標記指示(偵測層仍會遮)。
  • 詞表管理介面:在 Claude Code 裡直接把詞加入遮蔽或放行清單。

開發

claude plugin validate .
claude plugin test .

測試全部使用虛構資料。偵測規則在 hooks/detect.ts,畫面與判讀在 hooks/register.tsx。台灣身分證、電話、地址等規則移植自 pii-guard。

授權

MIT,見 repo 授權。

Source 3 files
hooks/register.tsx 373 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Revealed, Verdicts } from '../types'
5import { analyze, bar, chunkMarkdown, hasTags, maskDeep, maskPlain, parseTerms, segments, stripTags, tidy, tokens, units } from './detect'
6import type { Span, Terms, Unit } from './detect'
7
8const enabled = atom({ plugin: 'screen-guard', key: 'enabled' } as const, false)
9const verdicts = atom({ plugin: 'screen-guard', key: 'verdicts' } as const, {} as Verdicts)
10const revealed = atom({ plugin: 'screen-guard', key: 'revealed' } as const, {} as Revealed)
11
12const JEV_URL = 'https://api.typesafe.ai/v1/systemone'
13// Below this a soft candidate is shown. Strict on purpose: unsure means masked.
14const THRESHOLD = 0.2
15const BATCH = 40
16const RETRY_MS = 30_000
17const MAX_CACHE = 5000
18
19const QUESTION =
20  'A teacher is screen-sharing this text live to a class of students. Does `candidate`, as used in `snippet`, ' +
21  'name a real private person (client, student, colleague, family member) or a real private company or organisation ' +
22  'whose identity the teacher should hide from the students?'
23const CRITERIA = {
24  true: 'A real individual\'s name, nickname or handle, or a specific private company, client, school or organisation.',
25  false:
26    'A public software product, tool, programming term, famous brand or public platform, a generic word or role, ' +
27    'a fictional demo placeholder, or text that is not a name at all.',
28}
29
30const TERMS_PATH = '.config/screen-guard/terms.txt'
31
32const TAG_PROMPT = [
33  '# Screen sharing is on',
34  'The user is sharing this screen with an audience. In your prose replies, wrap every real private',
35  "person's name (client, student, colleague, family) and every private company, client or organisation",
36  'name in ⟦ and ⟧, for example ⟦王小明⟧ or ⟦Acme Trading⟧. Tag each occurrence. Do not tag public',
37  'products, tools, platforms, famous brands, or yourself. Never put ⟦ or ⟧ inside tool inputs, file',
38  'contents, code or commands. The brackets are hidden from the screen; do not mention them.',
39].join('\n')
40// Path prefixes (one per line) whose sessions never send text to a third party.
41const LOCAL_ONLY_PATH = '.config/screen-guard/local-only.txt'
42const OLLAMA_URL = 'http://localhost:11434/api/generate'
43// Override with SCREEN_GUARD_OLLAMA_MODEL; the default is the model this was tuned on.
44const DEFAULT_OLLAMA_MODEL = 'qwen3.6:35b-a3b'
45
46type Batch = Array<[string, string]>
47type Engine = 'jev' | 'local'
48
49let terms: Terms = { mask: [], allow: new Set() }
50const queue = new Map<string, string>() // term -> context snippet
51const inflight = new Set<string>()
52const failedAt = new Map<string, number>()
53let known: Verdicts = {}
54let apiKey: string | undefined
55let ollamaModel = DEFAULT_OLLAMA_MODEL
56let engine: Engine = 'jev'
57let forcedLocal = false
58
59function statusText() {
60  const route = forcedLocal ? '本機(路徑鎖定)' : engine === 'local' ? '本機' : 'JEV'
61  return `遮蔽 ON · ${route}`
62}
63
64async function loadLocalOnly($: any, cwd: string) {
65  const home = await $.env.get('HOME')
66  try {
67    const raw: string = await $.fs.read(`${home}/${LOCAL_ONLY_PATH}`)
68    forcedLocal = raw
69      .split('\n')
70      .map(l => l.trim().replace(/^~/, home ?? '~'))
71      .filter(l => l && !l.startsWith('#'))
72      .some(prefix => cwd === prefix || cwd.startsWith(prefix.endsWith('/') ? prefix : prefix + '/'))
73  } catch {
74    forcedLocal = false
75  }
76}
77
78async function loadTerms($: any) {
79  const home = await $.env.get('HOME')
80  try {
81    terms = parseTerms(await $.fs.read(`${home}/${TERMS_PATH}`))
82  } catch {
83    terms = { mask: [], allow: new Set() }
84  }
85}
86
87// Strict: hard hits always; soft hits unless JEV scored them low.
88function decider(v: Verdicts, shown: Revealed) {
89  return (s: Span) => {
90    if (shown[s.term]) return false
91    if (s.hard) return true
92    const p = v[s.term]
93    if (p === undefined) {
94      const now = Date.now()
95      if (!inflight.has(s.term) && now - (failedAt.get(s.term) ?? 0) > RETRY_MS) queue.set(s.term, s.context)
96      return true
97    }
98    return p >= THRESHOLD
99  }
100}
101
102async function masker($: any) {
103  const v = await read($, verdicts)
104  const shown = await read($, revealed)
105  const decide = decider(v, shown)
106  const plain = (s: string) => {
107    const { clean, spans } = analyze(s, terms)
108    return maskPlain(clean, spans, decide)
109  }
110  return { decide, plain }
111}
112
113async function askJev($: any, batch: Batch): Promise<Verdicts> {
114  if (!apiKey) throw new Error('no key')
115  const questions: Record<string, unknown> = {}
116  batch.forEach(([t, ctx], i) => {
117    questions[`q${i}`] = {
118      type: 'noul',
119      instructions: { candidate: t, snippet: ctx, question: QUESTION },
120      criteria: CRITERIA,
121    }
122  })
123  const res = await $.http.fetch(JEV_URL, {
124    method: 'POST',
125    headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
126    body: JSON.stringify({ model: 'jev-latest', state: 'Screen-share privacy review', questions }),
127  })
128  if (!res.ok) throw new Error(`jev ${res.status}`)
129  const answers = JSON.parse(res.text).answers as Record<string, { noul?: number }>
130  const got: Verdicts = {}
131  batch.forEach(([t], i) => {
132    const p = answers[`q${i}`]?.noul
133    if (typeof p === 'number') got[t] = p
134  })
135  return got
136}
137
138// Local fallback: one Ollama call, a keyed JSON schema so no item can shift rows.
139async function askLocal($: any, batch: Batch): Promise<Verdicts> {
140  const items = batch.map(([t, c], i) => ({ id: `c${i}`, candidate: t, snippet: c }))
141  const prompt =
142    'A teacher is screen-sharing text live to students. For each item, score 0-100 how likely `candidate` ' +
143    '(as used in `snippet`) names a real private person (client, student, colleague, family) or a real private ' +
144    'company/organisation that must be hidden. Score low for public software products, tools, programming terms, ' +
145    'famous brands, public platforms, generic words or roles, or text that is not a name.\nItems:\n' +
146    JSON.stringify(items) +
147    '\nReturn a JSON object mapping every item id to its integer score.'
148  const res = await $.http.fetch(OLLAMA_URL, {
149    method: 'POST',
150    headers: { 'Content-Type': 'application/json' },
151    body: JSON.stringify({
152      model: ollamaModel,
153      prompt,
154      stream: false,
155      think: false,
156      keep_alive: '2h',
157      options: { temperature: 0 },
158      format: {
159        type: 'object',
160        properties: Object.fromEntries(items.map(it => [it.id, { type: 'integer', minimum: 0, maximum: 100 }])),
161        required: items.map(it => it.id),
162      },
163    }),
164  })
165  if (!res.ok) throw new Error(`ollama ${res.status}`)
166  const scores = JSON.parse(JSON.parse(res.text).response) as Record<string, number>
167  const got: Verdicts = {}
168  batch.forEach(([t], i) => {
169    const v = scores[`c${i}`]
170    if (typeof v === 'number') got[t] = v / 100
171  })
172  return got
173}
174
175async function flush($: any) {
176  if (queue.size === 0 || inflight.size > 0) return
177  const batch: Batch = [...queue.entries()].slice(0, BATCH)
178  for (const [t] of batch) {
179    queue.delete(t)
180    inflight.add(t)
181  }
182  try {
183    let got: Verdicts | null = null
184    // Cloud only when this session allows it; any JEV failure falls through to the local model.
185    if (!forcedLocal && engine === 'jev') got = await askJev($, batch).catch(() => null)
186    if (got === null) got = await askLocal($, batch).catch(() => null)
187    if (got === null) throw new Error('no judge reachable')
188    for (const [t] of batch) if (got[t] === undefined) failedAt.set(t, Date.now())
189    known = { ...known, ...got }
190    const keys = Object.keys(known)
191    if (keys.length > MAX_CACHE) for (const k of keys.slice(0, keys.length - MAX_CACHE)) delete known[k]
192    const fresh = got
193    await update($, verdicts, v => ({ ...v, ...fresh }))
194    await $.store.set('verdicts', known)
195  } catch {
196    for (const [t] of batch) failedAt.set(t, Date.now())
197    $.ui.status(`${statusText()}(判讀不可用,全從嚴)`)
198  } finally {
199    for (const [t] of batch) inflight.delete(t)
200  }
201}
202
203export const register: Register = on => {
204  on('session.start', async ($, e, next) => {
205    await $.command.register({
206      name: 'mask',
207      description: 'Screen-share guard: toggle masking (/mask on|off|local|cloud|reset|reload)',
208    })
209    await loadTerms($)
210    await loadLocalOnly($, e.cwd)
211    engine = (await $.store.get('engine')) === 'local' ? 'local' : 'jev'
212    const stored = ((await $.store.get('verdicts')) ?? {}) as Record<string, number>
213    known = stored
214    await update($, verdicts, () => stored)
215    const wasOn = (await $.store.get('enabled')) === true
216    await update($, enabled, () => wasOn)
217    $.ui.status(wasOn ? statusText() : undefined)
218
219    apiKey = await $.env.get('TYPESAFE_API_KEY')
220    ollamaModel = (await $.env.get('SCREEN_GUARD_OLLAMA_MODEL')) || DEFAULT_OLLAMA_MODEL
221    $.clock.every(120, () => void flush($))
222
223    return next(e)
224  })
225
226  // While masking is on, Claude also tags private names itself; the renderer turns tags into bars.
227  on('prompt.compose', async ($, e, next) => {
228    const out = await next(e)
229    if (!(await read($, enabled))) return out
230    return { sections: [...out.sections, { id: 'screen-guard:tags', text: TAG_PROMPT, scope: 'session' as const }] }
231  })
232
233  // Tags are for the screen only: strip them from anything a tool would write or run.
234  on('tool.call', async ($, e, next) => {
235    // A call's arguments sit on the event itself (e.command, e.file_path, e.content).
236    if (!hasTags(e)) return next(e)
237    return next(maskDeep(e, s => stripTags(s).clean) as typeof e)
238  })
239
240  on('command.run', { command: 'mask' }, async ($, e) => {
241    const arg = e.args.trim()
242    if (arg === 'reset') {
243      await update($, revealed, () => ({}))
244      return { text: '已重新遮住所有手動解遮的項目。' }
245    }
246    if (arg === 'reload') {
247      await loadTerms($)
248      await loadLocalOnly($, await $.session.cwd())
249      return {
250        text: `已重讀詞表:遮蔽 ${terms.mask.length} 條、放行 ${terms.allow.size} 條;判讀走${forcedLocal ? '本機(路徑鎖定)' : engine === 'local' ? '本機' : 'JEV'}。`,
251      }
252    }
253    if (arg === 'local' || arg === 'cloud') {
254      engine = arg === 'local' ? 'local' : 'jev'
255      await $.store.set('engine', engine)
256      if (await read($, enabled)) $.ui.status(statusText())
257      if (forcedLocal && engine === 'jev') return { text: '這個資料夾在 local-only 清單內,仍維持本機判讀。' }
258      return { text: engine === 'local' ? `改用本機模型判讀(${ollamaModel}),文字不出這台機器。` : '改用 JEV 判讀;JEV 失敗時自動退回本機模型。' }
259    }
260    const cur = await read($, enabled)
261    const on_ = arg === 'on' ? true : arg === 'off' ? false : !cur
262    await update($, enabled, () => on_)
263    await $.store.set('enabled', on_)
264    if (!on_) await update($, revealed, () => ({}))
265    $.ui.status(on_ ? statusText() : undefined)
266    return { text: on_ ? '螢幕遮蔽已開啟。點遮罩可解遮該詞,/mask reset 全部重新遮住。' : '螢幕遮蔽已關閉。' }
267  })
268
269  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
270    if (!(await read($, enabled))) {
271      // Masking off: older tagged replies still drop their brackets.
272      const { clean } = stripTags(e.props.text)
273      return next(clean === e.props.text ? e : { ...e, props: { ...e.props, text: clean } })
274    }
275    const { Box, Text, Button, Markdown } = $.ui.resolve(e)
276    try {
277      const { decide } = await masker($)
278      const { clean: src, spans } = analyze(e.props.text, terms)
279      const plainProps = src === e.props.text ? e : { ...e, props: { ...e.props, text: src } }
280      if (!spans.some(decide)) return next(plainProps)
281      const reveal = (term: string) => void update($, revealed, r => ({ ...r, [term]: true }))
282      let n = 0
283      const row = (line: Unit, raw: boolean) => {
284        const segs = segments(line.text, spans.filter(sp => sp.start >= line.start && sp.end <= line.end).map(sp => ({ ...sp, start: sp.start - line.start, end: sp.end - line.start })), decide)
285        const indent = raw ? 0 : (line.text.match(/^\s*/)?.[0].length ?? 0)
286        const heading = !raw && /^\s*#{1,6}\s/.test(line.text)
287        return (
288          <Box key={`r${n++}`} flexDirection="row" flexWrap="wrap" paddingLeft={indent}>
289            {segs.map((sg, i) => {
290              if (sg.span) {
291                const term = sg.span.term
292                return <Button key={`b${n++}`} plain label={bar(term)} onPress={() => reveal(term)} />
293              }
294              const t = raw ? sg.text : tidy(sg.text, i === 0)
295              return tokens(t).map(tk => (
296                <Text key={`t${n++}`} bold={heading}>
297                  {tk}
298                </Text>
299              ))
300            })}
301          </Box>
302        )
303      }
304      const out: unknown[] = []
305      let clean: string[] = []
306      const flushClean = () => {
307        if (!clean.length) return
308        for (const c of chunkMarkdown(clean.join('\n'))) out.push(<Markdown key={`m${n++}`} text={c} />)
309        clean = []
310      }
311      for (const unit of units(src)) {
312        const masked = spans.some(sp => decide(sp) && sp.start < unit.end && sp.end > unit.start)
313        if (!masked) {
314          clean.push(unit.text)
315          continue
316        }
317        flushClean()
318        // A span crossing lines (a key block) hides the whole unit.
319        if (spans.some(sp => decide(sp) && sp.start < unit.end && sp.end > unit.start && (sp.start < unit.start || sp.end > unit.end))) {
320          out.push(<Text key={`x${n++}`}>{bar('xxxxxxxxxx')}</Text>)
321          continue
322        }
323        for (const line of unit.lines) out.push(row(line, unit.raw))
324      }
325      flushClean()
326      return (
327        <Box flexDirection="row">
328          <Text>{e.props.isFirstOfReply ? '⏺ ' : '  '}</Text>
329          <Box flexDirection="column" flexGrow={1} flexShrink={1}>
330            {out}
331          </Box>
332        </Box>
333      )
334    } catch {
335      return <Text dimColor>⏺ ‹此段已遮蔽›</Text>
336    }
337  })
338
339  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
340    if (!(await read($, enabled))) return next(e)
341    const { plain } = await masker($)
342    return next({ ...e, props: { ...e.props, text: plain(e.props.text) } })
343  })
344
345  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
346    if (!(await read($, enabled))) return next(e)
347    const { plain } = await masker($)
348    return next({
349      ...e,
350      props: {
351        ...e.props,
352        input: maskDeep(e.props.input, plain),
353        ...(e.props.output === undefined ? {} : { output: maskDeep(e.props.output, plain) }),
354      },
355    })
356  })
357
358  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
359    if (!(await read($, enabled))) return next(e)
360    const { plain } = await masker($)
361    return next({ ...e, props: { ...e.props, output: maskDeep(e.props.output, plain) } })
362  })
363
364  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
365    if (!(await read($, enabled))) return next(e)
366    const { plain } = await masker($)
367    return next({
368      ...e,
369      props: { ...e.props, calls: e.props.calls.map(c => ({ ...c, input: maskDeep(c.input, plain) })) },
370    })
371  })
372}
373
hooks/detect.ts 418 lines
1// Local, synchronous candidate detection. Hard hits are always masked;
2// soft hits (names, orgs) are masked until JEV clears them.
3
4export type Kind = 'secret' | 'contact' | 'id' | 'person' | 'org' | 'term'
5export type Span = {
6  start: number
7  end: number
8  term: string
9  kind: Kind
10  hard: boolean
11  context: string
12}
13
14export const LABEL: Record<Kind, string> = {
15  secret: '‹金鑰›',
16  contact: '‹聯絡›',
17  id: '‹證號›',
18  person: '‹人名›',
19  org: '‹組織›',
20  term: '‹機敏›',
21}
22
23type Rule = { re: RegExp; kind: Kind; group?: number }
24
25// Patterns ported from pii-guard's Taiwan recognizers plus common API key shapes.
26const HARD: Rule[] = [
27  { re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, kind: 'secret' },
28  { re: /\bsk-(?:ant-)?[A-Za-z0-9_-]{16,}/g, kind: 'secret' },
29  { re: /\b(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{10,}/g, kind: 'secret' },
30  { re: /\bwhsec_[A-Za-z0-9]{10,}/g, kind: 'secret' },
31  { re: /\b(?:ghp|gho|ghs|ghu|ghr)_[A-Za-z0-9]{20,}|\bgithub_pat_[A-Za-z0-9_]{20,}/g, kind: 'secret' },
32  { re: /\bxox[abprs]-[A-Za-z0-9-]{10,}/g, kind: 'secret' },
33  { re: /\bAKIA[0-9A-Z]{16}\b/g, kind: 'secret' },
34  { re: /\bAIza[0-9A-Za-z_-]{35}/g, kind: 'secret' },
35  { re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, kind: 'secret' },
36  { re: /\bBearer\s+([A-Za-z0-9._~+/=-]{16,})/g, kind: 'secret', group: 1 },
37  {
38    re: /(?:password|passwd|pwd|密碼|密码|token|secret|api[_-]?key|驗證碼)["']?\s*(?:是|為)?\s*[:=:]\s*["']?([^\s"',;]{4,})/gi,
39    kind: 'secret',
40    group: 1,
41  },
42  { re: /(?<![A-Za-z0-9.@_%+-])[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}(?![A-Za-z0-9])/g, kind: 'contact' },
43  { re: /\+886[-\s]?9\d{2}[-\s]?\d{3}[-\s]?\d{3}/g, kind: 'contact' },
44  { re: /\+66[-\s]?\d{1,2}[-\s]?\d{3}[-\s]?\d{4}/g, kind: 'contact' },
45  { re: /(?<!\d)09\d{2}[-\s]?\d{3}[-\s]?\d{3}(?!\d)/g, kind: 'contact' },
46  { re: /\(0[2-8]\)\s*\d{3,4}[-\s]?\d{4}|(?<!\d)0[2-8]-\d{7,8}(?!\d)/g, kind: 'contact' },
47  { re: /(?<![A-Za-z0-9])[A-Z][12A-D89]\d{8}(?![A-Za-z0-9])/g, kind: 'id' },
48  { re: /(?<!\d)\d{4}[-\s]\d{4}[-\s]\d{4}[-\s]\d{4}(?!\d)/g, kind: 'id' },
49  {
50    re: /(?:(?:台|臺)(?:北|中|南|東)市|新北市|桃園市|高雄市|基隆市|新竹[市縣]|嘉義[市縣]|苗栗縣|彰化縣|南投縣|雲林縣|屏東縣|宜蘭縣|花蓮縣|(?:台|臺)東縣|澎湖縣|金門縣|連江縣)[^\s,。,]{2,30}?\d+(?:之\d+)?號(?:\d+樓)?/g,
51    kind: 'contact',
52  },
53  { re: /(?<![\d.])(?!(?:127|10|0)\.)(?!192\.168\.)(?!172\.(?:1[6-9]|2\d|3[01])\.)(?:\d{1,3}\.){3}\d{1,3}(?![\d.])/g, kind: 'contact' },
54]
55
56// High-entropy token: long, mixes upper, lower and digits. Git SHAs are lowercase hex, so they pass.
57const ENTROPY = /(?<![A-Za-z0-9_-])(?=[A-Za-z0-9_-]*[A-Z])(?=[A-Za-z0-9_-]*[a-z])(?=[A-Za-z0-9_-]*\d)[A-Za-z0-9_-]{32,}(?![A-Za-z0-9_-])/g
58
59const SURNAMES =
60  '陳林黃張李王吳劉蔡楊許鄭謝洪郭邱曾廖賴徐周葉蘇莊呂江何蕭羅高潘簡朱鍾游彭詹胡施沈余盧梁趙顏柯翁魏孫戴范宋方鄧杜傅侯曹薛丁卓阮馬董溫唐藍蔣石古紀姚連馮歐湯黎田白涂尤巫韓龔嚴袁鐘鄒'
61
62// Two-character words that start or end on a surname character but are not names.
63const STOP = new Set(
64  (
65    '許多 許可 允許 也許 或許 期許 曾經 未曾 不曾 周末 周邊 周圍 周期 周年 每周 四周 周遭 江湖 朱紅 ' +
66    '高度 高級 高手 高效 高速 高中 高雄 高峰 高頻 高層 高價 高達 高於 最高 提高 很高 太高 更高 較高 拉高 偏高 調高 高亮 ' +
67    '方法 方式 方向 方案 方便 方面 對方 雙方 地方 官方 一方 方塊 方針 方形 處方 配方 前方 後方 下方 上方 我方 他方 遠方 東方 西方 ' +
68    '何時 如何 任何 為何 幾何 何必 何況 何謂 何處 何種 施作 施工 實施 措施 設施 施行 施加 ' +
69    '程式 連結 連線 連接 連續 連到 連上 串連 紀錄 紀念 紀律 簡單 簡化 簡報 簡介 簡短 簡潔 精簡 謝謝 感謝 致謝 ' +
70    '馬上 尤其 嚴格 嚴重 黃色 黃金 藍色 藍圖 溫度 溫和 白話 白色 空白 明白 古典 石頭 余額 唐突 湯匙 田野 董事 鐘頭 時鐘 ' +
71    '陳述 陳列 林立 森林 樹林 范圍 宋體 歐洲 歐美 洪水 莊重 胡亂 葉子 羅列 韓國 杜絕 卓越 丁點 傅立 ' +
72    '一張 主張 擴張 緊張 誇張 開張 紙張 張數 張貼 李子 行李 王國 王牌 王道 國王 吳語 劉海 蔡司 楊柳 鄭重 ' +
73    '邱比 郭外 廖廖 賴床 依賴 信賴 仰賴 徐徐 蘇打 呂宋 蕭條 潘朵 鍾愛 鍾情 游戲 游標 上游 下游 彭湃 詹姆 胡椒 ' +
74    '沈默 沈重 沈浸 盧比 梁柱 趙氏 顏色 顏料 柯南 翁 魏 孫子 戴上 佩戴 愛戴 鄧 杜鵑 傅 侯 曹 薛 阮 溫暖 唐朝 ' +
75    '蔣 姚 馮 黎明 涂 尤 巫師 龔 嚴謹 袁 鐘 鄒 連帶 連動 連鎖 紀元 古老 石油 田地 白天 白板 方才 高興 高低 馬達 馬虎'
76  ).split(' '),
77)
78const FUNC = new Set('的了是在和與及說也就都要會能把被讓給從到對為以並或而但不沒很太更最這那個些上下中裡外前後時年月日請可已用將再又還跟找於'.split(''))
79const QUANT = new Set('一二兩三四五六七八九十幾每這那多0123456789'.split(''))
80const TITLES =
81  '先生|小姐|女士|老師|經理|總監|董事長|總經理|執行長|醫師|醫生|律師|會計師|教授|同學|博士|主任|院長|校長|副總|協理|特助|學姊|學長|學妹|學弟|太太|阿姨|教練'
82const TITLE_RE = new RegExp(`([\\u4e00-\\u9fff]{1,3})(?:${TITLES})`, 'g')
83const ORG_SUFFIX =
84  /(?:股份有限公司|有限公司|公司|集團|銀行|醫院|診所|大學|學院|法院|事務所|協會|基金會|工作室|實業|控股|證券|保險|顧問|研究院|研究所|管理處|委員會)/g
85const ORG_EN =
86  /\b(?:[A-Z][A-Za-z&.-]+\s){1,4}(?:Inc|Ltd|LLC|Corp|Co|Pte|GmbH|Holdings|Capital|Group|Partners|Advisors|Ventures|Asset Management)\b\.?/g
87const EN_PAIR = /\b[A-Z][a-z]{1,15}\s[A-Z][a-z]{1,15}\b/g
88// In mixed Chinese/English text, a capitalised word next to Chinese is usually a name.
89// CJK ideographs plus CJK and full-width punctuation (、,:()).
90const CJK_CTX = '[\\u4e00-\\u9fff\\u3000-\\u303f\\uff00-\\uffef]'
91const EN_NEAR_CJK = new RegExp(
92  `(?<=${CJK_CTX}\\s?)[A-Z][A-Za-z]{2,15}\\b|\\b[A-Z][A-Za-z]{2,15}(?=\\s?${CJK_CTX})`,
93  'g',
94)
95
96const isCJK = (c: string | undefined) => !!c && c >= '一' && c <= '鿿'
97
98function contextOf(text: string, start: number, end: number) {
99  return text.slice(Math.max(0, start - 60), Math.min(text.length, end + 60))
100}
101
102function chineseNames(text: string, out: Span[]) {
103  for (let i = 0; i < text.length; i++) {
104    const c = text[i]
105    if (!SURNAMES.includes(c)) continue
106    const prev = text[i - 1]
107    const next = text[i + 1]
108    if (!isCJK(next) || FUNC.has(next)) continue
109    if (prev && (QUANT.has(prev) || STOP.has(prev + c))) continue
110    if (STOP.has(c + next)) continue
111    let end = i + 2
112    const third = text[i + 2]
113    if (isCJK(third) && !FUNC.has(third) && !STOP.has(next + third)) end = i + 3
114    const term = text.slice(i, end)
115    out.push({ start: i, end, term, kind: 'person', hard: false, context: contextOf(text, i, end) })
116    i = end - 1
117  }
118}
119
120export type Terms = { mask: string[]; allow: Set<string> }
121
122export function parseTerms(raw: string): Terms {
123  const mask: string[] = []
124  const allow = new Set<string>()
125  for (const line of raw.split('\n')) {
126    const t = line.trim()
127    if (!t || t.startsWith('#')) continue
128    if (t.startsWith('!')) allow.add(t.slice(1).trim().toLowerCase())
129    else mask.push(t)
130  }
131  mask.sort((a, b) => b.length - a.length)
132  return { mask, allow }
133}
134
135function escapeRe(s: string) {
136  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
137}
138
139export function detect(text: string, terms: Terms): Span[] {
140  const all: Span[] = []
141  const push = (start: number, end: number, kind: Kind, hard: boolean) => {
142    const term = text.slice(start, end)
143    if (!term.trim()) return
144    if (terms.allow.has(term.toLowerCase())) return
145    all.push({ start, end, term, kind, hard, context: hard ? '' : contextOf(text, start, end) })
146  }
147  const run = (re: RegExp, kind: Kind, hard: boolean, group?: number) => {
148    re.lastIndex = 0
149    for (let m = re.exec(text); m; m = re.exec(text)) {
150      if (m[0].length === 0) {
151        re.lastIndex++
152        continue
153      }
154      if (group !== undefined && m[group] !== undefined) {
155        const s = m.index + m[0].lastIndexOf(m[group])
156        push(s, s + m[group].length, kind, hard)
157      } else push(m.index, m.index + m[0].length, kind, hard)
158    }
159  }
160
161  for (const t of terms.mask) {
162    // ASCII terms match whole words only, so "Anne" does not hit "planned".
163    const edge = /^[A-Za-z0-9]/.test(t) ? '(?<![A-Za-z0-9])' : ''
164    const tail = /[A-Za-z0-9]$/.test(t) ? '(?![A-Za-z0-9])' : ''
165    run(new RegExp(edge + escapeRe(t) + tail, 'gi'), 'term', true)
166    // Longer ASCII terms also hide handles and paths built on them (MorwennaQ929).
167    if (/^[A-Za-z0-9 ._-]{5,}$/.test(t)) run(new RegExp(`[A-Za-z0-9._-]*${escapeRe(t)}[A-Za-z0-9._-]*`, 'gi'), 'term', true)
168  }
169  for (const r of HARD) run(r.re, r.kind, true, r.group)
170  run(ENTROPY, 'secret', true)
171  // Org: suffix plus up to 8 preceding name characters, stopping at function words.
172  ORG_SUFFIX.lastIndex = 0
173  for (let m = ORG_SUFFIX.exec(text); m; m = ORG_SUFFIX.exec(text)) {
174    let s = m.index
175    while (s > 0 && m.index - s < 8) {
176      const c = text[s - 1]
177      if (!(isCJK(c) || /[A-Za-z0-9]/.test(c)) || FUNC.has(c)) break
178      s--
179    }
180    if (s < m.index) push(s, m.index + m[0].length, 'org', false)
181  }
182  run(ORG_EN, 'org', false)
183  TITLE_RE.lastIndex = 0
184  for (let m = TITLE_RE.exec(text); m; m = TITLE_RE.exec(text)) {
185    // Keep only the name part before the title, trimmed of leading function words.
186    let s = m.index
187    let name = m[1]
188    while (name.length && FUNC.has(name[0])) {
189      name = name.slice(1)
190      s++
191    }
192    if (name.length) push(s, s + name.length, 'person', false)
193  }
194  chineseNames(text, all)
195  run(EN_PAIR, 'person', false)
196  run(EN_NEAR_CJK, 'person', false)
197
198  // Allowlisted phrases shield everything inside them.
199  const lower = text.toLowerCase()
200  const shield: Array<[number, number]> = []
201  for (const a of terms.allow) {
202    for (let i = lower.indexOf(a); a && i >= 0; i = lower.indexOf(a, i + a.length)) shield.push([i, i + a.length])
203  }
204  const shielded = (s: Span) => shield.some(([a, b]) => s.start >= a && s.end <= b)
205
206  // Resolve overlaps: hard beats soft, then longer beats shorter.
207  all.sort((a, b) => Number(b.hard) - Number(a.hard) || b.end - b.start - (a.end - a.start))
208  const taken: Span[] = []
209  for (const s of all) {
210    if (terms.allow.has(s.term.toLowerCase()) || shielded(s)) continue
211    const hit = taken.find(t => s.start < t.end && t.start < s.end)
212    if (!hit) {
213      taken.push(s)
214      continue
215    }
216    // A soft span reaching past a hard one widens it: "Morwenna" + "Morwenna Quill" hides both words.
217    if (hit.hard && !s.hard && (s.start < hit.start || s.end > hit.end)) {
218      hit.start = Math.min(hit.start, s.start)
219      hit.end = Math.max(hit.end, s.end)
220      hit.term = text.slice(hit.start, hit.end)
221    }
222  }
223  return taken.sort((a, b) => a.start - b.start)
224}
225
226export type Decide = (s: Span) => boolean
227
228// Plain-text masking for rows drawn by the engine (prompts, tool rows).
229export function maskPlain(text: string, spans: Span[], decide: Decide): string {
230  let out = ''
231  let at = 0
232  for (const s of spans) {
233    if (!decide(s)) continue
234    out += text.slice(at, s.start) + bar(s.term)
235    at = s.end
236  }
237  return out + text.slice(at)
238}
239
240// Code fences and inline code: links do not render there, so masks stay plain.
241function codeRanges(text: string): Array<[number, number]> {
242  const ranges: Array<[number, number]> = []
243  const re = /```[\s\S]*?(?:```|$)|`[^`\n]+`/g
244  for (let m = re.exec(text); m; m = re.exec(text)) ranges.push([m.index, m.index + m[0].length])
245  return ranges
246}
247
248// Markdown masking: each mask outside code is a link whose press reveals the term.
249export function maskMarkdown(
250  text: string,
251  spans: Span[],
252  decide: Decide,
253): { text: string; links: Map<string, string> } {
254  const code = codeRanges(text)
255  const inCode = (i: number) => code.some(([a, b]) => i >= a && i < b)
256  const links = new Map<string, string>()
257  let out = ''
258  let at = 0
259  for (const s of spans) {
260    if (!decide(s)) continue
261    out += text.slice(at, s.start)
262    if (inCode(s.start) || text.slice(s.start, s.end).includes('\n')) out += LABEL[s.kind]
263    else {
264      const href = `https://mask.invalid/${links.size}`
265      links.set(href, s.term)
266      out += `[${LABEL[s.kind]}](${href})`
267    }
268    at = s.end
269  }
270  return { text: out + text.slice(at), links }
271}
272
273// Split markdown into chunks under the Markdown element's 10,000-char bound,
274// cutting only between lines outside a code fence.
275export function chunkMarkdown(text: string, max = 9000): string[] {
276  if (text.length <= max) return [text]
277  const chunks: string[] = []
278  let cur = ''
279  let fence = false
280  for (const line of text.split('\n')) {
281    if (cur.length + line.length + 1 > max && !fence && cur) {
282      chunks.push(cur)
283      cur = ''
284    }
285    if (line.length + 1 > max) {
286      for (let i = 0; i < line.length; i += max) chunks.push(line.slice(i, i + max))
287      continue
288    }
289    cur += (cur ? '\n' : '') + line
290    if (/^\s*```/.test(line)) fence = !fence
291  }
292  if (cur) chunks.push(cur)
293  return chunks
294}
295
296// Walks any tool input/output value, masking every string inside it.
297export function maskDeep(v: unknown, mask: (s: string) => string, depth = 0): unknown {
298  if (depth > 12) return v
299  if (typeof v === 'string') return mask(v)
300  if (Array.isArray(v)) return v.map(x => maskDeep(x, mask, depth + 1))
301  if (v && typeof v === 'object') {
302    const o: Record<string, unknown> = {}
303    for (const [k, x] of Object.entries(v)) o[k] = maskDeep(x, mask, depth + 1)
304    return o
305  }
306  return v
307}
308
309export type Seg = { text: string; span?: Span }
310
311const WIDE = /[ᄀ-ᅟ⺀-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]/
312
313// Display width: CJK and full-width characters take two cells.
314export function cells(s: string): number {
315  let n = 0
316  for (const ch of s) n += WIDE.test(ch) ? 2 : 1
317  return n
318}
319
320// Splits text into plain pieces and masked spans, keeping the original order.
321export function segments(text: string, spans: Span[], decide: Decide): Seg[] {
322  const out: Seg[] = []
323  let at = 0
324  for (const s of spans) {
325    if (!decide(s)) continue
326    if (s.start > at) out.push({ text: text.slice(at, s.start) })
327    out.push({ text: text.slice(s.start, s.end), span: s })
328    at = s.end
329  }
330  if (at < text.length) out.push({ text: text.slice(at) })
331  return out
332}
333
334// A solid redaction bar roughly as wide as the hidden text.
335export function bar(term: string): string {
336  return '█'.repeat(Math.max(2, Math.min(10, cells(term))))
337}
338
339export type Line = { text: string; start: number; end: number }
340export type Unit = Line & { lines: Line[]; raw: boolean }
341
342// Splits markdown into render units: a fenced code block or a table is one unit
343// (drawn raw when masked), every other line its own unit.
344export function units(text: string): Unit[] {
345  const lines: Line[] = []
346  let at = 0
347  for (const t of text.split('\n')) {
348    lines.push({ text: t, start: at, end: at + t.length })
349    at += t.length + 1
350  }
351  const out: Unit[] = []
352  const group = (ls: Line[], raw: boolean) =>
353    out.push({ text: ls.map(l => l.text).join('\n'), start: ls[0].start, end: ls[ls.length - 1].end, lines: ls, raw })
354  for (let i = 0; i < lines.length; i++) {
355    const l = lines[i]
356    if (/^\s*```/.test(l.text)) {
357      let j = i + 1
358      while (j < lines.length && !/^\s*```/.test(lines[j].text)) j++
359      group(lines.slice(i, Math.min(j + 1, lines.length)), true)
360      i = j
361    } else if (/^\s*\|/.test(l.text)) {
362      let j = i
363      while (j + 1 < lines.length && /^\s*\|/.test(lines[j + 1].text)) j++
364      group(lines.slice(i, j + 1), true)
365      i = j
366    } else group([l], false)
367  }
368  return out
369}
370
371// Light markdown cleanup for a line drawn as plain text beside redaction bars.
372export function tidy(s: string, isStart: boolean): string {
373  let t = s.replace(/\*\*|__|`/g, '')
374  if (isStart) t = t.replace(/^\s*#{1,6}\s+/, '').replace(/^\s*[-*+]\s+/, '• ').replace(/^\s+/, '')
375  return t
376}
377
378// Inline tokens for a wrapping row: one per CJK character, one per latin word
379// with its trailing spaces, so redaction bars stay where the text was.
380export function tokens(s: string): string[] {
381  return s.match(/[A-Za-z0-9_.,:;!?'"()\[\]\/@#$%&*+=<>~^-]+\s*|\s+|[^\s]/g) ?? []
382}
383
384// Model-side tags: Claude may wrap a private name as ⟦name⟧ (U+27E6/U+27E7).
385export const TAG_OPEN = '⟦'
386export const TAG_CLOSE = '⟧'
387const TAG_RE = /⟦([^⟦⟧\n]{1,80})⟧/g
388
389// Removes the tag characters and returns the tagged spans in the cleaned text.
390export function stripTags(text: string): { clean: string; tagged: Span[] } {
391  const tagged: Span[] = []
392  let clean = ''
393  let at = 0
394  TAG_RE.lastIndex = 0
395  for (let m = TAG_RE.exec(text); m; m = TAG_RE.exec(text)) {
396    clean += text.slice(at, m.index)
397    const start = clean.length
398    clean += m[1]
399    tagged.push({ start, end: clean.length, term: m[1], kind: 'person', hard: true, context: '' })
400    at = m.index + m[0].length
401  }
402  clean += text.slice(at)
403  // Stray brackets (an unclosed tag) are dropped too, so none reach the screen.
404  return { clean: clean.replace(/[⟦⟧]/g, ''), tagged }
405}
406
407// Local detection plus the model's own tags; a tagged span wins any overlap.
408export function analyze(text: string, terms: Terms): { clean: string; spans: Span[] } {
409  const { clean, tagged } = stripTags(text)
410  if (!tagged.length) return { clean, spans: detect(clean, terms) }
411  const found = detect(clean, terms).filter(s => !tagged.some(t => s.start < t.end && t.start < s.end))
412  return { clean, spans: [...found, ...tagged].sort((a, b) => a.start - b.start) }
413}
414
415export function hasTags(v: unknown): boolean {
416  return JSON.stringify(v ?? '').includes(TAG_OPEN) || JSON.stringify(v ?? '').includes(TAG_CLOSE)
417}
418
types/index.d.ts 9 lines
1export type Verdicts = Record<string, number>
2export type Revealed = Record<string, true>
3
4declare module 'claude-code' {
5  interface PluginState {
6    'screen-guard': { enabled: boolean; verdicts: Verdicts; revealed: Revealed }
7  }
8}
9