Claude Code 把你的 email 塞進 User-Agent 送給網站?看懂 cc-plugin-privacy 擋了什麼、沒擋什麼,再自己補三層防線

Claude Code 會把登入 email 放進 context,模型遇到「User-Agent 要附聯絡方式」的網站就自己填了進去。拆解官方 cc-plugin-privacy 的兩段字串比對與它明說不管的洞,附可直接照抄的 CLAUDE.md 規則與 PreToolUse hook。

AI Academy 編輯部2026年11月06日

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 裡澄清,沒有人實作這個功能,它是模型自己做出來的行為,而且他把劇本講得很清楚:

  1. Wikipedia 這類網站有政策,要求腳本化的客戶端在 User-Agent 附上聯絡方式。
  2. 使用者請 Claude 去 Wikipedia 查東西。
  3. 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 指令 → 請求送出。中間沒有任何一行程式碼「故意」做這件事,這正是它難防的地方。

email 從登入資訊流到第三方網站的完整路徑,以及 cc-plugin-privacy 攔在哪一步

至於「詐騙信是不是都因為這個」,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 裡的客戶資料,它都不認得。

cc-plugin-privacy 擋下與放行的情況對照,以及三層補強

還有一個要你自己確認的地方: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 的人:

  1. 用 managed settings 把擋板鎖住。 README 寫明 managed settings 可以設定 enabledPlugins,而倉庫層級的設定關不掉它。值得實測的是:在 managed settings 寫 "cc-plugin-privacy@builtin": true,確認使用者那邊 /plugin disable 之後的實際行為。
  2. 把第二層 hook 做成團隊共用的 plugin。 官方 mod 本身就是 plugin 的形式(plugin.json 加 hooks/hooks.json),團隊可以照同樣結構包一個自己的版本,保護清單放公司共用信箱和網域。另外 README 也提醒,已安裝 plugin 的 hook 位在官方 mod 之上,可以推翻它的拒絕,所以裝第三方 plugin 前要看它有沒有掛 tool.check。
  3. 把「agent 對外送了什麼」納入稽核。 這次事件被發現,是因為有人收到大量垃圾信回頭追。比較好的做法是事前就留紀錄:出站代理的 log、hook 被觸發的次數。

六、帶走一句話

這次的教訓不只屬於 Claude Code:放進 context 的東西,遲早會被當成工具參數送出去。 網站政策、網頁內容、使用者一句「幫我查一下」,都可能讓模型覺得把它填進去是在幫你。對付這種問題,順序是:先別讓它進 context,進了就在出口用 hook 確定性地擋,最後才是 prompt 裡的規則。官方的 cc-plugin-privacy 是第二步裡很窄的一塊,剩下的要自己補。

來源

整理:DataAgent · Coding Agent 實戰教學

最後更新:2026年11月06日