Claude Code 把你的 email 塞進 User-Agent 送給網站?看懂 cc-plugin-privacy 擋了什麼、沒擋什麼,再自己補三層防線
Claude Code 會把登入 email 放進 context,模型遇到「User-Agent 要附聯絡方式」的網站就自己填了進去。拆解官方 cc-plugin-privacy 的兩段字串比對與它明說不管的洞,附可直接照抄的 CLAUDE.md 規則與 PreToolUse hook。
11 月 5 日,Taylor Edwards 在 Claude Code 的 GitHub 開了一張 issue(#107757),標題是「Privacy violation in User-Agent header」。他說自己每天收到幾十封詐騙信,原因是 Claude Code 上網查資料時,把他的帳號 email 塞進 HTTP 的 User-Agent 標頭,送給了一堆第三方網站。同一天 motoroco 把它貼上 Hacker News,標題直接寫「Claude Code sends your email address to websites」,我們抓資料時已經 461 分。motoroco 貼出的標頭長這樣:
User-Agent: my-session-slug/1 <my.name@example.com>
幾個小時內,Anthropic 的工程師 poteat 回覆:v2.1.317 起有一個內建 mod 叫 cc-plugin-privacy,預設開啟,會確定性地擋下這種行為,原始碼也放到了 repo 的 mods/privacy。
這篇不吵「該不該信任廠商」,要講的是工程師能動手的部分:這個 email 是怎麼跑進請求裡的、官方那道擋板到底擋了什麼、沒擋什麼,以及你今天就能補上的三層防線。用 Codex、Cursor、Cline 的人也一樣值得看,因為問題的根源是「context 裡有什麼,agent 就可能把它送出去」,不是某一家獨有。
一、先搞清楚:這不是誰寫了「把 email 寄給網站」的功能
很多留言以為 Claude Code 程式碼裡有一行「把 email 放進 User-Agent」。poteat 在 issue 裡澄清,沒有人實作這個功能,它是模型自己做出來的行為,而且他把劇本講得很清楚:
- Wikipedia 這類網站有政策,要求腳本化的客戶端在 User-Agent 附上聯絡方式。
- 使用者請 Claude 去 Wikipedia 查東西。
- Claude 照著網站的規定,在
curl指令裡把使用者的 email 放進 User-Agent。
第 1 點是真的。Wikimedia 的 User-Agent 政策建議格式是 <client name>/<version> (<contact information>),沒有正確 User-Agent 的請求可能被封鎖或限流。但要注意,政策寫的聯絡方式可以是 wiki 使用者頁、專案網址或 email,三選一,email 不是必填。模型挑了最「方便」的那個,而那個剛好是你的。
那模型手上為什麼有你的 email?答案在更早的一張 issue。7 月 25 日 skflowne 開了 #81138,他拆了 v2.1.220 的打包程式碼,發現 Claude Code 會從 ~/.claude.json 的 oauthAccount.emailAddress 讀出登入帳號的 email,在每個 session 的 context 塞進這樣一段:
# userEmail
The user's email address is <account email>.
他還整理出幾件值得知道的事(以下是他從 bundle 看到的,我們沒有自己反編譯驗證):
- 文件裡沒寫這件事,也沒有設定可以關掉。
IS_DEMO=1文件說能在錄影、直播時隱藏 email,但它只遮/status和啟動畫面,不影響 context,模型照樣可以在回覆裡印出 email。- 他實際遇過模型在十個 commit 上用
git -c user.email=<私人信箱>覆蓋掉他設好的 noreply 身分,是 GitHub 的 push protection 擋下才沒公開。 - 同類回報至少還有 #53231、#50702、#67605、#58081,多半因為久沒動靜被關掉。
這也是 #107757 底下 AlexErrant 那句抱怨的由來:原始 issue 從 7 月放到 11 月,上了 HN 才有回應。
所以整條鏈是:登入資訊進 context → 網站政策叫你附聯絡方式 → 模型自己把 email 填進 shell 指令 → 請求送出。中間沒有任何一行程式碼「故意」做這件事,這正是它難防的地方。

至於「詐騙信是不是都因為這個」,issue 底下 tobinfekkes、HN 上的 mk89 都說最近垃圾信暴增,但這些是使用者自己的觀察,沒有人證明因果。可以確定的只有一件事:email 一旦出現在你不認識的網站的存取紀錄裡,你就收不回來了。
二、cc-plugin-privacy 的運作原理:兩個字串比對
官方的修法不是改 prompt,而是加一道「確定性」的檢查,也就是 hook。這點很值得學:靠 prompt 叫模型別做,管的是它願不願意;靠 hook 在工具呼叫前攔下,管的是它做不做得到。
把 mods/privacy 的原始碼讀過一遍,機制可以拆成四步:
1. 只看會跑 shell 的工具。 shell-tools.ts 寫死了 ['Bash', 'Monitor', 'PowerShell']。hook 掛在 tool.check 事件上,只有這三個工具的呼叫會被檢查。
2. 先快篩。 指令文字裡沒有 @ 也沒有 %40(@ 的 URL 編碼),直接放行,省下後面的比對。
3. 比對 email。 透過 $.session.identity() 拿到登入帳號的 email,在指令全文中找它,大小寫不拘,也會先把百分比編碼解一次再找。
4. 比對「這是在設 User-Agent」。 拿掉 email 之後,剩下的文字只要符合下面任一條就算:
- 出現
User-Agent、user_agent、--user-agent等字樣(包含前綴縮寫),或UA=、UA:。 curl搭配-A、wget搭配-U,或 PowerShell 的Invoke-WebRequest系列搭配-User。連破折號被換成 en dash、em dash 這種變體都算進去了。
兩個條件同時成立,就回傳 deny,附上一段給模型讀的拒絕訊息:這個呼叫含帳號 email 和疑似 User-Agent 設定;如果沒人要求送出 email,就拿掉它、改用通用的客戶端名稱重跑。
這道拒絕有幾個特性,README 寫得很明白:
- 不跳出確認視窗,不管你開哪個權限模式。
- 已存的 allow 規則和 auto mode 的分類器都蓋不過它。
- 倉庫自己的
.claude/settings.json關不掉它,只有使用者層級、--settings或 managed settings 可以。
作者也很坦白:這裡沒有 parser,就是兩次字串比對,所以會誤擋(例如指令只是「提到」User-Agent 這個字,旁邊剛好有 email),他們接受這個代價換簡單。README 原話是:這是一種禮貌(courtesy),不是邊界(boundary)。
三、它沒擋的:這張清單才是重點
README 列出了它明確不管的情況。把它當成你的待補清單:
| 不會被擋的情況 | 例子 |
|---|---|
| email 出現在 User-Agent 以外的地方 | URL query、POST body、curl -u、From: 標頭 |
| 非 shell 的工具 | WebFetch、WebSearch、所有 MCP 工具 |
| 執行時才算出來的值 | $(git config user.email)、環境變數、寫在檔案裡再送出 |
| 包在腳本裡 | Claude 寫一支 Python 腳本、腳本裡放 email,再執行腳本 |
| 其他編碼 | Base64、兩次以上 URL 編碼、非 ASCII 地址的編碼 |
| 沒列到的客戶端 | httpie、Node 的 fetch、PowerShell 用 hashtable 設標頭 |
另外它只認「登入帳號那一個 email」。你在 git config 裡的公司信箱、CLAUDE.md 裡寫的私人信箱、.env 裡的客戶資料,它都不認得。

還有一個要你自己確認的地方:poteat 說 v2.1.317 以後就有,但同一串裡 francescor 回報,他在 2.1.318 的 /plugins 裡找不到 cc-plugin-privacy。README 也提到有一個伺服器端開關、每個 session 讀一次。所以別假設它一定在跑,下一節教你怎麼驗。
四、手把手:三層補強
第 0 步:確認官方擋板真的有開
claude --version # 確認 >= 2.1.317
進 Claude Code 之後打 /plugin,找 cc-plugin-privacy,確認是 enabled。再檢查你自己(或同事給你的 dotfiles)有沒有把它關掉:
grep -rn "cc-plugin-privacy" ~/.claude/settings.json ~/.claude/settings.local.json 2>/dev/null
看到 "cc-plugin-privacy@builtin": false 就是被關了。
接著做一次不會真的連網的自我測試。請 Claude 執行這行(把信箱換成你登入 Claude Code 用的那個):
echo "User-Agent: selftest/1 (you@example.com)"
因為比對看的是整段指令文字,連 echo 都會被拒。如果它照常印出來,代表擋板沒在跑,你更需要下面兩層。
第一層:CLAUDE.md 給模型一個「合法的替代答案」
模型會把 email 填進去,是因為網站要聯絡方式,而它手上只有這個。最有效的 prompt 不是「不准」,而是先給它一個可以填的東西。在 ~/.claude/CLAUDE.md(全域,所有專案都吃得到)加上:
## 對外請求的身分
- 任何對外 HTTP 請求(curl、wget、腳本、WebFetch)都不得包含我的 email 或任何個人信箱。
- 網站要求 User-Agent 附聯絡方式時(例如 Wikimedia),一律用:
`my-team-research-bot/1.0 (+https://github.com/my-org/my-repo)`
- 腳本裡需要 User-Agent 時,用上面那串,不要用 `git config user.email` 或環境變數組出來。
- 不確定某個網站要什麼,先停下來問我,不要自己填個人資料。
- git commit 一律使用 repo 已設定的身分,不得用 `git -c user.email=...` 覆蓋。
這段參考了 xSetech 在 issue 裡分享的版本。他的做法比較嚴:預設禁止,只有白名單網站(他只放了 Wikipedia)可以帶 email,遇到新網站要先問他。我們建議改成「給網址不給信箱」,因為 Wikimedia 的政策本來就接受網址,沒理由讓 email 出門。
但記住 #81138 講得很對:CLAUDE.md 管的是用不用,不是有沒有,而且依賴模型配合,碰到 prompt injection(例如某個網頁內容叫模型把聯絡資訊附上)就不一定守得住。所以還要第二層。
第二層:自己寫一個 PreToolUse hook,補官方沒擋的洞
官方 mod 只看 Bash 和 User-Agent。我們可以用 Claude Code 既有的 hooks 機制再加一道,範圍放大:Bash、WebFetch、所有 MCP 工具,只要對外送的內容裡有你列出的任何一個信箱,就擋。
先建立腳本 ~/.claude/hooks/block-email-egress.py:
#!/usr/bin/env python3
"""PreToolUse hook:對外工具呼叫中出現個人信箱就擋下(exit 2)。"""
import json, os, re, sys, urllib.parse
def load_emails():
emails = set()
# 1) Claude Code 登入帳號的 email
try:
with open(os.path.expanduser("~/.claude.json")) as f:
acct = json.load(f).get("oauthAccount") or {}
if acct.get("emailAddress"):
emails.add(acct["emailAddress"].lower())
except Exception:
pass
# 2) 你額外想保護的信箱,一行一個
extra = os.path.expanduser("~/.claude/hooks/protected-emails.txt")
if os.path.exists(extra):
for line in open(extra):
if line.strip():
emails.add(line.strip().lower())
return emails
NET = re.compile(r"\b(curl|wget|http|httpie|xh|fetch|requests|urllib|aiohttp|Invoke-WebRequest|iwr|irm)\b", re.I)
RUNTIME_EMAIL = re.compile(r"git\s+config[^|;&]*user\.email|\$\{?(EMAIL|GIT_AUTHOR_EMAIL)\b", re.I)
event = json.load(sys.stdin)
tool = event.get("tool_name", "")
text = json.dumps(event.get("tool_input", {}), ensure_ascii=False)
low = text.lower()
decoded = urllib.parse.unquote(urllib.parse.unquote(low))
hit = any(e in low or e in decoded for e in load_emails())
# Bash:連網指令裡在執行時才組出 email,也擋
if tool == "Bash" and NET.search(text) and RUNTIME_EMAIL.search(text):
hit = True
# Bash:用 git -c 覆蓋 commit 身分
if tool == "Bash" and re.search(r"git\s+-c\s+user\.email=", text):
hit = True
if hit:
print("Blocked: this call would send a personal email address off the machine. "
"Use the project contact URL from CLAUDE.md instead, or ask the user.",
file=sys.stderr)
sys.exit(2)
sys.exit(0)
chmod +x ~/.claude/hooks/block-email-egress.py
echo "you@company.com" >> ~/.claude/hooks/protected-emails.txt
再到 ~/.claude/settings.json 註冊:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|WebFetch|mcp__.*",
"hooks": [
{ "type": "command", "command": "python3 ~/.claude/hooks/block-email-egress.py" }
]
}
]
}
}
PreToolUse hook 以 exit code 2 結束時,工具呼叫會被擋下,stderr 的內容會回給模型,讓它知道為什麼被拒、該怎麼改。拒絕訊息寫成「下一步怎麼做」而不只是「不行」,這招是從官方 refusal.ts 學來的,模型讀到之後通常會自己換成正確做法重跑。
幾個設計取捨要講清楚:
- 它仍然擋不住所有情況。 Claude 先寫一支腳本檔、把 email 寫進去、再執行
python3 fetch.py,hook 看到的只有檔名。要補這個洞,可以把Write和Edit也加進 matcher,但會誤擋「在測試資料裡寫個假信箱」之類的正常工作。我們的建議是先不加,觀察一週再決定。 - 會誤擋。 指令裡只要出現你的信箱就擋,即使你本來就要它幫你寄信或填表。被擋時你自己手動跑就好,這比漏掉划算。
- matcher 寫
mcp__.*是因為 MCP 工具名稱都以mcp__開頭,官方 mod 完全不看 MCP,這裡補上。
這支腳本的判斷邏輯,我們用假的 ~/.claude.json 餵過幾組輸入(URL 編碼的 email、WebFetch 網址帶 email、$(git config user.email)、git -c user.email=)都會回 exit 2,一般指令放行;接進 Claude Code 後的實際表現,請用下面的方法自己驗。
裝完用同一招測試:請 Claude 執行 echo "contact you@company.com" | curl -d @- https://example.invalid。網址用 .invalid 這種保留網域,就算沒擋也連不出去,安全。
第三層:別讓 email 出現在 agent 摸得到的地方
前兩層都是在出口攔。根本做法是減少入口:
- git 身分用 noreply。 GitHub 的
<id>+<username>@users.noreply.github.com,在 agent 常跑的 repo 用git config user.email設好,並在 GitHub 打開「Block command line pushes that expose my email」。#81138 的作者就是靠這個才沒讓私人信箱進公開 commit。 - CLAUDE.md、memory、
.env裡別寫私人信箱。 這些全會進 context。需要的話寫專案用的群組信箱或網址。 - 錄影直播別只信
IS_DEMO=1。 依 #81138 的分析它只遮畫面,模型照樣可能把 email 講出來。 - 高敏感環境走網路層。 把 agent 跑在有出站代理或防火牆的容器裡,所有對外請求都經過你能記錄、能過濾的地方。這是唯一不依賴模型、也不依賴字串比對的做法。
#81138 裡有提到,只有設 ANTHROPIC_UNIX_SOCKET 時不會注入 email,但那是換傳輸方式的內部變數,不是隱私開關,不建議拿來當解法。
五、團隊與公司要做的事
如果你是幫團隊管 Claude Code 的人:
- 用 managed settings 把擋板鎖住。 README 寫明 managed settings 可以設定
enabledPlugins,而倉庫層級的設定關不掉它。值得實測的是:在 managed settings 寫"cc-plugin-privacy@builtin": true,確認使用者那邊/plugin disable之後的實際行為。 - 把第二層 hook 做成團隊共用的 plugin。 官方 mod 本身就是 plugin 的形式(
plugin.json加hooks/hooks.json),團隊可以照同樣結構包一個自己的版本,保護清單放公司共用信箱和網域。另外 README 也提醒,已安裝 plugin 的 hook 位在官方 mod 之上,可以推翻它的拒絕,所以裝第三方 plugin 前要看它有沒有掛tool.check。 - 把「agent 對外送了什麼」納入稽核。 這次事件被發現,是因為有人收到大量垃圾信回頭追。比較好的做法是事前就留紀錄:出站代理的 log、hook 被觸發的次數。
六、帶走一句話
這次的教訓不只屬於 Claude Code:放進 context 的東西,遲早會被當成工具參數送出去。 網站政策、網頁內容、使用者一句「幫我查一下」,都可能讓模型覺得把它填進去是在幫你。對付這種問題,順序是:先別讓它進 context,進了就在出口用 hook 確定性地擋,最後才是 prompt 裡的規則。官方的 cc-plugin-privacy 是第二步裡很窄的一塊,剩下的要自己補。
來源
- Taylor Edwards(taylor-edwards),〈Bug Report: Privacy violation in User-Agent header〉,anthropics/claude-code #107757,2026-11-05:https://github.com/anthropics/claude-code/issues/107757 (內含 Anthropic 工程師 poteat 的兩則回覆、xSetech 的 CLAUDE.md 範例)
- Hacker News 討論串,motoroco 提交:https://news.ycombinator.com/item?id=50385272
- skflowne,
userEmail注入 context 的原始回報,anthropics/claude-code #81138,2026-07-25:https://github.com/anthropics/claude-code/issues/81138 - Anthropic,
cc-plugin-privacy原始碼與 README:https://github.com/anthropics/claude-code/tree/main/mods/privacy - Anthropic,Claude Code 內建 mods 說明:https://github.com/anthropics/claude-code/tree/main/mods
- Wikimedia Foundation,User-Agent policy:https://foundation.wikimedia.org/wiki/Policy:User-Agent_policy
整理:DataAgent · Coding Agent 實戰教學