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

屬於 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.

上圖是實際畫面:人名、公司、Email、帳號都換成黑條,一般說明文字照常顯示。
回覆要畫到畫面上
|
v
本機規則偵測(約 1.5 萬字 5ms)
|-- 一定要遮:金鑰、Email、電話、身分證、地址、詞表裡的名字 --> 直接遮
|-- 可能是名字:中文姓名、「王經理」、公司全名、夾在中文旁的英文名 --> 先遮
| |
| 背景批次送去判讀 <-----------+
| 判定不是敏感資訊才放出來,結果存快取
v
畫面:黑色遮罩條,點一下解開那個詞
⟦…⟧。畫面上這些一律變黑條,括號不會出現;寫檔、跑指令前會自動剝掉括號,不會寫進檔案。模型讀得懂上下文,綽號、簡稱這類規則抓不到的也能遮。工具輸出(讀檔、指令結果)模型碰不到,那部分仍靠下面的偵測。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_KEY | TypeSafe JEV 金鑰。沒有就全部走本機模型 |
SCREEN_GUARD_OLLAMA_MODEL | 本機判讀用的 Ollama 模型,預設 qwen3.6:35b-a3b |
| 路徑 | 速度(實測) | 資料去哪 |
|---|---|---|
| JEV(預設) | 40 個候選詞一批約 0.5 秒 | 候選詞與前後各 60 字送到 TypeSafe |
| 本機 Ollama | 40 個候選詞約 7.8 秒(M4 Pro) | 不出這台機器 |
客戶資料不能送第三方時,把那個客戶的資料夾加進 local-only.txt。詞表比對永遠在本機進行,最敏感的名字直接寫進詞表,不必靠模型判斷。
claude plugin validate .
claude plugin test .
測試全部使用虛構資料。偵測規則在 hooks/detect.ts,畫面與判讀在 hooks/register.tsx。台灣身分證、電話、地址等規則移植自 pii-guard。
MIT,見 repo 授權。
hooks/register.tsx 373 lines1import { 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}
373hooks/detect.ts 418 lines1// 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}
418types/index.d.ts 9 lines1export 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