danyuchn/pii-guard/tree/main/examples/claude-code-mod
이 mod 소개
이 디렉터리는 완전한 Claude Code 플러그인입니다. examples/claude-code-hookd/의 classic hooks와 같은 일을 합니다. 모델로 보내기 전에 개인정보를 자리표시자로 바꾸고, 다시 쓸 때 복원하며, 내용을 이 컴퓨터 밖으로 가져갈 명령을 차단합니다. 다만 Claude Mods(function hooks)로 구현되어 classic hooks가 얻을 수 없는 가로채기 지점을 사용할 수 있습니다. 판단 규칙과 데이터는 계속 상주 서비스 pii-guard-hookd에 있습니다. 이 플러그인은 엔진 이벤트 값을 서비스로 전달하고 서비스의 응답을 되돌릴 뿐, 직접 탐지하지는 않습니다.
설치
먼저 작성자의 README에서 marketplace와 플러그인 이름을 확인하세요. 저장소 구조에 따라 명령어가 달라질 수 있습니다.
claude plugin marketplace add danyuchn/pii-guard claude plugin install pii-guard
원문 / 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。
