SLOPSHOPPER

phi-guard-tw

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

newguardtoaststatusprompt
v0.2.0MITupdated 2026-10-09liangRXdev/phi-guard-tw
A shopper browsing a rack in a slop shop
README

phi-guard-tw

English

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、日期、檔案大小),全部遮掉的話正常工作會壞。

設定(userConfig)

安裝時會詢問,之後可在 /config 修改。值存在你自己的 settings.json,不會進到任何 repo。

欄位預設說明
extraMrnLabels空貴院自己的病歷號欄名。當純文字比對,不吃 regex;空白或少於 2 個字元會被忽略
sensitiveDirs00_private、private、phi路徑裡出現這些目錄名稱時,擋下對圖片/PDF 的 Read。不分大小寫。設成空陣列等於關閉這道守門,session 開始時會跳 toast 提醒
exemptMaskExamples空寫入時不擋的遮罩字樣,例如規範文件裡的格式範例。值不像遮罩字樣(英數 2–3 碼+****+英數 2 碼)就會被忽略
readOnlyMcpToolsGoogle Sheets 的 get_values、get_spreadsheet輸入含遮罩字樣也放行的 MCP 工具全名(必須以 mcp__ 開頭)。設定會取代預設值。沒列出的 MCP 工具一律當成可能寫入

內建的病歷號標籤、健保卡號標籤和身分證規則不可關閉。

限制(不保證攔得到)

  • 中文姓名、地址、電話、生日:不處理,regex 的誤判和漏抓都太多。
  • 沒有標籤的病歷號:不處理。
  • Grep、head/tail 等片段輸出裡的表格欄:靠表頭判斷,片段沒有表頭就不會遮(身分證有檢核碼,不受影響)。含病歷號欄的表格請用 Read 讀整檔。
  • 帶分隔符的 ID(A123-456-789)、base64/URL 編碼內容:不處理。
  • 圖片、掃描 PDF 的像素:無法遮罩,只能靠 sensitiveDirs 擋讀取。其他位置的圖片會放行並跳 toast 提示。
  • 非文字的 document(PDF base64 等):無法掃描。文字型 document 一旦命中識別資料,就整份移除(依規格,document 不能改寫)。
  • engine 不認得的 block 類型(例如 MCP 的 embedded resource):engine 會原封放回,hook 無法處理。
  • 畫面與本機 transcript 的結構化紀錄:依 engine 規格,工具結果的結構化紀錄(畫面顯示的內容)會原樣存進本機 transcript。模型讀不到,但它會留在你的磁碟上。
  • --resume 載入的舊對話:不會經過遮罩。安裝本 mod 之前的對話若含識別資料,resume 後模型會讀到原文。
  • CSV 欄位內換行(引號包住的多行欄位):不支援。
  • 路徑含 ..:只要字面路徑含敏感目錄就擋,即使實際落點已離開該目錄。
  • Bash 以外的間接寫入:例如透過程式自己寫檔,不在守門範圍內。

開發

claude plugin validate .
claude plugin test .
  • 型別檢查:mod 被 engine 載入後,會在 .claude-plugin/types/ 產生型別與 tsconfig,之後執行 tsc -p .。
  • 測試全部使用合成資料:身分證是用檢核碼演算法產生的值,實機驗收用的合成檔在 tests/fixtures/synthetic.tsv。
  • 含遮罩字樣的原始碼:本 mod 會擋下內容含遮罩字樣的 Write/Edit,連它自己的測試檔也不例外。測試要用遮罩字樣時,請在執行時才組出字串(例如 '*'.repeat(4))。
  • 子代理涵蓋的實測方法:叫子代理用 Read 讀 fixture,再請它把身分證欄位逐字以空白分隔輸出。加了空白的字串不會被遮,所以能直接看出子代理讀到的是哪個版本。2026-10-09 實測結果:子代理讀到的是遮罩值。
  • 開發紀錄:.ai-review/ 收錄規格、Codex 獨立審查原文,以及逐項覆核判定。

授權

MIT。本工具不構成醫療、法律或資安建議,使用者須自行承擔使用風險。

Source 2 files
hooks/register.ts 109 lines
1import 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}
109
hooks/mask.ts 453 lines
1// 純函式:偵測與遮罩。不碰 $,方便單元測試。
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