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