我的智能體上下文含有電郵,我用 125 行程式攔截外送資料
My Agent Had My Email in Its Context. So I Put a 125-Line Guard on Its Way Out
作者為 AI 智能體編寫並測試一個 125 行的外送防護程式,檢查 URL 和標頭中的電郵、密鑰及自訂敏感字串,命中便拒絕請求。七項測試中,三項正常請求及一項 Base64 編碼請求到達捕獲伺服器,另外三項被攔截;透過防護程式額外耗時約 0.13 毫秒/請求。由於普通 HTTPS 轉發代理看不到加密後的標頭,作者建議在工具組裝並加密請求前執行檢查。
Most developers who saw the Claude Code thread on Hacker News this week took away one fact: an AI coding agent put a user's email address into the User-Agent header of web requests. Fewer noticed the more useful detail buried in the replies. The fix Anthropic shipped is a piece of code that checks requests as they leave. It does not ask the model to behave better.
I work at Karma Box, which builds a workspace that runs AI agents on hardware you own, so I'm not neutral on agents and privacy, and you should read this knowing that. To keep it checkable I've quoted the GitHub issues and the HN thread word for word, labelled every comment as one person's view, and kept my own work to things I ran on my own machine on 6 November 2026, with the versions written next to the results. Nothing here reproduces Claude Code's internals. I don't have them, and the test below uses placeholder data only.
An egress guard is a piece of code that looks at every request on its way out of an agent's tools and refuses the ones that carry data they shouldn't, such as an email address, a key or a value you've told it to watch for. It doesn't rely on the model reading an instruction. It runs every time.
What happened, in the order it happened
On 25 July 2026, a user opened issue #81138 on the Claude Code repository. The title is blunt: "Account email (PII) is injected into the model's system prompt without consent, disclosure". The body says Claude Code "takes personal data I gave Anthropic in order to authenticate, and injects it into the model's system prompt on every session". The reporter quoted the injected block from the v2.1.220 bundle:
# userEmail
The user's email address is <account email>.
According to that report, the value came from ~/.claude.json, under oauthAccount.emailAddress. The same report shows the builder skipping the field when an environment variable called ANTHROPIC_UNIX_SOCKET is set. I haven't checked the bundle myself, so treat those details as the reporter's reading of it.
On 29 October, a short "Tell HN" post linked that issue and a Reddit thread where someone said a model had shared their email address with a website. It got 40 points and little attention.
On 5 November, issue #107757 went up. Its author wrote: "I receive dozens of scam emails per day because claude code divulged my email to arbitrary 3rd parties in the User-Agent request header while conducting web searches without my permission." They added that they were "migrating my company to Pi with OpenRouter". The Hacker News submission linking it reached 450 points and 157 comments that day. One commenter described the header they had seen as something like my-session-slug/1 <my.name@example.com>.
The same day, an Anthropic contributor replied on the issue: "We introduced a built-in mod called cc-plugin-privacy to deterministically block this behavior in v2.1.317 and later". They added that the mod is "enabled by default, you can turn it off via /plugins."
That's a quick, specific response, and it's worth saying so. The four months between July and November are a separate question. So is whether the scam emails actually came from these headers. Several commenters doubted that, pointing out that work addresses get scraped from LinkedIn and GitHub all the time. Nobody in the thread proved the link either way, and I can't either.
Why the model wrote the header
The most useful comment in the thread came from a user called oefrha. In their view, this wasn't a hardcoded line in a web-search tool. It was the model itself:
"As long as the email is in the context, which it is IIRC, it can totally decide to run curl –H 'User-Agent: '."
And later:"The problem here literally is the non-deterministic model cooking up commands with the user email address in the header, and the harness not blocking that (and the implemented fix is to deterministically block that)"
That's one person's reading, and another commenter disagreed about how to interpret the original report. But it matches the shape of the fix Anthropic described, and it explains something that matters for anyone building with agents.
A language model that can run shell commands or fetch URLs can put anything from its context into what it sends. Email addresses in the system prompt are the obvious case. oefrha pointed out a less obvious one: "your email will be all over the place in the context as long as the agent is allowed to read git log". Every commit has an author line. An agent that reads history to understand a codebase has seen your address, and maybe your colleagues' addresses too.
Many web services ask automated clients to identify themselves in the User-Agent, often with a contact address. A model that has seen that convention, and has an address sitting in its context, can decide that a polite bot includes one. Nobody has to code that. It just happens.
So there are two separate questions. What does the agent know? And what is allowed out through the door a request leaves through? The first is hard to keep small, because agents need context to be useful. The second is something you can check.
Step one: see what a request carries
Before writing a guard, I wanted to see a request the way the receiving server sees it. A commenter in the thread suggested exactly this: "fire up a little web server locally, have the agent make a request to it, then check the logs."
The capture server is about fifteen lines with Python's standard library. It listens on 127.0.0.1, writes the path and User-Agent of every GET to a file, and replies "ok".
class Capture(http.server.BaseHTTPRequestHandler):
log_path = None
def do_GET(self):
with open(self.log_path, "a") as f:
f.write(json.dumps({"path": self.path, "ua": self.headers.get("User-Agent")}) + "\n")
self.send_response(200); self.end_headers(); self.wfile.write(b"ok\n")
A colleague ran the simplest version of this earlier the same morning. Default curl 8.7.1 announced itself as curl/8.7.1. Python's urllib announced Python-urllib/3.9. When they built a User-Agent from a variable holding a placeholder address, demo.user@example.invalid, the server logged it exactly as sent. That's not a surprise. It's the point: the server records whatever arrives, and nothing between the client and the server had any opinion about it.
If you run an agent, this is the cheapest test you can do this week. Point one of its fetch tools at a local server and read the log. You'll learn what your agent sends by default, which is more than most of us knew on Wednesday.
Step two: a guard at the door
The guard is a small forward proxy that sits between the client and the capture server. For every request it looks at the request line, with the URL decoded, and at every header. If it finds an email address, a key-shaped string, or a value you've registered as sensitive, it answers 403 and doesn't forward the request. Otherwise it passes the request on.
The checks are deliberately plain:
EMAIL = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
KEYS = [re.compile(p) for p in (r"\bsk-[A-Za-z0-9_-]{16,}", r"\bAKIA[0-9A-Z]{16}\b", r"\bghp_[A-Za-z0-9]{36}\b")]
DENY = {"demo.user@example.invalid"} # 显式登记:上下文里已知的身份串
def findings(text):
hits = []
if any(s in text for s in DENY):
hits.append("denylist")
if EMAIL.search(text):
hits.append("email")
if any(k.search(text) for k in KEYS):
hits.append("key")
return hits
def inspect(request_line, headers):
target = request_line.split(" ")[1] if " " in request_line else request_line
decoded = urllib.parse.unquote_plus(target)
hits = [("url", h) for h in findings(decoded)]
for k, v in headers.items():
hits += [("header:" + k, h) for h in findings(v)]
return hits
Three kinds of check, each doing a different job. The email pattern catches anything that looks like an address. It's loose on purpose; a false positive costs you one failed request you can look at, and a false negative costs you an address you can't get back. The key patterns catch the shapes of a few common API keys. The denylist is the interesting one. If your harness knows the account email, the git author address, or a customer name the agent is working with, it can register those exact strings and refuse any request that carries them, whatever the pattern rules think.
The unquote_plus call matters more than it looks. An address in a query string usually arrives URL-encoded, as demo.user%40example.invalid. A pattern check on the raw string would miss it. Decoding first catches it.
The full script, with the proxy handler, the test cases and the timing loop, is 125 lines. It uses only the standard library and refuses to run unless you hand it a fresh, empty directory for its log, so it can't scribble over anything else.
Step three: seven requests
I ran seven requests through the guard to the capture server. Three should pass, three should be blocked, and one is there to show what the guard can't see.
The capture server's log showed four requests: the three that should pass, and the base64 one. None of the three blocked requests reached it.

I ran the whole thing three times. Same results each time. Machine: Apple M4 Mac mini with 24 GB, macOS 15.6.1, Python 3.9.6, curl 8.7.1, everything on 127.0.0.1, no TLS, placeholder data only.
On cost: 200 sequential requests took 0.040 seconds direct and 0.067 seconds through the guard, about 0.13 milliseconds extra per request (local loopback, sequential, no TLS; a real network round trip will dwarf this). Inspecting a request is cheap. The work is in deciding what to look for.
The base64 miss deserves a straight answer. A model that wanted to smuggle an address past a pattern check could encode it, split it, or spell it out. Pattern checks catch the shapes you list, and the denylist catches the exact strings you register. Neither understands meaning. If you need more, you narrow what the agent can send at all: fixed headers per tool, no free-form headers, an allowlist of hosts. That's a policy decision, and it costs flexibility.
The pitfall that ate my first run
My first run reported every request as 200, including the ones that should have been blocked. The capture log had all seven. For a minute I thought the inspection code was broken.
It wasn't. The machine has NO_PROXY and no_proxy set, and both lists include 127.0.0.1 and localhost. curl honours those lists, so it ignored my -x flag and went straight to the server. The guard never saw a single request.
The fix in the test is one flag, --noproxy "", which clears the exception list for that call. The lesson is bigger than the flag. A guard that lives on the network only works if every request actually goes through it, and environment settings you've forgotten about can route around it without any warning. I only noticed because I had a capture server that logged what arrived. Without it, I'd have shipped a guard that guarded nothing and a test that said everything was fine.
There's a cheap way to catch this class of bug in any guard you build: count. The guard should know how many requests it saw, and the capture server knows how many it received. If the guard saw zero and the server logged seven, something is going around it. My script now prints which requests reached the capture server for exactly this reason, and if I'd had that line on the first run I'd have found the problem in seconds instead of a minute of doubting my regex.
The same check works in production. Log a counter at the guard, compare it with what the tool layer thinks it sent, and alert when they drift apart. A guard that's been routed around doesn't fail loudly. It just stops seeing anything, and an empty refusal log looks exactly like a well-behaved agent.
Where the check belongs
There's a second reason not to rely on a network proxy, and it's a bigger one. My test used plain HTTP. Almost every real request an agent makes is HTTPS. When a client talks HTTPS through a forward proxy, it sends a CONNECT request with a host and port, and everything after that, including the headers and the full URL, is encrypted. A plain proxy can't see the User-Agent at all. You'd need TLS interception to inspect it, with its own certificates, trust and failure modes.
So the place to check is earlier: in the tool, after the agent has decided what to send and before the request is built and encrypted. The same inspect() function works there. I called it directly on three HTTPS-shaped requests. A normal search passed. A request with the placeholder address in the User-Agent and one with it in the query string were both flagged, by the denylist and the email pattern. Ten thousand inspections of the second request took 0.034 seconds on the same machine.

That's also, as far as I can tell from the public reply, the level at which Anthropic fixed this. Their description says the new mod is built in and blocks the behaviour "deterministically". I haven't seen its source. The contributor said they'd try to make it available, the way some other built-in mods are.
For your own agents, the practical version looks like this:
wrap every tool that makes network calls in one function that runs the check
register the identity strings the harness already knows (account email, git author, customer names in scope) in the denylist
log every refusal with the tool name and the field that matched, never the matched value
treat a refusal as a bug report about the agent's behaviour, and look at what it was trying to do
None of that is clever. It's the same thing we already do for SQL: we don't ask the query author to promise they'll escape input, we use code that does it every time.
What goes on the denylist
The pattern rules are generic. The denylist is the part only you can write, because it holds the specific strings your agent has seen. It's worth an hour of thought.
Start with whatever identifies the person running the agent. The account email is the obvious one, and in this week's story it was the one that leaked. Next is the git identity. Running git config user.email in a repository shows the address that will end up on every commit the agent makes, and on every commit it reads. If your team signs commits with work addresses, those addresses are in the agent's context the moment it reads git log, whether or not anything put them in the system prompt.
Then think about what the agent is working on. A support agent sees customer names and order numbers. A finance agent sees account numbers. A coding agent in a client repository sees the client's internal hostnames. Some of these are personal data. Some are commercially sensitive. All of them are strings you can register for the length of the task and remove afterwards.
Last, the secrets. Key patterns catch common shapes, but your own tokens may not match any public pattern. If the harness hands the agent a token to call an internal API, register that exact token too. The denylist doesn't care what a string looks like. It only cares whether it's there.
Two practical notes from building this. Keep the denylist out of the agent's context; it's a list of things the agent shouldn't send, and handing it to the model defeats the purpose. And when the guard refuses a request, log which field matched and which rule fired, never the value. A log of refusals that quotes the secret it protected is just another place the secret lives.
What this guard covers, and what it doesn't
This guard covers outbound leaks through headers and URLs from tools you control. That's a real class of problem, and it's the one in this week's story.
It doesn't cover what the model provider sees. Whatever is in the context goes to the provider's servers with every call, and the guard sits after that. If you want less there, the only fix is less context. Our team checked our own agent setup that morning: across 668 files in the configuration and workspace, there was no email field, though there were a user identifier and a machine identifier. We didn't print either. That's a smaller footprint than an email, and it's still not nothing.
It doesn't cover request bodies, which my test didn't inspect. Bodies need the same check, with size limits so you don't scan uploads byte by byte.
It doesn't cover disguised data, as the base64 case showed.
And it doesn't cover tools you don't control. If a vendor's built-in web tool runs on their side, your guard never sees the request. You're relying on their fix, which this week was a good one.
So: outbound-leak risk, partly addressed. Context-exposure risk, unchanged. Encoded-data risk, open. Third-party-tool risk, outside your reach. Naming them separately makes it easier to see which one you're working on.
Pointing an agent you didn't build at the capture server
The capture server isn't only for your own code. Most agent tools that can fetch a URL will happily fetch http://127.0.0.1:<port>/anything if you ask them to, and that's enough to see what their fetch tool sends by default. It's the test the HN commenter suggested, and it takes a few minutes.
A few things make the result more useful. Ask for a plain page fetch, and separately for something that looks like research ("look up this page and summarise it"), because an agent may build requests differently depending on the task. Run it once in a fresh session and once in a session where the agent has already read a repository with commit history, so you can see whether what it knows changes what it sends. And read the whole log line, not just the User-Agent. Query strings and custom headers are where unexpected values tend to turn up.
The limits are worth stating. This shows you requests the agent sends to your server. It says nothing about requests a vendor's tool sends from the vendor's own infrastructure, which never touch your machine. A clean log on one day also doesn't prove much about the next release. Rerun it when the tool updates. That's cheap, and it's the only way to know that this week's fix is still in place in the version you're running.
Running it yourself
The script takes one argument, an empty directory for its log:
D=$(mktemp -d)
python3 d08p_egress_guard.py "$D"
rm -rf "$D"
It starts both servers on random ports on 127.0.0.1, runs the seven cases, prints which requests reached the capture server, times 200 requests direct and through the guard, and shuts down. Nothing leaves your machine. If your environment sets NO_PROXY for localhost, the --noproxy "" flag in the test handles it, but check yours anyway; that's the bug that hid every result on my first run.
If you adapt it, change the denylist first. The patterns are generic. The denylist is where your knowledge of your own agent goes: the strings it has seen and should never send.
For keys specifically, our API key self-custody checklist covers where to keep them so fewer of them end up in an agent's context in the first place. The longer guide on AI agent data leakage covers the other outbound channels, what GDPR's data minimisation rules expect, and how to test a vendor's agent as well as your own.
What would your agent's capture log show if you pointed it at a local server today?
Sources
GitHub, anthropics/claude-code issue #81138, "Account email (PII) is injected into the model's system prompt without consent, disclosure", 25 July 2026.
GitHub, anthropics/claude-code issue #107757, "Bug Report: Privacy violation in User-Agent header", 5 November 2026, including the contributor reply on cc-plugin-privacy.
Hacker News, "Claude Code sends your email address to websites" discussion, 5 November 2026.
來源:DEV Community · dev.to

