ClaudeMods
☰
ZH-TW
● 0 人在線上 · 瀏覽 0 次
贊助提交作品
GitHub 儲存庫 · 發布者 danyuchn

pii-guard

pii-guard 外掛(Claude Mods 前端)

danyuchn@danyuchn

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 時的三個環境限制

  1. 每個 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.sh export。
  2. $.http.fetch 在 eval 裡會被直接拒絕,連 loopback 也一樣: refused: nonessential network traffic is disabled for this session。 但 $.process.run 開出去的子行程不受這條限制。所以傳輸層改成兩段:先試 $.http.fetch, 失敗就用一小段 stdlib Python 中繼。平常那條路不會多開行程,eval 裡才會。
  3. $.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。

更多類似作品