在模型讀到之前遮罩台灣身分證/居留證號與有標籤的病歷號、健保卡號;擋下會把遮罩值寫回來源的工具呼叫

Claude Code 的 mod(function hooks plugin)。在模型讀到之前,強制遮罩台灣病患識別欄位中格式固定的那幾種,把「靠模型記得遮罩」變成「程式強制」。
⚠ 這是防呆,不是去識別化保證。 它只抓得到格式固定的欄位(身分證、居留證、有標籤的病歷號與健保卡號)。中文姓名、地址、電話等完全不處理。裝了它不代表符合個資法或院內資安規範,處理真實病患資料前,請依貴院規定做好去識別化。
個人工具,歡迎 fork,不承諾支援。 已測試的 Claude Code 版本:2.1.292~2.1.295。mod API 目前是 early access,Claude Code 升版後請先執行
claude plugin test確認。
在 Claude Code 終端機輸入:
/plugin install phi-guard-tw --marketplace liangRXdev/phi-guard-tw
出現 Add marketplace? 時回答 y,scope 選 user,接著設定下方選項(全部可用預設值)。
| 位置 | 機制 | 行為 |
|---|---|---|
| 寫進對話的每一列(工具結果、MCP、附件、提問、子代理的對話) | session.append | 遮罩後才儲存與送出;狀態列顯示本 session 累計遮罩數 |
| 你的提問 | prompt.submit | 送出前遮罩;toast 只報類型與數量,不顯示原值 |
| 工具呼叫 | tool.call | ① 敏感目錄下的圖片/PDF 擋下(同時檢查字面路徑與 symlink 的實際落點)② 輸入含遮罩字樣的寫入類工具擋下,避免把遮罩值寫回來源、損毀原檔 |
失敗時一律擋下:遮罩出錯時,可改寫的內容換成佔位文字、圖片與 document 移除;提問不送出;工具呼叫也拒絕。
| 欄位 | 判定 | 遮罩結果 |
|---|---|---|
| 身分證、新式居留證(第二碼 8/9)、舊式居留證(第二碼 A–D) | regex 加檢核碼;全形、小寫也抓 | A12**90(前 3 碼+**+後 2 碼) |
| 病歷號 | 只遮有標籤的值:病歷號、病歷號碼、MRN、ChartNo、chart_no,以及你在 extraMrnLabels 加的標籤,後面接 :、:、= 或空白;或表格(CSV/TSV/Markdown/JSON 二維陣列)中欄名是這些標籤的那一欄 | 12**89(頭尾各留 2 碼);6 碼以下全遮成 ** |
| 健保卡號 | 同上,標籤為 健保卡號、健保卡號碼 | 同上 |
病歷號只遮有標籤的值是刻意的:開發環境裡到處是長數字(CI run ID、日期、檔案大小),全部遮掉的話正常工作會壞。
安裝時會詢問,之後可在 /config 修改。值存在你自己的 settings.json,不會進到任何 repo。
| 欄位 | 預設 | 說明 |
|---|---|---|
extraMrnLabels | 空 | 貴院自己的病歷號欄名。當純文字比對,不吃 regex;空白或少於 2 個字元會被忽略 |
sensitiveDirs | 00_private、private、phi | 路徑裡出現這些目錄名稱時,擋下對圖片/PDF 的 Read。不分大小寫。設成空陣列等於關閉這道守門,session 開始時會跳 toast 提醒 |
exemptMaskExamples | 空 | 寫入時不擋的遮罩字樣,例如規範文件裡的格式範例。值不像遮罩字樣(英數 2–3 碼+****+英數 2 碼)就會被忽略 |
readOnlyMcpTools | Google Sheets 的 get_values、get_spreadsheet | 輸入含遮罩字樣也放行的 MCP 工具全名(必須以 mcp__ 開頭)。設定會取代預設值。沒列出的 MCP 工具一律當成可能寫入 |
內建的病歷號標籤、健保卡號標籤和身分證規則不可關閉。
head/tail 等片段輸出裡的表格欄:靠表頭判斷,片段沒有表頭就不會遮(身分證有檢核碼,不受影響)。含病歷號欄的表格請用 Read 讀整檔。A123-456-789)、base64/URL 編碼內容:不處理。sensitiveDirs 擋讀取。其他位置的圖片會放行並跳 toast 提示。--resume 載入的舊對話:不會經過遮罩。安裝本 mod 之前的對話若含識別資料,resume 後模型會讀到原文。..:只要字面路徑含敏感目錄就擋,即使實際落點已離開該目錄。claude plugin validate .
claude plugin test .
.claude-plugin/types/ 產生型別與 tsconfig,之後執行 tsc -p .。tests/fixtures/synthetic.tsv。'*'.repeat(4))。.ai-review/ 收錄規格、Codex 獨立審查原文,以及逐項覆核判定。MIT。本工具不構成醫療、法律或資安建議,使用者須自行承擔使用風險。
hooks/register.ts 109 lines1import type { Register } from 'claude-code'
2import {
3 addCounts,
4 buildRules,
5 DEFAULT_CONFIG,
6 describeCounts,
7 emptyCounts,
8 findMaskTokens,
9 isMediaPath,
10 isSensitivePath,
11 maskBlocks,
12 maskText,
13 normalizeConfig,
14 placeholderBlocks,
15 totalOf,
16} from './mask'
17
18// 不寫回資料的內建工具:讀取類,以及代理之間傳訊息的工具(子代理的回報、SendMessage)。
19// 輸入裡出現遮罩字樣也不會寫壞任何來源檔,不擋。MCP 工具另由 userConfig 的 readOnlyMcpTools 指定,
20// 未列出的 MCP 工具一律當成可能寫入(未知即擋)。
21const READ_ONLY_TOOLS = new Set([
22 'Read', 'Glob', 'Grep', 'WebSearch', 'WebFetch', 'ToolSearch', 'TodoWrite', 'Skill',
23 'Agent', 'Task', 'SubagentHandback', 'SendMessage',
24])
25
26const MEDIA_FIELDS = ['file_path', 'path', 'notebook_path'] as const
27
28function pathOf(input: Record<string, unknown>): string | undefined {
29 for (const k of MEDIA_FIELDS) {
30 const v = input[k]
31 if (typeof v === 'string') return v
32 }
33 return undefined
34}
35
36function loadRules(options: unknown): { rules: ReturnType<typeof buildRules>; warnings: string[] } {
37 try {
38 const { config, warnings } = normalizeConfig(options)
39 return { rules: buildRules(config), warnings }
40 } catch {
41 // 設定讀取失敗:退回預設值,遮罩照常運作(開源版 Q3)
42 return { rules: buildRules(DEFAULT_CONFIG), warnings: ['設定無法解析,已改用預設值'] }
43 }
44}
45
46export const register: Register = (on, options) => {
47 const { rules, warnings } = loadRules(options)
48 let sessionMasked = 0
49
50 // 設定有異常時,session 開始時提醒一次
51 on('session.start', ($, e, next) => {
52 if (warnings.length > 0) $.ui.toast(`phi-guard 設定:${warnings.join(';')}`)
53 return next(e)
54 })
55
56 // ① 每一筆寫進對話的列(工具結果、MCP、附件、提問、子代理的對話):模型讀到之前遮罩。
57 on('session.append', async ($, e, next) => {
58 const r = maskBlocks(e.message.content, rules)
59 if (!r.changed) return next(e)
60 const stored = await next({ ...e, message: { ...e.message, content: r.blocks } })
61 sessionMasked += totalOf(r.counts)
62 $.ui.status(`phi-guard:本 session 已遮罩 ${sessionMasked} 處`)
63 return stored
64 }).catch(($, e, next) =>
65 // 掃描失敗:可改寫的換成佔位文字、媒體移除,寧可多擋(engine 自己的列不能拒絕,只能改寫)。
66 // next 已呼叫過(例如失敗發生在狀態列更新)時重播同一結果,不重複儲存。
67 next.called ? next(e) : next({ ...e, message: { ...e.message, content: placeholderBlocks(e.message.content) } }),
68 )
69
70 // ② 提問:送出前遮罩,toast 只報類型與數量,不顯示原值。
71 on('prompt.submit', ($, e, next) => {
72 const t = maskText(e.text, rules)
73 const ctx = e.context?.map(c => maskText(c, rules))
74 const merged = [t.counts, ...(ctx ?? []).map(c => c.counts)].reduce(addCounts, emptyCounts())
75 if (totalOf(merged) === 0) return next(e)
76 $.ui.toast(`phi-guard:提問已遮罩 ${describeCounts(merged)}`)
77 return next({ ...e, text: t.text, ...(ctx ? { context: ctx.map(c => c.text) } : {}) })
78 }).catch(($, e, next) => (next.called ? next(e) : { drop: 'phi-guard-tw 掃描失敗,提問未送出;請重送或移除可能的識別資料' }))
79
80 // ③ 工具呼叫:敏感目錄的圖片/PDF 擋下;輸入帶遮罩字樣(會把遮罩值寫回來源)擋下。
81 on('tool.call', async ($, e, next) => {
82 const input = e as unknown as Record<string, unknown>
83 const tool = String(e.tool)
84
85 const p = tool === 'Read' ? pathOf(input) : undefined
86 if (p !== undefined && isMediaPath(p)) {
87 // 字面路徑與實際落點(解析 symlink、junction、..)都要檢查;解析不到就擋(fail-closed)。
88 const stat = await $.fs.stat(p, { resolve: true }).catch(() => undefined)
89 const real = stat?.realPath
90 if (real === undefined || isSensitivePath(p, rules) || isSensitivePath(real, rules)) {
91 const why = real === undefined ? '無法解析實際路徑' : '位於敏感目錄'
92 return { deny: `phi-guard-tw:${p} 是圖片/PDF 且${why},影像內容無法遮罩,已擋下。需要時請先去識別化或轉成文字再讀。` }
93 }
94 $.ui.toast(`phi-guard:圖片/PDF 未經 PHI 掃描(${p.split(/[\\/]/).pop()})`)
95 }
96
97 if (!READ_ONLY_TOOLS.has(tool) && !rules.readOnlyMcpTools.has(tool)) {
98 const { tool: _t, tool_use_id: _id, agentId: _a, consent: _c, ...args } = input
99 const hits = findMaskTokens(JSON.stringify(args), rules)
100 if (hits.length > 0) {
101 return {
102 deny: `phi-guard-tw:${tool} 的輸入含遮罩字樣(${hits.length} 處),執行會把遮罩值寫回來源資料而損毀原檔,已擋下。請改用不經遮罩的方式處理該欄位(例如由使用者手動編輯)。`,
103 }
104 }
105 }
106 return next(e)
107 }).catch(($, e, next) => (next.called ? next(e) : { deny: 'phi-guard-tw:守門檢查失敗,為安全起見已擋下此工具呼叫' }))
108}
109hooks/mask.ts 453 lines1// 純函式:偵測與遮罩。不碰 $,方便單元測試。
2// 只處理格式固定的欄位:身分證/居留證(regex + 檢核碼)、有標籤的病歷號與健保卡號。
3// 姓名、地址、電話、生日、帶分隔符的 ID、base64/URL 編碼內容一律不處理(見 README 限制)。
4
5export type Kind = 'twid' | 'mrn' | 'nhi'
6
7export type Counts = Record<Kind, number>
8
9export const KIND_LABEL: Record<Kind, string> = {
10 twid: '身分證/居留證',
11 mrn: '病歷號',
12 nhi: '健保卡號',
13}
14
15export const emptyCounts = (): Counts => ({ twid: 0, mrn: 0, nhi: 0 })
16
17export function addCounts(a: Counts, b: Counts): Counts {
18 return { twid: a.twid + b.twid, mrn: a.mrn + b.mrn, nhi: a.nhi + b.nhi }
19}
20
21export const totalOf = (c: Counts): number => c.twid + c.mrn + c.nhi
22
23export function describeCounts(c: Counts): string {
24 return (Object.keys(c) as Kind[])
25 .filter(k => c[k] > 0)
26 .map(k => `${KIND_LABEL[k]}×${c[k]}`)
27 .join('、')
28}
29
30// ---------- 全形 → 半形(逐字 1:1,索引不位移) ----------
31
32export function toHalfWidth(s: string): string {
33 let out = ''
34 for (let i = 0; i < s.length; i++) {
35 const c = s.charCodeAt(i)
36 if (c >= 0xff01 && c <= 0xff5e) out += String.fromCharCode(c - 0xfee0)
37 else if (c === 0x3000) out += ' '
38 else out += s[i]
39 }
40 return out
41}
42
43// ---------- 身分證/居留證檢核碼 ----------
44
45const LETTER_CODE: Record<string, number> = {
46 A: 10, B: 11, C: 12, D: 13, E: 14, F: 15, G: 16, H: 17, I: 34, J: 18, K: 19, L: 20, M: 21,
47 N: 22, O: 35, P: 23, Q: 24, R: 25, S: 26, T: 27, U: 28, V: 29, W: 32, X: 30, Y: 31, Z: 33,
48}
49
50/** 身分證、新式居留證(第二碼 8/9)、舊式居留證(第二碼 A–D)。輸入須已是半形大寫 10 碼。 */
51export function isValidTwId(id: string): boolean {
52 if (!/^[A-Z][1289A-D][0-9]{8}$/.test(id)) return false
53 const first = id.charAt(0)
54 const second = id.charAt(1)
55 const head = LETTER_CODE[first]
56 if (head === undefined) return false
57 const secondDigit = /[A-D]/.test(second) ? (LETTER_CODE[second] ?? 0) % 10 : Number(second)
58 const digits = [secondDigit, ...id.slice(2).split('').map(Number)]
59 const weights = [8, 7, 6, 5, 4, 3, 2, 1, 1]
60 let sum = Math.floor(head / 10) + (head % 10) * 9
61 digits.forEach((d, i) => {
62 sum += d * (weights[i] ?? 0)
63 })
64 return sum % 10 === 0
65}
66
67// ---------- 遮罩格式 ----------
68
69/** A123456789 → A12****89 的格式:前 3 + **** + 後 2 */
70export const maskTwId = (id: string): string => `${id.slice(0, 3)}****${id.slice(-2)}`
71
72/** 病歷號/健保卡號:頭尾各留 2 碼;6 碼以下全遮(留 4 碼等於沒遮) */
73export const maskLabeled = (v: string): string => (v.length <= 6 ? '****' : `${v.slice(0, 2)}****${v.slice(-2)}`)
74
75// ---------- 偵測規則 ----------
76
77const TWID_RE = /(?<![A-Za-z0-9])[A-Za-z][1289A-Da-d][0-9]{8}(?![0-9])/g
78
79// 內建標籤:不可關閉(工具的核心)。使用者只能透過 extraMrnLabels 追加。
80const BUILTIN_MRN_LABELS = String.raw`病歷號碼|病歷號|MRN|Chart[ _]?No`
81const NHI_LABELS = String.raw`健保卡號碼|健保卡號`
82const SEP = String.raw`["']?[ \t]*[::=][ \t]*["']?|[ \t]+`
83// 值長度上限 64:遠大於實際病歷號(約 7–12 碼)與健保卡號(12 碼),只為避免病態長字串。
84const MAX_VALUE = 64
85const VALUE = String.raw`(?=[A-Za-z]*[0-9])([A-Za-z0-9]{1,${MAX_VALUE}})(?![A-Za-z0-9*])`
86const CELL_VALUE_RE = new RegExp(String.raw`^([ "']*)([A-Za-z0-9]{1,${MAX_VALUE}})([ "']*)$`)
87
88/** 病歷號/健保卡號的值:英數、長度在上限內、至少含一個數字(S3)。表格欄、JSON 表格共用。 */
89const isIdValue = (v: string): boolean => v.length <= MAX_VALUE && /^[A-Za-z0-9]+$/.test(v) && /[0-9]/.test(v)
90
91// ---------- 使用者設定(userConfig) ----------
92
93export type GuardConfig = {
94 extraMrnLabels: readonly string[]
95 sensitiveDirs: readonly string[]
96 exemptMaskExamples: readonly string[]
97 readOnlyMcpTools: readonly string[]
98}
99
100export const DEFAULT_CONFIG: GuardConfig = {
101 extraMrnLabels: [],
102 sensitiveDirs: ['00_private', 'private', 'phi'],
103 exemptMaskExamples: [],
104 readOnlyMcpTools: ['mcp__claude_ai_Google_Sheets__get_values', 'mcp__claude_ai_Google_Sheets__get_spreadsheet'],
105}
106
107const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
108
109/** 遮罩字樣的外形:英數 2–3 碼 + 四個星號 + 英數 2 碼,或有標籤後接四個星號(短值全遮)。 */
110const MASK_SHAPE = String.raw`[A-Za-z0-9]{2,3}\*{4}[A-Za-z0-9]{2}`
111
112function stringList(v: unknown): string[] | undefined {
113 if (v === undefined) return undefined
114 if (!Array.isArray(v)) return undefined
115 return v.filter((x): x is string => typeof x === 'string')
116}
117
118/**
119 * 把 register 收到的 options 整理成可用的設定;異常值依 2026-10-09 開源版 Q3 決策處理:
120 * 標籤當純文字、空白或少於 2 字元者忽略;豁免值不像遮罩字樣者忽略;讀不到就用預設。
121 */
122export function normalizeConfig(options: unknown): { config: GuardConfig; warnings: string[] } {
123 const warnings: string[] = []
124 const o = (options && typeof options === 'object' ? options : {}) as Record<string, unknown>
125 const pick = (k: keyof GuardConfig): readonly string[] => {
126 const v = stringList(o[k])
127 if (v === undefined) {
128 if (o[k] !== undefined) warnings.push(`${k} 不是字串陣列,改用預設值`)
129 return DEFAULT_CONFIG[k]
130 }
131 return v
132 }
133 const labels = pick('extraMrnLabels').map(s => s.trim()).filter(s => s.length >= 2)
134 const dirs = pick('sensitiveDirs').map(s => s.trim()).filter(s => s.length > 0)
135 const shape = new RegExp(String.raw`^${MASK_SHAPE}$`)
136 const exempt = pick('exemptMaskExamples').map(s => s.trim()).filter(s => shape.test(s))
137 const mcp = pick('readOnlyMcpTools').map(s => s.trim()).filter(s => s.startsWith('mcp__'))
138 if (dirs.length === 0) warnings.push('sensitiveDirs 為空:敏感目錄的圖片/PDF 守門已關閉')
139 return { config: { extraMrnLabels: labels, sensitiveDirs: dirs, exemptMaskExamples: exempt, readOnlyMcpTools: mcp }, warnings }
140}
141
142export type Rules = {
143 labeled: Array<[Kind, RegExp]>
144 header: Array<[Kind, RegExp]>
145 maskToken: RegExp
146 exempt: ReadonlySet<string>
147 sensitiveDirs: ReadonlySet<string>
148 readOnlyMcpTools: ReadonlySet<string>
149}
150
151export function buildRules(cfg: GuardConfig): Rules {
152 const mrn = [BUILTIN_MRN_LABELS, ...cfg.extraMrnLabels.map(escapeRe)].join('|')
153 return {
154 labeled: [
155 ['mrn', new RegExp(String.raw`(?:${mrn})(?:${SEP})${VALUE}`, 'gi')],
156 ['nhi', new RegExp(String.raw`(?:${NHI_LABELS})(?:${SEP})${VALUE}`, 'gi')],
157 ],
158 header: [
159 ['mrn', new RegExp(String.raw`^["' ]*(?:${mrn})["' ]*$`, 'i')],
160 ['nhi', new RegExp(String.raw`^["' ]*(?:${NHI_LABELS})["' ]*$`, 'i')],
161 ],
162 maskToken: new RegExp(
163 String.raw`(?<![A-Za-z0-9])${MASK_SHAPE}(?![A-Za-z0-9])|(?:${mrn}|${NHI_LABELS})(?:${SEP})\*{4}(?!\*)`,
164 'gi',
165 ),
166 exempt: new Set(cfg.exemptMaskExamples),
167 sensitiveDirs: new Set(cfg.sensitiveDirs.map(s => s.toLowerCase())),
168 readOnlyMcpTools: new Set(cfg.readOnlyMcpTools),
169 }
170}
171
172export const DEFAULT_RULES: Rules = buildRules(DEFAULT_CONFIG)
173
174type Span = { start: number; end: number; replacement: string; kind: Kind }
175
176function headerKind(cell: string, rules: Rules): Kind | undefined {
177 for (const [kind, re] of rules.header) if (re.test(cell)) return kind
178 return undefined
179}
180
181// ---------- 逐行表格:欄名是標籤的那一欄(CSV/TSV/Markdown pipe) ----------
182
183const DELIMS = ['\t', '|', ','] as const
184
185/** 切欄並保留每欄起點。CSV/TSV 認得雙引號:引號內的分隔符不切(`""` 是跳脫,兩次切換後狀態不變)。 */
186function splitWithOffsets(line: string, d: string): Array<{ text: string; start: number }> {
187 const cells: Array<{ text: string; start: number }> = []
188 const quoteAware = d === ',' || d === '\t'
189 let inQuotes = false
190 let start = 0
191 for (let i = 0; i <= line.length; i++) {
192 const ch = line[i]
193 if (quoteAware && ch === '"') inQuotes = !inQuotes
194 if (i === line.length || (ch === d && !inQuotes)) {
195 cells.push({ text: line.slice(start, i), start })
196 start = i + 1
197 }
198 }
199 return cells
200}
201
202function tableSpans(norm: string, rules: Rules): Span[] {
203 const spans: Span[] = []
204 const lines: Array<{ text: string; start: number }> = []
205 let s = 0
206 for (let i = 0; i <= norm.length; i++) {
207 if (i === norm.length || norm[i] === '\n') {
208 lines.push({ text: norm.slice(s, i).replace(/\r$/, ''), start: s })
209 s = i + 1
210 }
211 }
212 let active: { d: string; cols: Array<[number, Kind]>; width: number } | undefined
213 for (const line of lines) {
214 if (active) {
215 const cells = splitWithOffsets(line.text, active.d)
216 if (line.text.trim() === '' || cells.length !== active.width) {
217 active = undefined
218 } else {
219 for (const [col, kind] of active.cols) {
220 const cell = cells[col]
221 if (!cell) continue
222 const m = CELL_VALUE_RE.exec(cell.text)
223 const lead = m?.[1] ?? ''
224 const value = m?.[2]
225 if (!value || !isIdValue(value)) continue
226 const start = line.start + cell.start + lead.length
227 spans.push({ start, end: start + value.length, replacement: maskLabeled(value), kind })
228 }
229 continue
230 }
231 }
232 for (const d of DELIMS) {
233 const cells = splitWithOffsets(line.text, d)
234 if (cells.length < 2) continue
235 const cols: Array<[number, Kind]> = []
236 cells.forEach((c, i) => {
237 const k = headerKind(c.text.trim(), rules)
238 if (k) cols.push([i, k])
239 })
240 if (cols.length > 0) {
241 active = { d, cols, width: cells.length }
242 break
243 }
244 }
245 }
246 return spans
247}
248
249// ---------- JSON 二維陣列(Google Sheets get_values 等):表頭列是標籤的那一欄 ----------
250
251function jsonGridSpans(norm: string, rules: Rules): Span[] {
252 const t = norm.trim()
253 if (!t.startsWith('[') && !t.startsWith('{')) return []
254 let parsed: unknown
255 try {
256 parsed = JSON.parse(t)
257 } catch {
258 return []
259 }
260 const values = new Map<string, Kind>()
261 const visitGrid = (grid: unknown[]): void => {
262 if (!grid.every(r => Array.isArray(r))) return
263 const rows = grid as unknown[][]
264 const header = rows[0]
265 if (!header) return
266 const cols: Array<[number, Kind]> = []
267 header.forEach((h, i) => {
268 const k = typeof h === 'string' ? headerKind(h.trim(), rules) : undefined
269 if (k) cols.push([i, k])
270 })
271 if (cols.length === 0) return
272 for (const row of rows.slice(1)) {
273 for (const [c, k] of cols) {
274 const v = row[c]
275 if (typeof v !== 'string' && typeof v !== 'number') continue
276 const s = String(v).trim()
277 if (isIdValue(s)) values.set(s, k)
278 }
279 }
280 }
281 // 以堆疊走訪,不設深度上限(遞迴會受 call stack 限制,設上限則會靜默略過深層資料)
282 const stack: unknown[] = [parsed]
283 while (stack.length > 0) {
284 const v = stack.pop()
285 if (Array.isArray(v)) {
286 visitGrid(v)
287 for (const x of v) stack.push(x)
288 } else if (v && typeof v === 'object') {
289 for (const x of Object.values(v as Record<string, unknown>)) stack.push(x)
290 }
291 }
292 if (values.size === 0) return []
293 // 回到原字串單次掃描英數 token,命中集合者遮罩:O(n)。
294 // 同值出現在非目標欄也會被遮(偏向多遮,見 verdict #7)。
295 const spans: Span[] = []
296 for (const m of norm.matchAll(/(?<![A-Za-z0-9])[A-Za-z0-9]+(?![A-Za-z0-9])/g)) {
297 const kind = values.get(m[0])
298 if (kind === undefined) continue
299 const start = m.index ?? 0
300 spans.push({ start, end: start + m[0].length, replacement: maskLabeled(m[0]), kind })
301 }
302 return spans
303}
304
305// ---------- 主函式 ----------
306
307export function maskText(input: string, rules: Rules = DEFAULT_RULES): { text: string; counts: Counts } {
308 const counts = emptyCounts()
309 if (input.length === 0) return { text: input, counts }
310 const norm = toHalfWidth(input)
311 const spans: Span[] = []
312
313 for (const m of norm.matchAll(TWID_RE)) {
314 const id = m[0].toUpperCase()
315 if (!isValidTwId(id)) continue
316 const start = m.index ?? 0
317 spans.push({ start, end: start + 10, replacement: maskTwId(id), kind: 'twid' })
318 }
319 for (const [kind, re] of rules.labeled) {
320 for (const m of norm.matchAll(re)) {
321 const value = m[1]
322 if (value === undefined) continue
323 const start = (m.index ?? 0) + m[0].length - value.length
324 spans.push({ start, end: start + value.length, replacement: maskLabeled(value), kind })
325 }
326 }
327 spans.push(...tableSpans(norm, rules), ...jsonGridSpans(norm, rules))
328
329 if (spans.length === 0) return { text: input, counts }
330
331 // 由前往後套用;重疊時取先出現且較長者,避免重複遮
332 spans.sort((a, b) => a.start - b.start || b.end - a.end)
333 let out = ''
334 let cursor = 0
335 for (const sp of spans) {
336 if (sp.start < cursor) continue
337 out += input.slice(cursor, sp.start) + sp.replacement
338 cursor = sp.end
339 counts[sp.kind]++
340 }
341 out += input.slice(cursor)
342 return { text: out, counts }
343}
344
345// ---------- 對話列的 content blocks ----------
346
347type Block = { type: string; [field: string]: unknown }
348
349// 依 session.append 契約:text 可改寫;tool_result 的 content 可改寫;
350// image/document 只能移除或搬移、不能改寫;其他(tool_use、thinking、未知類型)engine 會原樣放回。
351
352export const DOCUMENT_REMOVED = '[phi-guard-tw:此文件含識別資料,已整份移除(document 依規格不能改寫)]'
353
354/** 文字型 document(source.type 為 text)的內文;其他 document(PDF base64 等)無法掃描,回 undefined。 */
355function documentText(b: Block): string | undefined {
356 const src = b.source as { type?: unknown; data?: unknown } | undefined
357 return src && src.type === 'text' && typeof src.data === 'string' ? src.data : undefined
358}
359
360/** 遮罩 text block、tool_result 的 content(字串或 block 陣列);命中識別資料的文字型 document 整塊換成說明文字。 */
361export function maskBlocks(
362 blocks: readonly Block[],
363 rules: Rules = DEFAULT_RULES,
364): { blocks: Block[]; counts: Counts; changed: boolean } {
365 let counts = emptyCounts()
366 let changed = false
367 /** 遮罩一段字串;沒有命中回 undefined,呼叫端保留原 block(保持參照不變)。 */
368 const apply = (s: string): string | undefined => {
369 const r = maskText(s, rules)
370 if (totalOf(r.counts) === 0) return undefined
371 counts = addCounts(counts, r.counts)
372 changed = true
373 return r.text
374 }
375 const maskOne = (b: Block): Block => {
376 if (b.type === 'text' && typeof b.text === 'string') {
377 const t = apply(b.text)
378 return t === undefined ? b : { ...b, text: t }
379 }
380 if (b.type === 'document') {
381 const data = documentText(b)
382 return data !== undefined && apply(data) !== undefined ? { type: 'text', text: DOCUMENT_REMOVED } : b
383 }
384 if (b.type === 'tool_result') {
385 const c = b.content
386 if (typeof c === 'string') {
387 const t = apply(c)
388 return t === undefined ? b : { ...b, content: t }
389 }
390 if (Array.isArray(c)) {
391 const inner = c.map(x => maskOne(x as Block))
392 return inner.some((x, i) => x !== c[i]) ? { ...b, content: inner } : b
393 }
394 }
395 return b
396 }
397 const out = blocks.map(maskOne)
398 return { blocks: out, counts, changed }
399}
400
401/** 遮罩失敗時的替代內容:寧可移除,也不放行原文。 */
402export const FAILED_PLACEHOLDER = '[phi-guard-tw:掃描失敗,此段內容已移除以免洩漏]'
403
404const MEDIA_TYPES = new Set(['image', 'document'])
405
406/**
407 * 掃描失敗時的整列替代:text 與 tool_result 的內容換成佔位文字;image/document 移除;
408 * 其餘 block 保留(tool_use、thinking、未知類型由 engine 原樣放回,hook 移不掉)。
409 * 不把整列換成單一 text block:tool_result 的 id 組是釘住的,engine 會把它放回,內容可能是原文。
410 */
411export function placeholderBlocks(blocks: unknown): Block[] {
412 if (!Array.isArray(blocks)) return [{ type: 'text', text: FAILED_PLACEHOLDER }]
413 const out: Block[] = []
414 for (const b of blocks as unknown[]) {
415 if (!b || typeof b !== 'object') {
416 out.push({ type: 'text', text: FAILED_PLACEHOLDER })
417 continue
418 }
419 const blk = b as Block
420 if (MEDIA_TYPES.has(blk.type)) continue
421 if (blk.type === 'text') out.push({ ...blk, text: FAILED_PLACEHOLDER })
422 else if (blk.type === 'tool_result') out.push({ ...blk, content: FAILED_PLACEHOLDER })
423 else out.push(blk)
424 }
425 if (!out.some(b => b.type === 'text' || b.type === 'tool_result')) out.push({ type: 'text', text: FAILED_PLACEHOLDER })
426 return out
427}
428
429// ---------- 資料完整性:偵測本 mod 產生的遮罩字樣 ----------
430
431/** 找出輸入中的遮罩字樣;使用者設定的豁免示範字樣(exemptMaskExamples)除外。 */
432export function findMaskTokens(s: string, rules: Rules = DEFAULT_RULES): string[] {
433 const hits: string[] = []
434 for (const m of s.matchAll(rules.maskToken)) {
435 if (!rules.exempt.has(m[0])) hits.push(m[0])
436 }
437 return hits
438}
439
440// ---------- 圖片/掃描檔 ----------
441
442const MEDIA_EXT_RE = /\.(png|jpe?g|gif|webp|bmp|tiff?|heic|pdf)$/i
443
444export const isMediaPath = (p: string): boolean => MEDIA_EXT_RE.test(p)
445
446/** 路徑的任一目錄段(不含檔名)是設定中的敏感目錄,不分大小寫。 */
447export function isSensitivePath(p: string, rules: Rules = DEFAULT_RULES): boolean {
448 return p
449 .split(/[\\/]+/)
450 .slice(0, -1)
451 .some(seg => rules.sensitiveDirs.has(seg.toLowerCase()))
452}
453