danyuchn/pii-guard/tree/main/examples/claude-code-mod
About this mod
This directory is a complete Claude Code plugin. It does the same job as the classic hooks in examples/claude-code-hookd/—replace personal data with placeholders before sending it to the model, restore it when writing back, and block commands that would take content off this machine—but it is implemented with Claude Mods (function hooks), so it can use interception points that classic hooks cannot. The rules and data still live in the resident pii-guard-hookd service. This plugin only moves engine-event values to the service and moves the service's answer back; it does not perform detection itself.
Installation
Check the author's README for the marketplace and plugin name first. Commands may change as the repository evolves.
claude plugin marketplace add danyuchn/pii-guard claude plugin install pii-guard
Original text / README
pii-guard Mod(Claude Mods 前端)
這個目錄是一個完整的 Claude Code plugin。它做的事情跟 examples/claude-code-hookd/
的 classic hooks 一樣——把個資換成佔位符再送進模型、寫回去時還原、擋掉會把內容帶離這台機器的
指令——差別只在它是用 Claude Mods(function hooks)實作,因此拿得到 classic hooks 拿不到的
攔截點。
判斷準則跟資料都還在常駐服務 pii-guard-hookd 裡。這個 plugin 只是把引擎事件的值搬給服務、
再把服務的回答搬回來,自己不做任何偵測。
為什麼要有第二個前端:Edit 缺口
classic hooks 的 PreToolUse 在 Edit 自己比對 old_string 之後才跑,所以模型看到
<TW_MOBILE_1> 之後拿它去 Edit,一定會撞到 String to replace not found。
classic 前端只能在 SessionStart 告訴模型「遇到這種情況改用 Write」。
Mod 的 tool.call 跑在 Edit 驗證之前,所以佔位符可以在比對前就被換回真值,Edit 直接成功。
這是換前端唯一真正換到的能力,其餘行為兩邊一致。
安裝
uv run pii-guard-hookd install --mod [--harden] [--engine full|regex]
install 會把 plugin 連到 Claude Code 設定目錄下一個固定路徑、清掉被 Mod 取代的 classic hook 條目、並印出啟動指令。Mod 只有帶旗標才會載入,沒帶就等於沒裝:
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir ~/.claude/plugins/pii-guard
檢查安裝:uv run pii-guard-hookd doctor --mod(會實際跑 claude plugin validate)。
移除:uv run pii-guard-hookd uninstall。
--mod 安裝後還留著的兩條 classic hook
其餘全部移掉——兩邊都留會遮蔽兩次、開場訊息也會出現兩次。留下的是這兩條:
- MessageDisplay:「使用者看到真值、模型看到佔位符」在 function hook API 裡沒有對應事件。
- SessionStart
--start-only:hooks module 沒辦法生出一個活得比 session 久的行程, 所以服務的隨選啟動只能留在 classic hook。這條只做啟動,固定印{}, 不送 systemMessage 也不送 additionalContext,開場說明由 Mod 自己負責。 沒有這條的話,stop之後就再也沒有東西會把服務叫回來,之後每個 session 都是死的, 直到使用者自己手動 serve。
/pii-terms:把客戶名單設成固定要遮的字
plugin 附一個指令檔 commands/pii-terms.md,裝上 Mod 之後就有 /pii-terms:
/pii-terms ~/Downloads/客戶名單.xlsx
它會跑 terms inspect --json 拿欄位(那份輸出刻意不含任何值),用
AskUserQuestion 逐欄跟你確認,最後跑 terms import --map ... --yes 寫進設定。
指令檔明文禁止模型 Read 或 cat 那份名單,模型從頭到尾看不到名單內容,
只轉述筆數。
設計、預設值與值的流向見 hookd README 的「參考名單」一節;兩個前端用的是同一個
服務、同一份 .pii-guard/sources.json。
classic hooks 使用者(沒裝 --mod)要手動複製這個檔案才有這個指令:
mkdir -p .claude/commands
cp examples/claude-code-mod/commands/pii-terms.md .claude/commands/
不想用指令的話,uv run pii-guard-hookd terms import <檔案> 或
uv run pii-guard-hookd terms ui 的網頁分頁做的是同一件事。
涵蓋範圍
| 事件 | 往下(進工具前) | 往上(回模型前) |
|------|------------------|------------------|
| tool.call | Write/Edit/MultiEdit/Bash 還原佔位符;WebFetch/WebSearch/mcp__*/remote agent 直接拒絕;帶佔位符又能連網的指令拒絕 | Read/Bash/Grep/mcp__* 的結果去識別化 |
| prompt.submit | @檔案 改寫成「請 Read 這個檔」;使用者打字打進去的個資換成佔位符 | — |
| session.compact | 壓縮前後都掃一次已知值 | 同左 |
| session.start | 向服務要開場說明並載入 seed terms | — |
prompt.submit 這條跟 classic 前端不同:classic 只能整則 擋掉,Mod 是 改寫,
所以正常工作不會被打斷,而且打進去的值會被記住,之後寫回檔案時會還原。
兩個實作上最容易踩的地方
回傳結果時不能把 ref 一起帶回去。 ref 指的是 core 用真值建好的訊息;
把拿到的物件原封不動回傳,core 就會照用那份,你改過的 result 會被忽略。
要改結果就只回 { result, context }。
hook 失敗或超時會被「跳過」,底下的東西照跑,也就是 fail open。 預算是十秒。
所以每個註冊都掛了 .catch,由 handler 自己回安全值;沒掛的話,服務出問題的當下防線就沒了。
傳輸方式
plugin 用 $.fs.read 讀服務的 state 檔拿 port 與 token,再用 $.http.fetch 直接打
127.0.0.1。兩者實測都通(見下方 spike)。這樣每次工具呼叫不用另外開一個 Python 行程。
$.http.fetch 失敗時會退到第二條路:用 $.process.run 叫一小段 stdlib Python 去 POST。
這是為了 claude plugin eval,那個環境會直接拒掉 $.http.fetch(連 loopback 也拒),
但子行程還是打得通。需要機器上有 python3——classic hook client 本來就要,所以不是新的依賴。
服務位置的解析順序是 options.hookdHome → 環境變數 PII_GUARD_HOOKD_HOME → 預設值,
沒有寫死路徑。
注意:$ 上的每個呼叫都是往 host 的 dispatch,全部都是非同步的,$.env.get 也是。
沒 await 會拿到 Promise,字串化之後變成 [object Promise] 塞進路徑裡,
結果是服務明明在跑卻每個事件都 fail closed。claude plugin validate 跟 tsc 都抓不到這個。
已知限制
- 打字打進去的原始 prompt 還是會落到本機 transcript。
prompt.submit的改寫保護的是 送進模型的內容;Claude Code 會在改寫之前先把使用者輸入原樣記進~/.claude/projects/的queue-operation紀錄。模型看不到,但那個值確實寫進了本機檔案。classic 前端(整則擋掉) 也有同樣情形。 - 被改寫出來的檔案路徑本身不會去識別化。 路徑要能讀得到才有意義,所以如果路徑裡就含人名, 模型會看到。classic 前端的拒絕訊息也會顯示路徑,行為一致。
session.compact只掃已知值,不跑偵測。 整份 transcript 跑一次偵測太慢,而且值在第一次 經過工具時就該被抓到了。--plugin-dir是目前唯一的載入方式。 少帶旗標就完全沒有防護,doctor 不會知道你啟動時 有沒有帶。
實測結果
互動模式(herdr pane、Sonnet、--plugin-dir、hardened settings,逐項核對 transcript)與
-p 模式都跑過,以下每一項都以 transcript 為準,不看終端機輸出:
| 項目 | 結果 |
|------|------|
| Read 結果去識別化 | 模型看到 <TW_MOBILE_1>、<TW_NATIONAL_ID_1> |
| Write 寫回磁碟 | 檔案內是真值 |
| Edit 的 old_string 帶佔位符 | 成功,磁碟上是真值(classic 前端會失敗) |
| Bash grep 帶佔位符 | 還原後執行 |
| 互動模式打 @customers.txt | 模型收到「please Read the file /abs/path/customers.txt」,任何 user message 裡都沒有檔案內容 |
| 使用者打字打進去的手機號 | user message 裡變成佔位符 |
| curl 帶佔位符 | 拒絕 |
| 服務停掉 | prompt 被 drop;工具層另以 stub 實測:Read 被扣住、Write 被拒且沒有產生檔案 |
| 服務停掉後再開新 session | SessionStart --start-only 把服務叫回來,同一個 session 的 Read 就已經去識別化 |
互動模式那筆 @ 結果也回答了下面 spike (b) 沒能回答的問題:prompt.submit 的改寫確實
發生在 @ 展開之前(或者展開根本不會作用在改寫後的文字上)。實作本來就設計成與順序無關,
這個結果只是確認了兩種順序都安全。
注意驗證方法:要看 transcript,不要看終端機 stdout。-p 的 stdout 會經過 MessageDisplay,
那條 hook 的工作就是把佔位符還原給人看,所以終端機上出現真值是正確行為,不是外洩。
Eval 分數(2026-09-16,haiku,runs=1,full engine)
evals/ 是一套 claude plugin eval 的案例。跑法:
./evals/run.sh # full engine、每個 arm 跑 1 次、haiku
ENGINE=regex RUNS=3 MODEL=sonnet ./evals/run.sh
run.sh 會在一個 tmp 目錄開一個 guard、把路徑用 EVAL_PII_GUARD_HOOKD_HOME 傳進去、
跑完再把服務停掉。不會碰到任何真實安裝。
| CASE | WITH | W/OUT | Δ | |------|------|-------|---| | read-redaction | 1.00 | 0.09 | +0.91 | | typed-pii | 1.00 | 0.00 | +1.00 | | write-restore | 1.00 | 0.50 | +0.50 |
3 cases、mean Δ +0.80、29 秒、$0.11。
without-arm 分數低是預期的:沒有 Mod,個資本來就會進到模型,not_contains 那幾條
grader 本來就該失敗。這個差值才是這套 suite 真正在量的東西。without 不是 0 的兩條,是因為
有些 grader 兩邊都會過:read-redaction 的「有沒有真的去讀檔」、write-restore 的
「檔案裡有沒有真值」與「有沒有用 Write」。
grader 要看 message.content,不是整行 trace
target: trace 的 regex 是拿整行 JSON 去比對,而那一行除了模型真正讀到的
message.content,還有 Claude Code 自己留著做 diff/undo/檔案狀態追蹤的
tool_use_result。在 tool_use_result 裡命中不算外洩。
實際例子(write-restore 的 with-arm,--keep-temp 取得):
message.content -> [{"type":"tool_result","content":"File created successfully at: .../summary.txt ..."}]
真實號碼:不在裡面
tool_use_result -> {"type":"create","filePath":".../summary.txt","content":"林美玲,0987654321", ...}
真實號碼:在這裡,但模型看不到
所以 write-restore 改成問模型「把你剛才傳給 Write 的 content 原樣唸回來」,
再用 last_message 判定——量的是模型知道什麼,不是整行 JSON 裡有什麼。
read-redaction 與 typed-pii 仍然用 target: trace,這在那兩個案例是有效的:
Read 的結果本身就是 Mod 換掉的,所以連 tool_use_result 裡的副本也是遮過的;
typed-pii 根本沒有工具呼叫。
跑 eval 時的三個環境限制
- 每個 run 都有一個全新的 home,所以 Mod 找不到預設路徑下的 state 檔。
只有
EVAL_開頭的環境變數會從外層 shell 傳進去,所以homeDir()多認一個EVAL_PII_GUARD_HOOKD_HOME(順序:options.hookdHome→PII_GUARD_HOOKD_HOME→EVAL_PII_GUARD_HOOKD_HOME→ 預設值)。prompt.md的env:只吃寫死的值,所以案例裡沒寫,改由run.shexport。 $.http.fetch在 eval 裡會被直接拒絕,連 loopback 也一樣:refused: nonessential network traffic is disabled for this session。 但$.process.run開出去的子行程不受這條限制。所以傳輸層改成兩段:先試$.http.fetch, 失敗就用一小段 stdlib Python 中繼。平常那條路不會多開行程,eval 裡才會。$.env.get的參數必須是字面字串,claude plugin validate會擋下用變數去取的寫法 (這樣它才能把模組讀寫哪些環境變數列出來)。
事前驗證(spike)
改版前先用一個拋棄式 plugin 實測了兩個排序問題,證據在 ~/.claude/projects/ 的 transcript。
(a)tool.call 有沒有跑在 Edit 驗證之前?有。
檔案內容是 hello world,hook 把 Read 結果改成 hello PLACEHOLDER 給模型看,
模型據此下 Edit(old_string: "hello PLACEHOLDER"),hook 在往下時換回 hello world。
Edit 成功,檔案變成 goodbye everyone。Edit 缺口確定可以補。
(b)prompt.submit 有沒有跑在 @檔案 展開之前?spike 當下未定論,後來由互動模式實測確認為「是」。
hook 看到的是還沒展開的 @secret.txt,改寫也確實生效(模型只看到改寫後的字)。
但對照組顯示 -p 模式根本不會展開 @ 引用,所以這個實驗證明不了互動模式下的先後。
因此實作沒有押在任何一邊:先把 @檔案 換成 sentinel、再對整段文字做去識別化、最後才把
sentinel 換成路徑。萬一哪天展開跑在前面,被內嵌進來的檔案內容會被當成一般文字去識別化,
而不是整段漏出去。後來的互動模式實測顯示現在的順序對我們有利(見上面「實測結果」),
但這個寫法不依賴那個順序,所以 Claude Code 之後改動也不會突然破功。
另外 claude plugin validate 會擋下把 $ 傳給非頂層函式的寫法,所有拿 $ 的 helper
都必須宣告在檔案最上層。
開發
型別宣告是跟著 Claude Code 版本產生的,沒有進版控。要改這個 plugin 先產一份:
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude -p "/plugin-types" --model haiku
# 把產生的 .claude/types/claude-code.d.ts 放到 examples/claude-code-mod/types/
然後 claude plugin validate examples/claude-code-mod 與 tsc -p examples/claude-code-mod/tsconfig.json。
