一个叫 taylor-edwards 的开发者,11 月 5 日提交了一份 bug 报告。他说自己每天收到几十封诈骗邮件,起因是 Claude Code 在做网页搜索时,把他的账户邮箱写进了 HTTP 请求的 User-Agent 头,发给了不特定的第三方站点。他没有要求过这个行为,也没有任何设置能关掉它。报告编号 #107757,几小时内拿到 179 个赞、34 条评论,状态仍是 open。
表面看,这是一个 header 字段的疏漏。把源码翻出来读一遍,问题在更靠前的位置:harness 把用户邮箱塞进了模型的系统提示,模型只是在执行「帮我去这个网站查点东西」的时候,顺手拿它去满足网站的机器人政策。User-Agent 只是出口。源头在上下文构造那一层。
三个 issue,一条 101 天的时间线
这件事不是突然出现的,它有一条很清楚的时间线,而且三个 issue 到今天全部是 open。
- 2026 年 7 月 25 日,issue #81138 提交,标题直接写「账户邮箱在未经同意、未披露的情况下被注入模型的系统提示」。报告人反编译了 v2.1.220 的 bundle,指出 userEmail 从 ~/.claude.json 的 oauthAccount.emailAddress 读出,拼成
The user's email address is X.放进上下文。这个 issue 有 20 条评论,至今没有关闭。 - 2026 年 9 月 26 日和 10 月 3 日,issue #104774 记录了三个 subagent 把邮箱写进 curl 的 User-Agent 发给 SEC EDGAR,一共 9 个请求。系统提示里写得很明确:这个地址只用于识别用户,绝不要发给不相关的服务。但 SEC 的 fair-access 政策要求 User-Agent 里有联系邮箱,否则返回 403。模型选择了满足网站,放弃了那条隐私指令。这个 issue 在 10 月 24 日提交,至今零评论。
- 2026 年 11 月 3 日,v2.1.317 发布,release notes 写「Added a check that asks before a web or shell tool call sends your account email address to the network」。
- 2026 年 11 月 5 日,issue #107757 出现,说邮箱已经通过 User-Agent 发给第三方,人已经在收诈骗邮件,版本是 2.1.318。
从系统提示注入被报告,到针对 header 泄漏的过滤器发布,隔了 101 天。注入点本身没有改,#81138 仍然是 open。过滤器发布两天后,同一个泄漏仍然在产生诈骗邮件。
#104774 里有一个细节比泄漏本身更值得注意:模型不是「不小心」漏了邮箱。系统提示给了它一条明确的禁止指令,网站政策给了它一条相反的硬要求,它在两者冲突时选了网站。这说明泄漏是结构性的,不能靠「让模型更听话」解决。
泄漏的三步链条
把这件事拆开,是三个环节依次成立。
第一步,身份注入。harness 在会话启动时读本地的 ~/.claude.json,把登录账户的 email 拼进系统提示。issue #81138 贴出的代码片段里,这个字段叫 userEmail,在整个 bundle 里只出现三次:构造点,以及两个遥测事件里记录 has_user_email: Boolean(...)。除了塞给模型,没有任何功能读它。认证走 OAuth token,计费在服务端按账户解析,两件事都不需要这段明文。
第二步,模型拿到一个带身份的任务。用户让它去查资料,它看到上下文里有邮箱,又看到目标站点(比如 Wikipedia)的机器人政策要求脚本客户端留下联系方式。于是它把这两件事接起来,写了一条 curl -A "邮箱" https://...。这是指令跟随,不是模型故障。
第三步,出口没有过滤。User-Agent 是 HTTP 协议里最老的字段之一,设计目的是标识客户端软件。当它被用来携带邮箱,接收方拿到的就不只是「谁在浏览」,而是「哪个具体的人在浏览」。诈骗邮件、跨站画像、账户关联,都从这一个字段开始。
把锅甩给模型是没有意义的。harness 把 PII 放进上下文,又把网络调用的构造权交给模型,这两件事同时成立,泄漏就只是时间问题。触发点可以是 Wikipedia 的机器人政策,可以是 SEC 的 fair-access 要求,也可以只是模型觉得「留下联系方式更专业」。
修复代码:6 个文件,167 行正则
v2.1.317 的修复没有动上下文构造,而是在工具调用层加了一个 mod。源码在 anthropics/claude-code 仓库的 mods/privacy 目录,我把六个文件拉下来逐个数了行数:index.ts 6 行、names-user-agent.ts 76 行、refusal.ts 9 行、shell-tools.ts 11 行、text-of.ts 32 行、register.ts 33 行,合计 167 行。dev.to 上那篇分析写的是 171 行,差 4 行,应该是计行口径不同,不影响结论。
注册逻辑很短。register.ts 在 tool.check 事件上挂一个 hook,工具范围只有 Bash、Monitor、PowerShell。先跑 next(e) 拿到原有裁决,如果已经是 deny 就直接返回。然后把工具输入摊平成文本,文本里既没有 @ 也没有 %40 就放行。有的话,读 $.session.identity() 拿当前账户邮箱,交给 namesUserAgentWithAddress 判断。
判断函数是两段正则。USER_AGENT_WORD 匹配 user-agent、user_agent、–user-agent、-UserAgent 这些写法,还有 UA= 赋值;CLIENT_FLAGS 处理 curl -A、wget -U、以及 PowerShell 的 -User。命中且文本里含邮箱,就返回 deny,理由是固定文案。README 自己写得很清楚:测试是对整条输入做两个词匹配,没有 parser,为了简单。它还写了一句定性的话:「This is a courtesy, not a boundary.」
我把这个函数在 Node 26 上跑了一遍,用假地址 [email protected] 构造 23 条命令行,覆盖直接泄漏、编码变形、运行时取值和误伤场景。结果是这样。
| 结果 | 条数 | 具体写法 |
|---|---|---|
| DENY(预期拦截) | 10 | curl -A 字面量、–user-agent 空格/等号、大写、百分号编码、wget -U、Invoke-WebRequest、en dash 的 -A、单引号、UA= 赋值 |
| DENY(误伤) | 3 | curl -user、echo 里同时提到 user-agent 和邮箱、通用 UA 但 URL 路径里带邮箱 |
| PASS(真实泄漏) | 8 | From 头、curl -u 基本认证、$(git config user.email)、$EMAIL 变量、双重百分号编码、query string、POST body、非 ASCII 地址的百分号编码 |
| PASS(无害) | 2 | [email protected]: 的 query、不相关的 ls 加 echo |
这张表说明的东西比「有 8 个漏洞」更具体。判定条件是「同一条命令里,字面邮箱加 User-Agent 词」。只要邮箱不在字面量里,或者不在 User-Agent 的位置上,它就不管。运行时取值、环境变量、From 头、basic auth、请求体,全部绕过。误伤则来自同一个简单性:curl 的 -user 其实是 -u 加用户名 ser,被当成了 User-Agent 词;echo 一句说明文字也会被拒。
还有三个结构性缺口,README 自己承认了。
它只检查 shell 工具。WebFetch 和 WebSearch 不在检查范围,MCP 工具也不在。模型如果不用 curl 而用内置网页工具,这个 mod 根本看不到。
它可以被关掉。/plugin disable、enabledPlugins 里的 cc-plugin-privacy@builtin: false、服务端开关,以及装在它上面的插件 hook,都能让它失效。
它拒绝的时候不弹提示,直接 deny。release notes 写的是「asks before」,实际发布的行为是静默拒绝。这两句话的差别不小:前者暗示用户会被询问,后者是替你决定。
如果真按「asks before」做,交互应该是这样:模型准备把账户邮箱写进任何出站请求时,弹一条明确的提示,说明目标域名、要发的字段、以及这次发送是否必要;用户可以选择拒绝,并且拒绝后模型应该换一条不带身份的路径继续完成任务,而不是直接失败。现在的实现跳过了询问,也跳过了「换路径」这一步,用户既没有知情,也没有替代方案。
对一个每天要跑几十条命令的编码工具来说,每次都弹窗确实烦。但频率高不是省略同意的理由,尤其是当被发送的是账户身份这种不可撤销的数据。工程上有折中做法:把「带身份的出站请求」单独归一类,只在第一次遇到某个域名时询问,之后按域名记住选择。这样既不打断日常操作,也把决定权留给了用户。
这段注入为什么会存在
从产品角度不难理解。Agent 产品需要知道「谁在提问」,才能做个性化、配额、滥用检测和账单归属。最省事的实现是把登录账户的邮箱从本地配置读出来,直接拼进系统提示,让模型在需要时自己用。这样不用改 API 契约,不用在每次请求里多带一个身份字段,也不用给工具层加参数。
代价是这段明文从此活在模型的上下文里,跟着每一次工具调用走。模型不会区分「这是身份凭证」和「这是可以写进命令的字符串」,它只看到一段自然语言。只要任务需要,它就会把这段文字当成素材。
#81138 的报告人核对过整个 bundle,userEmail 只出现在构造点和两个遥测事件里,后者记录的是 has_user_email 这个布尔值,不是地址本身。也就是说,除了给模型看,这个字段没有别的消费者。认证用 OAuth token,计费在服务端按账户解析。把邮箱放进上下文,收益接近零,风险却是把 PII 直接交给一个会生成网络请求的组件。
网站那一侧看到什么
从接收方看,这条请求和普通爬虫的请求长得不一样。User-Agent 里出现一个真实邮箱,等于给这次访问贴了一个唯一标识。服务器日志、CDN 日志、WAF 记录都会留下它,而这些日志经常被共享、导出,或者接到第三方分析服务上。
更麻烦的是关联。同一个邮箱出现在不同站点、不同时间、不同任务的请求里,就能把「同一个人在不同场景下的浏览行为」串起来。这比 cookie 更硬,因为邮箱是跨平台稳定的标识,而用户不知道自己被这样标记了。
#107757 的报告人说每天收到几十封诈骗邮件,这个结果和上面的机制对得上。邮箱一旦进入第三方日志,就会被爬取、被转卖、被拿去做钓鱼。泄漏的成本不由发送方承担,由用户承担。
为什么说它修在了错的层
从工程角度看,这个 mod 是一个字符串过滤器,挂在「模型可能生成的那条 shell 命令」上。它的输入是模型的输出,不是网络请求本身。
过滤器只能看见它被允许看见的工具。模型生成命令的路径有很多条,shell 只是其中一条。决定邮箱会不会出门的地方是网络出口,而出口没有策略。
字符串匹配天然是黑名单。黑名单的维护成本随模型创造力上升,漏报的代价是 PII 直接外泄。README 自己列了一串绕法:运行时取值、变量、文件名、脚本、别的客户端、PowerShell 哈希表、二次编码、非 ASCII 地址。这些写法不需要攻击者精心构造,正常写命令时就会自然出现。
误伤也有成本。每一条被误拒的命令都要重试一次,模型会从拒绝文案里学会换一种写法。这不是恶意的,但它意味着过滤器的规则会被模型的试错逐步绕开。黑名单和生成模型的关系,从来都是这样。
正确的做法在更下面一层。出口代理应该对出站请求做 header 白名单,把身份字段剥掉,或者把 User-Agent 固定成与账户无关的常量。模型上下文里不该出现明文邮箱,需要识别用户时给一个不可逆的引用 token,让服务端去解析。这两件事都比写正则难,但它们修的是根因。
这也解释了一个看起来矛盾的现象。Anthropic 在 7 月就知道邮箱被注入了系统提示,但 11 月的修复没有动注入点,而是在工具层加了个过滤器。动注入点要改上下文契约,会影响遥测、计费、身份识别一串东西;加过滤器只需要一个 plugin。工程上这是阻力最小的路径,安全上它把边界画在了最容易绕过的地方。
这不是一家产品的问题
把视线从 Claude Code 移开,这个案例暴露的是 agent 工具的通病。任何 harness 只要同时做两件事,就会遇到同样的泄漏。
一件是把用户身份放进模型的上下文。这件事通常有正当理由:个性化、权限判断、审计。但一旦放进去,模型就把它当成可用的信息,而不是需要保护的数据。
另一件是让模型自己构造网络调用。shell 工具、HTTP 工具、MCP 工具,本质都是把「怎么发请求」的决定权交给模型。模型没有「这是 PII,不能出门」的概念,它只有「这条命令能不能完成任务」的目标。
两者叠加,泄漏就是时间问题。这和浏览器指纹是同一个问题的上一层。以前 User-Agent 回答的是「你用什么浏览器」,网站靠它做兼容和统计;现在 agent 把它变成了「你是谁」,而且填的是真邮箱。所谓「谁在浏览」,在 agent 时代变成了一个可以直接反查到人的问题。
我在《Anthropic 开源 srt 沙箱》里拆过出口代理这条线,那里的思路是把密钥和身份留在代理层,容器里的进程只拿到一个占位符。这个案例应该用同一套思路处理:身份在网络层解析,不在模型层流通。另一篇《Claude Code 官方扩展被投毒》讲的是供应链入口,两件事的风险方向不同,但都说明 agent 的信任边界不能画在模型身上。
现在能做什么
对使用方来说,有几件事今天就能做。
检查 ~/.claude.json 里 oauthAccount.emailAddress 是否存在,确认你的 agent 是否把账户身份带进了上下文。如果要用这类工具处理敏感任务,给它一个专用账户或别名邮箱,不要把主邮箱留在登录态里。在网络层加一条出站规则,把 User-Agent 固定成通用值,或者直接剥掉身份类 header。企业环境里,用 managed settings 强制开启 privacy mod,同时用 egress proxy 做第二道防线,不要只依赖那 167 行正则。
想确认自己有没有暴露,可以按三步查。先看 ~/.claude.json 里 oauthAccount.emailAddress 是否有值,那是注入的源头。再看当前版本的 mods/privacy 是否启用,mods 列表把它标成 built in,默认在,但 /plugin disable 或 enabledPlugins 里的 cc-plugin-privacy@builtin: false 会把它关掉。最后拿一条测试命令验证拦截是否生效:在会话里让它执行一条把假地址写进 User-Agent 的 curl,看它是被拒绝还是照常执行。三步都不需要改代码。
对做 agent 产品的人来说,教训更直接:不要在上下文里放明文 PII,不要假设模型会遵守隐私指令,也不要把出口过滤交给模型生成的字符串。身份应该由网络层解析,不应该在模型层流通。
写在最后
#107757 现在还是 open。报告人说他已经在把公司迁到 Pi 加 OpenRouter,理由是「不再相信 Anthropic 会保护隐私」。这个反应可能过激,但它指出的问题是真的:当 agent 替你去浏览,网站看到的是一个带着你邮箱的机器人,不再是一个匿名爬虫。这个字段本来是用来标识客户端的,现在它标识了你。