個資去識別化:把手機、身分證、Email、地址等換成代號再送給模型,對照表只留在本機;/pii 開關與查看

我為自己的開發流程寫的四個 Claude Code mod。Mod 是跑在 Claude Code 裡面的 TypeScript 事件處理函式,可以攔截工具呼叫、改寫 prompt,或在介面上畫出自己的區塊。
| Mod | 做什麼 | 指令 |
|---|---|---|
| ding-dong | 回合跑超過一分鐘才結束時,發一則系統通知叫你回來 | /ding 預覽通知 |
| ship-it | 依序執行專案的階段(檢查、測試、打包、部署),在 prompt 上方的深色面板用像素吉祥物顯示進度 | /ship、/ship demo、/ship init |
| secret-guard | 遮蔽 prompt 與工具輸出裡的金鑰,擋下讀寫 .env 與私鑰 | /guard、/guard on、/guard off |
| pii-mask | 把個資換成代號再送給模型,對照表只留在本機,之後可以還原 | /pii、/pii on、/pii add、/pii restore |
ding-dong 的系統通知只支援 macOS,其他平台會改用 Claude Code 內的 toast把這個 repo 加成 marketplace,再安裝想要的 mod:
claude plugin marketplace add builtbyjia/claude-code-mods
claude plugin install ship-it@builtbyjia-mods
只想試用一次的話,clone 下來後用 --plugin-dir 載入,只在那個 session 生效:
claude --plugin-dir ./ship-it
Mod 會用你的權限執行,可以讀寫檔案、執行指令。安裝任何人的 mod 之前(包括這裡的),建議先用
claude plugin validate <資料夾>看它掛了哪些事件、呼叫了哪些 API,並讀過原始碼。
回合依時長分成四種語氣(一到三分鐘、三到十分鐘、十分鐘以上、出錯),每種隨機抽一句文案,副標題顯示專案資料夾、耗時與工具次數。你自己中斷的回合不會通知。
通知預設由系統的 osascript 發出。想讓通知圖示變成橘色像素吉祥物,執行一次:
./ding-dong/notifier/build.sh
它會在本機編譯出 DingDong.app,之後通知就由這個小程式發出。文案與門檻秒數都在 ding-dong/hooks/register.ts 最上方。
/ship 會在 prompt 正上方畫出一塊深色的橫式面板,吉祥物沿著軌道走向正在執行的階段,每個階段顯示耗時;失敗時流程停下,並列出最後幾行錯誤輸出。面板右上角的「關」可以收起來,再輸入一次 /ship 就會重新出現。
「現在是哪個階段」不是偵測出來的:mod 自己依序執行每個階段的指令,正在跑的那個指令就是目前階段,指令結束時的結束碼是 0 才會往下一個走。吉祥物在兩個節點之間的位置只是依時間估算的動畫,不代表真實進度。
階段的來源依序是:
.claude/ship.jsonpackage.json 裡的 lint、test、build、deploy 這四個 script用 /ship init 可以產生設定檔,格式如下:
{
"stages": [
{ "name": "跑測試", "run": "npm test" },
{ "name": "打包", "run": "npm run build" },
{ "name": "部署", "run": "npm run deploy" }
]
}
run 會交給 bash -c 執行,所以只在你信任的專案裡使用 /ship。/ship demo 用 sleep 模擬五個階段,不會動到專案。
三層防護:
[已遮蔽:GitHub token] 這類標記。.env、私鑰、.npmrc 等檔案,以及 cat .env、printenv 這類指令;.env.example 這類範本檔放行。已知限制:
預設關閉。用 /pii on 開啟後,prompt 和工具輸出裡的個資會先換成 <PERSON_1>、<TW_MOBILE_1> 這類代號才送給模型;同一個原文永遠對應同一個代號。對照表存在本機,只會畫在 /pii 打開的面板上,不會進入對話。
模型產出的檔案裡如果有代號,用 /pii restore 檔案路徑 換回原文,結果另存成 .restored 檔,原檔不動。
能辨識的類型:
| 類型 | 辨識方式 |
|---|---|
| 手機、市話、Email | 格式比對 |
| 身分證、居留證、信用卡 | 格式比對加檢查碼 |
| 統一編號 | 前面寫明「統編」或「統一編號」的八位數字 |
| 地址 | 正式寫法(縣市、鄉鎮市區、路街) |
| 公司 | 以「有限公司」「企業社」「商行」「事務所」結尾 |
| 人名 | 「姓名:」「客戶:」「聯絡人:」這類欄位後面的名字,或用 /pii add 加進名單的詞 |
已知限制:
/pii 檢查對照表是否抓齊。每個 mod 是一個獨立的 plugin 資料夾:
<mod>/
├── .claude-plugin/plugin.json
├── hooks/
│ ├── hooks.json
│ └── register.ts
└── tests/
檢查 Claude Code 會從 mod 讀到什麼:
claude plugin validate ./secret-guard
在 mod 的資料夾裡執行測試,不需要開 session:
claude plugin test
用 --plugin-dir 載入時,存檔就會自動重新載入。
通知圖示與 ship-it 裡的像素小生物是我自己畫的,靈感來自 Claude 的橘色吉祥物,不是 Anthropic 的官方素材。這個專案與 Anthropic 沒有從屬關係。
以 MIT 授權釋出,詳見 LICENSE。
hooks/register.ts 413 lines1import type { EngineInterface, Register } from 'claude-code'
2
3const PANE = 'pii-mask'
4const STORE_KEY = 'state'
5
6type Block = { type: string; [field: string]: unknown }
7type Masked = { text: string; kinds: string[] }
8type Saved = { isOn: boolean; names: string[]; map: Record<string, string> }
9type Rule = {
10 kind: string
11 pattern: RegExp
12 // 有標籤的規則:第一個括號是要保留的標籤,第二個括號才是要遮的值
13 isLabeled?: true
14 accept?: (value: string) => boolean
15}
16
17// 身分證與居留證的英文字母對應碼:A=10、B=11 …,順序是內政部的編碼表
18const LETTER_CODES = 'ABCDEFGHJKLMNPQRSTUVXYWZIO'
19const ID_WEIGHTS = [1, 9, 8, 7, 6, 5, 4, 3, 2, 1, 1]
20
21function isTaiwanId(value: string): boolean {
22 const first = LETTER_CODES.indexOf(value[0]) + 10
23 // 舊式居留證第二碼是英文字母,取對應碼的個位數
24 const second = /\d/.test(value[1]) ? Number(value[1]) : (LETTER_CODES.indexOf(value[1]) + 10) % 10
25 const digits = [Math.floor(first / 10), first % 10, second, ...[...value.slice(2)].map(Number)]
26
27 return digits.reduce((sum, digit, index) => sum + digit * ID_WEIGHTS[index], 0) % 10 === 0
28}
29
30function isLuhn(value: string): boolean {
31 const digits = [...value.replace(/\D/g, '')].map(Number).reverse()
32 const sum = digits.reduce((total, digit, index) => {
33 const doubled = index % 2 === 1 ? digit * 2 : digit
34
35 return total + (doubled > 9 ? doubled - 9 : doubled)
36 }, 0)
37
38 return sum % 10 === 0
39}
40
41// 正式寫法的臺灣地址:縣市、鄉鎮市區、路街,後面的段巷弄號樓有幾項算幾項
42const ADDRESS =
43 /(?:[台臺]灣)?[一-鿿]{2}[縣市][一-鿿]{1,3}[鄉鎮市區][一-鿿]{1,6}(?:路|街|大道)(?:[一二三四五六七八九十\d]+段)?(?:\d+巷)?(?:\d+弄)?(?:\d+(?:之\d+)?號)?(?:\d+樓(?:之\d+)?)?/g
44
45// 依序套用。有檢查碼的先驗證,沒通過就不遮,避免把一般數字誤判成個資
46const RULES: readonly Rule[] = [
47 {
48 kind: 'EMAIL_ADDRESS',
49 pattern: /[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+/g,
50 // git@github.com 這類是系統帳號,不是個人信箱
51 accept: value => !/^(git|noreply|no-reply)@/i.test(value),
52 },
53 { kind: 'CREDIT_CARD', pattern: /(?<![\d-])\d(?:[ -]?\d){12,18}(?![\d-])/g, accept: isLuhn },
54 { kind: 'TW_NATIONAL_ID', pattern: /(?<![A-Za-z0-9])[A-Z][12]\d{8}(?!\d)/g, accept: isTaiwanId },
55 { kind: 'TW_ARC', pattern: /(?<![A-Za-z0-9])[A-Z][89A-D]\d{8}(?!\d)/g, accept: isTaiwanId },
56 { kind: 'TW_MOBILE', pattern: /(?<!\d)(?:\+886[- ]?9|09)\d{2}[- ]?\d{3}[- ]?\d{3}(?!\d)/g },
57 { kind: 'TW_PHONE', pattern: /(?<!\d)\(?0\d{1,2}\)?[- ]\d{3,4}[- ]?\d{4}(?!\d)/g },
58 // 八位數字太常見,只有前面寫明「統編」才當成統一編號
59 { kind: 'TW_BUSINESS_ID', pattern: /((?:統一編號|統編)\s*[::]?\s*)(\d{8})(?!\d)/g, isLabeled: true },
60 { kind: 'LOCATION', pattern: ADDRESS },
61 { kind: 'ORG', pattern: /[一-鿿A-Za-z0-9]{2,10}(?:股份有限公司|有限公司|企業社|商行|事務所)/g },
62 // 人名沒有固定格式,只認得欄位標籤後面的那一個
63 {
64 kind: 'PERSON',
65 pattern:
66 /((?:姓名|客戶|聯絡人|收件人|寄件人|負責人|申請人|承辦人)\s*[::]\s*)([一-鿿]{2,4}?)(?=先生|小姐|女士|經理|[^一-鿿]|$)/gm,
67 isLabeled: true,
68 },
69]
70
71// 送給模型的內容從這些 door 進來;模型自己的回覆和你打的 prompt(另外處理)不在這裡
72const MASKED_DOORS = new Set(['tool-result', 'tool-message', 'attachment', 'delivery', 'command', 'hook-context'])
73
74const HELP = [
75 '/pii:打開對照表',
76 '/pii on、/pii off:開關遮蔽(會記住,跨 session 有效)',
77 '/pii add 王小明:把詞加進名單,之後出現就會遮蔽',
78 '/pii remove 王小明:從名單移除',
79 '/pii restore 檔案路徑:把檔案裡的代號換回原文,另存成 .restored 檔',
80 '/pii forget:清空對照表(之前的代號就無法還原了)',
81].join('\n')
82
83let isOn = false
84let names: string[] = []
85// 代號 → 原文。只存在本機,不會出現在送給模型的任何內容裡
86let map: Record<string, string> = {}
87const codeByValue = new Map<string, string>()
88const counters = new Map<string, number>()
89
90function adopt(saved: Saved): void {
91 isOn = saved.isOn
92 names = [...saved.names]
93 map = { ...saved.map }
94 codeByValue.clear()
95 counters.clear()
96
97 for (const [code, value] of Object.entries(map)) {
98 const parts = /^<([A-Z_]+)_(\d+)>$/.exec(code)
99 codeByValue.set(value, code)
100
101 if (parts !== null) {
102 counters.set(parts[1], Math.max(counters.get(parts[1]) ?? 0, Number(parts[2])))
103 }
104 }
105}
106
107function isSaved(value: unknown): value is Saved {
108 const saved = value as Saved | null
109
110 return (
111 typeof saved?.isOn === 'boolean' &&
112 Array.isArray(saved.names) &&
113 typeof saved.map === 'object' &&
114 saved.map !== null
115 )
116}
117
118// 同一個原文永遠拿到同一個代號,模型才看得出「這兩處是同一個人」
119function codeFor(kind: string, value: string): string {
120 const known = codeByValue.get(value)
121
122 if (known !== undefined) {
123 return known
124 }
125
126 const next = (counters.get(kind) ?? 0) + 1
127 const code = `<${kind}_${next}>`
128 counters.set(kind, next)
129 map[code] = value
130 codeByValue.set(value, code)
131
132 return code
133}
134
135function maskText(text: string): Masked {
136 const kinds: string[] = []
137 const swap = (kind: string, value: string): string => {
138 kinds.push(kind)
139
140 return codeFor(kind, value)
141 }
142 let out = text
143
144 // 名單先處理,長的詞優先,避免「王小明」被「小明」拆開
145 for (const name of [...names].sort((a, b) => b.length - a.length)) {
146 out = out.replaceAll(name, () => swap('NAME', name))
147 }
148
149 for (const rule of RULES) {
150 out = out.replace(rule.pattern, (whole: string, label: string, labeled: string) => {
151 const value = rule.isLabeled ? labeled : whole
152
153 if (rule.accept !== undefined && !rule.accept(value)) {
154 return whole
155 }
156
157 return rule.isLabeled ? label + swap(rule.kind, value) : swap(rule.kind, value)
158 })
159 }
160
161 return { text: out, kinds }
162}
163
164function maskBlocks(blocks: readonly Block[]): { blocks: Block[]; kinds: string[] } {
165 const kinds: string[] = []
166 const scrub = (text: string): string => {
167 const result = maskText(text)
168 kinds.push(...result.kinds)
169
170 return result.text
171 }
172 const scrubBlock = (block: Block): Block =>
173 block.type === 'text' && typeof block.text === 'string' ? { ...block, text: scrub(block.text) } : block
174
175 const out = blocks.map(block => {
176 if (block.type !== 'tool_result') {
177 return scrubBlock(block)
178 }
179
180 if (typeof block.content === 'string') {
181 return { ...block, content: scrub(block.content) }
182 }
183
184 return Array.isArray(block.content) ? { ...block, content: (block.content as Block[]).map(scrubBlock) } : block
185 })
186
187 return { blocks: out, kinds }
188}
189
190function restoreText(text: string): { text: string; restored: number; unknown: number } {
191 let restored = 0
192 let unknown = 0
193 const out = text.replace(/<[A-Z_]+_\d+>/g, code => {
194 const value = map[code]
195
196 if (value === undefined) {
197 unknown += 1
198
199 return code
200 }
201
202 restored += 1
203
204 return value
205 })
206
207 return { text: out, restored, unknown }
208}
209
210// report.md → report.restored.md;沒有副檔名就直接加在後面
211function restoredPath(path: string): string {
212 const slash = path.lastIndexOf('/')
213 const dot = path.lastIndexOf('.')
214
215 return dot > slash + 1 ? `${path.slice(0, dot)}.restored${path.slice(dot)}` : `${path}.restored`
216}
217
218async function save($: EngineInterface): Promise<void> {
219 const saved: Saved = { isOn, names, map }
220 await $.store.set(STORE_KEY, saved)
221 $.ui.status(isOn ? 'PII 遮蔽中' : undefined)
222 $.ui.invalidate('ui.render')
223}
224
225async function announce($: EngineInterface, where: string, kinds: readonly string[]): Promise<void> {
226 await save($)
227 $.ui.toast(`已把${where}裡的 ${kinds.length} 處個資換成代號`)
228}
229
230async function restoreFile($: EngineInterface, path: string): Promise<string> {
231 if (path === '') {
232 return '請給檔案路徑,例如 /pii restore 回信.md'
233 }
234
235 if (!(await $.fs.exists(path))) {
236 return `找不到 ${path}`
237 }
238
239 const result = restoreText(await $.fs.read(path))
240
241 if (result.restored === 0) {
242 return `${path} 裡沒有可還原的代號。`
243 }
244
245 const target = restoredPath(path)
246 await $.fs.write(target, result.text)
247 const leftover = result.unknown > 0 ? `,另有 ${result.unknown} 個代號不在對照表裡,保持原樣` : ''
248
249 return `已還原 ${result.restored} 處,另存為 ${target}${leftover}。原檔沒有更動。`
250}
251
252// 這段文字會進入對話、被模型讀到,所以只能放代號的數量,不能放原文
253function summary(): string {
254 return [
255 `pii-mask 目前${isOn ? '開啟' : '關閉'}`,
256 `對照表:${Object.keys(map).length} 筆,名單:${names.length} 個詞(內容只顯示在 /pii 的面板裡)`,
257 ].join('\n')
258}
259
260async function run($: EngineInterface, args: string): Promise<string> {
261 const [action = '', ...rest] = args.trim().split(/\s+/)
262 const word = rest.join(' ')
263
264 if (action === '') {
265 await $.ui.open({ id: PANE, title: '個資對照表', rows: 14 })
266
267 return summary()
268 }
269
270 if (action === 'on' || action === 'off') {
271 isOn = action === 'on'
272 await save($)
273
274 return summary()
275 }
276
277 if (action === 'add' && word !== '') {
278 names = [...new Set([...names, word])]
279 await save($)
280
281 return `已加入名單,目前共 ${names.length} 個詞。`
282 }
283
284 if (action === 'remove' && word !== '') {
285 names = names.filter(name => name !== word)
286 await save($)
287
288 return `已從名單移除,目前共 ${names.length} 個詞。`
289 }
290
291 if (action === 'restore') {
292 return restoreFile($, word)
293 }
294
295 if (action === 'forget') {
296 adopt({ isOn, names, map: {} })
297 await save($)
298
299 return '對照表已清空。之前產生的代號無法再還原。'
300 }
301
302 return HELP
303}
304
305export const register: Register = on => {
306 on('session.start', async ($, e, next) => {
307 const saved = await $.store.get(STORE_KEY)
308
309 if (isSaved(saved)) {
310 adopt(saved)
311 }
312
313 await $.command.register({
314 name: 'pii',
315 description: '個資去識別化的開關與對照表(/pii help 看全部用法)',
316 })
317 $.ui.status(isOn ? 'PII 遮蔽中' : undefined)
318
319 return next(e)
320 })
321
322 on('command.run', { command: 'pii' }, async ($, e) => ({ text: await run($, e.args) }))
323
324 // 你打的或貼上的 prompt
325 on('prompt.submit', async ($, e, next) => {
326 const result = isOn ? maskText(e.text) : { text: e.text, kinds: [] }
327
328 if (result.kinds.length === 0) {
329 return next(e)
330 }
331
332 await announce($, ' prompt ', result.kinds)
333
334 return next({ ...e, text: result.text })
335 })
336
337 // 工具結果等內容,在存進對話、送給模型之前處理。
338 // 指令紀錄(command)不管開關都處理,否則 /pii add 王小明 這一行本身就會把名字送出去
339 on('session.append', async ($, e, next) => {
340 if (!MASKED_DOORS.has(e.door) || (!isOn && e.door !== 'command')) {
341 return next(e)
342 }
343
344 const result = maskBlocks(e.message.content)
345
346 if (result.kinds.length === 0) {
347 return next(e)
348 }
349
350 await announce($, '工具輸出', result.kinds)
351
352 return next({ ...e, message: { ...e.message, content: result.blocks } })
353 })
354
355 // 對照表只畫在本機的面板上,不會進入對話
356 on('ui.render', { component: 'Pane', requestId: PANE }, ($, e) => {
357 const { Box, Button, Text } = $.ui.resolve(e)
358 const entries = Object.entries(map)
359 const width = Math.max(20, e.props.bodyColumns - 2)
360
361 return Box({
362 flexDirection: 'column',
363 paddingX: 1,
364 children: [
365 Text({ key: 'state', bold: true, color: isOn ? 'green' : undefined, children: isOn ? '遮蔽中' : '已關閉' }),
366 Text({
367 key: 'names',
368 dimColor: true,
369 wrap: 'truncate-end',
370 children: names.length > 0 ? `名單:${names.join('、')}`.slice(0, width) : '名單:還沒有,用 /pii add 加入',
371 }),
372 ...(entries.length === 0
373 ? [Text({ key: 'empty', dimColor: true, children: '對照表是空的。' })]
374 : entries.map(([code, value]) =>
375 Box({
376 key: code,
377 flexDirection: 'row',
378 gap: 2,
379 children: [
380 Text({ key: 'code', color: 'cyan', children: code }),
381 Text({ key: 'value', wrap: 'truncate-end', children: value }),
382 ],
383 }),
384 )),
385 Box({
386 key: 'buttons',
387 flexDirection: 'row',
388 gap: 2,
389 marginTop: 1,
390 children: [
391 Button({
392 key: 'toggle',
393 label: isOn ? '關閉遮蔽' : '開啟遮蔽',
394 hotkey: 't',
395 onPress: () => {
396 void run($, isOn ? 'off' : 'on')
397 },
398 }),
399 Button({
400 key: 'close',
401 label: '關閉面板',
402 hotkey: 'c',
403 onPress: () => {
404 void $.ui.close({ id: PANE })
405 },
406 }),
407 ],
408 }),
409 ],
410 })
411 })
412}
413