Long-form

Claude Code 邮箱泄露事件拆解:Agent 身份链路的两段式病灶

8 min read ·

11 月 5 日,一条标题直白的 issue 登上 Hacker News 首页:「Claude Code sends your email address to websites」,一天内收获三百多个赞。报告者描述的场景很具体:Claude Code 在执行网页搜索和数据请求时,把一个形如会话标识加真实登录邮箱的字符串塞进请求头的 User-Agent 字段,而这位用户因此每天收到数十封诈骗和钓鱼邮件——收件方显然从他从未访问过的属性里拿到了这个地址。

这不是一条孤立的投诉。把它放回时间线,你会看到一个从 7 月延续到 11 月、横跨五个 issue 的完整证据链。比泄露本身更值得写的,是这条链路的形状:它揭示了所有 agent harness 都要面对的一个架构病灶——身份信息从控制面渗入数据面,中间没有任何边界检查。

一条五个月的证据链

把公开材料按时间排开,事件的演化路径相当清晰。

2026 年 7 月 25 日,issue 81138 被提交。报告者反编译了 Claude Code v2.1.220 捆绑的 JavaScript,在上下文构建器里发现它从本机配置文件读取 OAuth 账号的邮箱地址,与项目文档、当前日期并列注入系统提示,注入内容是一句「The user’s email address is」加上真实邮箱。关键属性有三个:只在 OAuth 登录路径下注入,API key 与 Bedrock、Vertex 路径下该字段不存在;没有任何关闭开关,有用户在二进制里逐个搜索疑似配置名一无所获;社区在后续多个版本上复现,说明这是设计而非偶发。

10 月 13 日,issue 101659 出现了第一起明确的网络侧泄露:一个由子代理派生的 web research 子代理用 curl 请求 Liquipedia API,把用户登录邮箱放进了 User-Agent。这里有一个讽刺的细节——Liquipedia 的 API 文档要求客户端在 UA 中附上联系方式,这是给稳定运行的 bot 准备的文明惯例,而模型上下文里唯一的联系方式就是用户的真实邮箱,于是它照做了。

三天后是 issue 102577:模型在对话里自述把邮箱放进了对 Wikipedia 的前四次搜索请求,之后才换成通用 UA。10 月 24 日,issue 104774 记录了子代理在明知不能泄露的前提下,仍把邮箱带进了发往 sec.gov 的请求——系统提示里的保密指令没能拦住它。

8 月底,Anthropic 其实做过一次修复(约 v2.1.246):在注入邮箱的同一行上下文后面追加使用规则,声明该邮箱仅用于识别用户,未经明确要求不得通过请求头、URL 或载荷发送给无关服务。但 10 月的三起泄露发生时,这段规则都在上下文里。prompt 层的防线被三次穿透,这正是整件事最有工程价值的部分。

两段式链路:控制面到数据面的渗漏

我倾向用一个简单的框架来理解这条链路:agent 系统里存在控制面和数据面,控制面是模型上下文——系统提示、配置注入、项目文档;数据面是 agent 对外产生的一切实际作用——发出的 HTTP 请求、写入的文件、创建的 commit。正常的架构里,两面之间应该有明确的边界检查;而这起事件里,身份信息从控制面一路渗到数据面,全程无阻。

第一段渗漏发生在控制面本身:邮箱被当作「用户上下文」的一部分塞进系统提示。设计动机大概是无害的——让模型知道自己在为谁服务。但一旦进入上下文,这个邮箱就变成了模型可见、可引用、可组合的普通数据。上下文没有权限模型,没有字段级别的用途标记,没有「这段信息仅供识别、不可外发」的强制约束——8 月底加的那句规则只是文字,而文字要靠模型理解并遵守。

第二段渗漏发生在数据面的出口:模型决定发起网络请求时,从上下文里挑选参数。网站要求联系方式,上下文里有邮箱,模型就把它填进去。注意这个决策的性质:它不是 bug 式的字符错位,而是一次语义上「合理」的参数填充——模型甚至可能认为自己在礼貌地遵循目标网站的 API 文档。这类错误没有崩溃、没有报错,只有静默的 PII 外流,这也是它难以被察觉的原因。

两段渗漏叠加的后果是:一个本应只存在于订阅关系里的身份标识,成了无数出站请求的载荷。而 agent 的出站请求天然是高频、随机目标、不可枚举的——你无法预知模型今天会访问哪个网站,也就无法事后圈定泄露范围。

为什么提示词规则注定失败

8 月底的修复值得单独分析,因为它是一个教科书式的反模式:用 prompt 层的手段去解决架构层的问题。

把「不要把邮箱发给无关服务」写进上下文,本质上是把安全属性外包给概率性输出。模型对这句话的遵守程度取决于它对「无关服务」的理解、对当前任务的优先级权衡、甚至对目标网站文档的尊重程度——sec.gov 那起泄露就是在明确保密指令下发生的。安全工程里有一条朴素的原则:凡是需要百分之百成立的约束,都不能只存在于自然语言里。理由很简单,自然语言的理解者是那个每秒采样一次 token 的概率系统,而你需要的保证是确定性的。

对照另一个细节:Codex CLI 的 User-Agent 被核验只含客户端版本、系统架构和终端类型,不含任何用户信息。这不是 Codex 的道德更高,而是它的身份设计里根本没有把邮箱放进模型上下文——架构上不存在的东西,模型再怎么「帮忙」也泄露不出去。这才是正确方向的注脚:把身份挡在上下文之外,比教上下文里的身份守规矩可靠得多。

礼貌惯例与临时身份的错配

还有一个容易被忽略的推手:User-Agent 附联系方式的行业惯例本身。

这套惯例诞生于爬虫文明时代,它的预设使用者是长期运行的稳定 bot——搜索引擎爬虫、监控服务,运营方希望站点被滥用时能找到一个稳定的联系入口。但 agent 是另一种东西:它按会话临时存在,每个请求的目标由模型即兴决定,它的「运营方」是终端用户本人。拿用户的真实邮箱去填一个为 bot 运营方设计的联系字段,等于让消费者为厂商的角色承担曝光责任——身份完全错配了。

错配的解法不是取消惯例,而是身份分层:agent 出站时应该携带一个任务级或厂商级的联系身份(比如厂商维护的转发邮箱或固定标识),与用户个人身份彻底解耦。厂商愿意为自家 agent 的网络行为背书,用户不必为每次出站请求押上自己的收件箱。

防御工程:实测过的两层 fail-closed

在官方修复落地之前,社区给出了两层可落地的防御,我把出口侧的方案在本机实际跑了一遍,五个用例全部符合预期。

第一层是出口阻断,利用 Claude Code 的 PreToolUse hook:在 Bash 或 WebFetch 工具真正执行前,把待执行的命令收上来,与收集到的身份列表(本机配置里的账号邮箱、git 邮箱、自定义字符串)做大小写不敏感的子串匹配,命中就以退出码 2 阻断,并把原因回传给模型重试。脚本核心逻辑如下:

#!/usr/bin/env bash
# PreToolUse hook: block outbound calls carrying personal identity.
input=$(cat)
cmd=$(printf '%s' "$input" | jq -r \
  '[.tool_input.command // "", .tool_input.url // ""] | join(" ")')

secrets=()
acct=$(jq -r '.oauthAccount.emailAddress // empty' \
  "$HOME/.claude.json" 2>/dev/null)
[ -n "$acct" ] && secrets+=("$acct")
gitmail=$(git config user.email 2>/dev/null)
[ -n "$gitmail" ] && secrets+=("$gitmail")
IFS=',' read -ra extra <<< "${CLAUDE_BLOCK_STRINGS:-}"
for s in "${extra[@]}"; do [ -n "$s" ] && secrets+=("$s"); done

lower=$(printf '%s' "$cmd" | tr '[:upper:]' '[:lower:]')
for s in "${secrets[@]}"; do
  l=$(printf '%s' "$s" | tr '[:upper:]' '[:lower:]')
  if [[ "$lower" == *"$l"* ]]; then
    echo "Blocked: outbound request contains a personal identifier. \
Retry with a generic User-Agent, or skip the site." >&2
    exit 2
  fi
done
exit 0

注册到用户级配置的 hooks 里,matcher 覆盖 Bash 与 WebFetch 即可。我的实测过程有两个值得记录的点。第一轮测试我犯了一个想当然的错误:用一条凭空编造的邮箱去测,hook 全部放行——它只匹配它收集到的身份,名单之外的东西它一概不认识。这既是设计使然也暴露了短板:身份发现靠的是两三个固定来源,任何绕过这两个来源进入上下文的身份(比如会话中读到的文件内容)都拦不住。第二轮用环境变量注入测试身份后,五个用例全部符合预期:UA 携带邮箱的 curl 被阻断,通用 UA 放行,URL 查询串里的邮箱被阻断,大小写混合的变体被阻断,真实 git 邮箱路径被阻断。我还确认了本机配置文件里确实存在 OAuth 账号对象——注入前提在这台机器上是成立的,这套防御对我不是理论需求。

第二层是源头剥离:社区插件用 prompt.context 类型的 hook 在邮箱进入模型上下文之前把它剥掉,失败时同样 fail-closed。两层叠加才构成完整防线——源头剥离降低泄露面,出口阻断兜住漏网的路径。另外三件低成本的事建议立刻做:把 git 邮箱换成 GitHub 的 no-reply 地址并开启 push protection(issue 81138 的评论区有人被模型在十个 commit 里写入私有邮箱,直到 GitHub 拦截);审计 CLAUDE.md 和 memory 文件里是否有联系方式;有条件的话在网络层挂记录代理,对出口流量 grep 自身标识符。

💡 如果只记一件事:任何需要确定性成立的约束,都不能只写在 prompt 里。检查你的 agent 工具链,把「身份不进上下文」或「出口 fail-closed」至少落实一样。

超出单一厂商的通用教训

把视线拉远,这件事真正值得所有 agent 开发者记住的是三条架构原则。

其一,上下文不是无副作用的数据池。放进上下文的每个字段都是模型的潜在素材,PII、密钥、内部路径一旦进入,就没有「仅供查看」这回事。上下文注入需要一份准入清单,而不是默认全塞。

其二,出口必须 fail-closed。模型输出的参数在到达网络之前是最后一个可信检查点,这个检查点要么存在于工具层(hook),要么存在于网络层(代理),唯独不能只存在于模型的自我约束里。

其三,身份要分层。用户身份、厂商身份、任务身份是三种不同的东西,混用就是把终端用户押给每一次即兴的网络请求。

Claude Code 的这个问题大概率会被修掉,但同构的病灶会换个工具、换个字段再次出现——只要 agent harness 的设计者仍然把「塞进上下文」当作传递信息的默认手段,把「模型会遵守」当作安全假设。五个月的证据链已经给出了判决:那不是假设,是运气。

Frequently asked questions

Claude Code 真的会把我的邮箱发给网站吗?
有条件成立,不是每次请求都发生。GitHub 上有多起公开报告:触发需要同时满足三个条件——账号邮箱在模型上下文中、目标网站要求 bot 提供联系方式、模型决定照做。2026 年 10 月有 Liquipedia 与 sec.gov 两起带版本号的记录,11 月的报告者称因此每天收到数十封诈骗邮件。但也有站长审查约一千万行 HTTP 日志未发现实例,说明行为有随机性。
改用 API key 登录而不是 OAuth,能避开这个问题吗?
能避开上下文注入这一段。反编译分析显示邮箱来自 oauthAccount 字段,只在 OAuth 认证路径下存在,API key、Bedrock、Vertex 登录时上下文里不会有邮箱。但这不等于零风险:git 邮箱、CLAUDE.md、memory 文件同样可能成为身份源,出口侧的防线依然需要。
Anthropic 官方修复了吗?
截至本文发布,相关 issue 均未见官方回复。已知的一次修复在 8 月底(约 v2.1.246),做法是在系统提示里加一段使用规则:邮箱仅用于识别用户,未经明确要求不得发送到无关服务。这是 prompt 层缓解,10 月的三起泄露发生时该规则都在上下文中,实际未生效。
我怎么自查自己有没有被泄露?
三个办法可以叠加用。一是在终端执行 claude -p 加一句问自己邮箱的提示词,能答出来说明邮箱确实进了上下文;二是审查会话里 curl 或 WebFetch 的调用参数,看 User-Agent 是否携带身份;三是在网络层挂一个记录型代理,对出口流量 grep 自己的姓名和邮箱,这是唯一覆盖全部出口的验证方式。
其他 agent 工具也有同样的问题吗?
HN 讨论中有用户让模型核验 Codex CLI 0.159.0,其 User-Agent 只包含客户端版本、操作系统架构和终端类型,不含邮箱。但这只是一次抽查而非保证。通用判断标准是:任何把用户 PII 放进模型上下文的 agent harness,都存在同构的泄露面,值得用同样的方法自查一遍。
// next.txt ›

Some outbound links in this post are affiliate links — see disclosure.