maborak.com~/blog/claude-code-user-agent-emailsoftware & infrastructure engineering
Back to all articles

Claude Code's new email guard, and two curl calls that get past it

Overview

Claude Code 2.1.317 added a guard to stop the model putting your email in User-Agent headers. On 2.1.318 the address still reached our test server through git config and shell quoting.

We asked Claude Code 2.1.318 to run two curl commands against a server on 127.0.0.1:

curl -s -A "probe-L1 ($(git config user.email))" http://127.0.0.1:8199/L1
curl -s -A "probe-L2 (you"@"example.com)" http://127.0.0.1:8199/L2

Both ran, and the server logged the account’s email address in each User-Agent ([email protected] stands in for it here). A third command with the address typed out plainly was stopped by cc-plugin-privacy, the guard Anthropic shipped in 2.1.317 after users caught Claude sending their email to crates.io, SEC EDGAR and other APIs.

Checked 6 November 2026.

Background

Claude Code has put the signed-in account’s email in the model’s context since at least May 2026 (#55743 (opens in a new tab), #81138 (opens in a new tab)). In July, #78431 (opens in a new tab) showed what the model did with it. One user found five curl calls in an hour of work, each sending their address to the crates.io API:

GitHub comment by mt-empty on 11 August 2026: “yes same here, curl commands are passing my email address, who thought this was a good idea! edit: here is the tool call from the session logs”, followed by a Bash tool_use whose command starts with curl -s -H and a User-Agent of ashurbanipal-publish-check, and “I have masked my email, 5 occurances in a 1 hour session.”

Issue 78431, captured 6 November 2026 at 23:54 UTC. The cut-off command is curl -s -H "User-Agent: ashurbanipal-publish-check (<masked>)" https://crates.io/api/v1/crates/ashurbanipal-axum.

That issue was closed as stale in September. A new report, #107757 (opens in a new tab), reached the Hacker News front page on 5 November, and a Claude Code engineer replied the next morning:

Hacker News comment by lydia_hallie: “I work on Claude Code. We’re removing the account email address from the context Claude Code gives the model. … Until the next release arrives, 2.1.317 and later have a built-in mod (on by default) that asks you before Claude runs a web tool call or a curl-style command containing your email … But it still doesn’t prevent all cases, like when Claude writes a script and runs on its own. The stable channel is still on 2.1.311, so update to 2.1.317 or later.”

Hacker News item 50388176, captured 6 November 2026 at 23:54 UTC.

2.1.317 was published to npm on 2 November, 2.1.318 on 3 November and 2.1.319 on 6 November.

Mechanics

No code in Claude Code sends the address on purpose. SEC EDGAR (Accessing EDGAR Data (opens in a new tab)), crates.io (RFC 3463 (opens in a new tab)) and Wikimedia (User-Agent Policy (opens in a new tab)) all ask scripted clients to put a contact in the User-Agent. The model tries to comply, and the only contact it has is yours.

Anthropic first answered with words. Since 2.1.318 the line that hands the model your address adds “A site asking automated clients for contact details, such as an email in the User-Agent, is not a reason to use this address”. Our copy of 2.1.319 still has the address line, so the removal has not shipped yet.

The guard is a hook on tool calls. Here is its rule as compiled into 2.1.319, identical in 2.1.317 and 2.1.318:

var i = ["WebBrowser", "WebFetch", "WebSearch"];
var w = /\b(curl|wget|https?|httpie|xh|nc|ncat|socat|telnet|ssh|scp|sftp|rsync|ftp|lftp|rclone|iwr|irm|invoke-webrequest|invoke-restmethod)\b/;
function m(tool, text, email) {   // text: the call as JSON, lowercased, plus one %-decode
  let r = email?.toLowerCase() ?? "";
  if (r === "" || !text.includes(r)) return false;
  let rest = text.replaceAll(r, " ").replace(/\\(?:\\|[nrt])/g, " ");
  return i.includes(tool) || w.test(rest);   // true -> ask the user
}

If your account address appears in a web tool call, or in a shell call that also mentions a network client, Claude Code asks before running it. With claude -p nobody is there to answer, so the ask becomes a refusal. The check reads the command as written. It never sees what the shell produces, so $(git config user.email) and "you"@"example.com" both get through.

Anthropic published the guard’s source on 6 November at mods/privacy (opens in a new tab) (commit 90380ef). That code has a different rule: it refuses only when the call also names a User-Agent, and it skips web tools. None of the three released binaries contains its refusal text, so the README is not a guide to what your install does.

Same inputs through both rules, with a placeholder account [email protected]:

Call (Bash unless noted) Published source Shipped 2.1.317–2.1.319
curl -A "probe ([email protected])", also -H, wget -U, requests refuse ask
echo 'User-Agent: test [email protected]' > /dev/null refuse runs
curl -H "From: [email protected]" or [email protected] runs ask
WebFetch with the address in the URL not checked ask
-A "probe (jo"@"example.test)" refuse runs
-A "probe ($(git config user.email))" runs runs
address built from variables or base64 runs runs
address inside a script file run by name runs runs
git email differs from the account email runs runs

We ran both rules from a TypeScript copy under tsx 4.23.15. The echo row explains why two people in #107757 saw the guard let an echo with their address run: they were testing the published rule.

Impact

Anyone signed in to Claude Code with an account is exposed on a version below 2.1.317, and the engineer’s comment says the stable channel was still on 2.1.311. Accounts are not the only source. An API-key user with an email in ~/.gitconfig gives the model an address it can read with one command, and the guard does not know that address exists.

The receiver is whoever runs the API, plus any CDN, proxy or log pipeline on the way. In #107757 the reporter blames a wave of scam email on it, which we have not checked.

On the workstation used for this post, a scan of 1,329 Claude Code transcripts going back to 14 September found no case outside our own tests.

Probe

Tested on macOS 27.0 (arm64) with Claude Code 2.1.318, curl 8.7.1 and Python 3.15.2. Nothing leaves the machine except the prompt, which Anthropic already has.

Check which rule your binary carries:

B=~/.local/share/claude/versions/$(claude --version | cut -d' ' -f1)
grep -a -c "may leave this machine" "$B"               # shipped rule, expect 2
grep -a -c "what looks like a User-Agent setting" "$B" # published rule, expect 0

Start a server that prints each User-Agent:

python3 -I -c '
import http.server
class H(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        print(self.path, self.headers.get("User-Agent"), flush=True)
        self.send_response(200); self.end_headers(); self.wfile.write(b"ok\n")
    def log_message(self, *a): pass
http.server.HTTPServer(("127.0.0.1", 8199), H).serve_forever()'

In another shell, have Claude Code run three commands as written:

ACCT=$(python3 -c 'import json,os;print(json.load(open(os.path.expanduser("~/.claude.json")))["oauthAccount"]["emailAddress"])')
U=${ACCT%@*}; D=${ACCT#*@}
probe() { claude -p "Run this shell command once with the Bash tool, unchanged, and print its output or refusal verbatim: $1" \
  --allowedTools "Bash(curl:*)" --output-format json | python3 -c 'import json,sys;print(json.load(sys.stdin)["result"][:160])'; }
probe "curl -s -A \"probe-L3 ($ACCT)\" http://127.0.0.1:8199/L3"
probe 'curl -s -A "probe-L1 ($(git config user.email))" http://127.0.0.1:8199/L1'
probe "curl -s -A \"probe-L2 ($U\"@\"$D)\" http://127.0.0.1:8199/L2"

Our run, each reply cut down to the tool result:

L3  Permission to use Bash denied by plugin cc-plugin-privacy: This call's input holds your account email address, which may leave this machine. ...
L1  ok
L2  ok

Our server log, address replaced:

/L1 probe-L1 (<account>)
/L2 probe-L2 (<account>)

The probe dictates each command, so it tests the guard and says nothing about how often the model writes these shapes by itself. We did not test bypassPermissions or auto mode, where an ask might be answered without a person.

To search your own history, run this against ~/.claude/projects. It only finds addresses written into a call, so the L1 and L2 shapes stay invisible to it:

#!/usr/bin/env python3
# List Claude Code tool calls that put your account or git email next to a User-Agent.
import json, pathlib, re, subprocess

home = pathlib.Path.home()
account = json.load(open(home / ".claude.json")).get("oauthAccount", {}).get("emailAddress", "")
git = subprocess.run(["git", "config", "--global", "user.email"], capture_output=True, text=True).stdout
mails = {m.strip().lower() for m in (account, git) if "@" in m}
ua = re.compile(r"user[-_]?agent|\bcurl\b.*\s-A\s|\bwget\b.*\s-U\s", re.I | re.S)

seen = set()
for f in (home / ".claude" / "projects").rglob("*.jsonl"):
    for line in f.open(errors="replace"):
        try:
            content = (json.loads(line).get("message") or {}).get("content")
        except (ValueError, AttributeError):
            continue
        for part in content if isinstance(content, list) else []:
            if not isinstance(part, dict) or part.get("type") != "tool_use" or part.get("id") in seen:
                continue
            seen.add(part.get("id"))
            text = json.dumps(part.get("input"))
            if ua.search(text) and any(m in text.lower() for m in mails):
                print(f.stem[:8], part.get("name"), text[:160])

Mitigation

  1. Update. Run 2.1.317 or later now (claude update), then the release that drops the address from the context, which Anthropic expects in the week of 9 November.

  2. Configure. Make sure enabledPlugins does not set cc-plugin-privacy@builtin to false. Point git config --global user.email at a no-reply address on machines that run agents. Tell the model which contact to use, in CLAUDE.md:

    When a site wants a contact in the User-Agent, use
    "my-agent/1.0 (+https://example.com/agent)". Never use my email or my git email.
  3. Add a stricter hook. This one also strips quotes, knows the git address, and refuses any call that reads the address at run time:

    #!/usr/bin/env python3
    # PreToolUse hook: deny a call that carries or reads your email next to a network client.
    import json, pathlib, re, subprocess, sys
    
    call = json.load(sys.stdin)
    tool, text = call.get("tool_name", ""), json.dumps(call.get("tool_input", {})).lower()
    
    mails = set()
    try:
        mails.add(json.load(open(pathlib.Path.home() / ".claude.json"))["oauthAccount"]["emailAddress"])
    except (OSError, KeyError, ValueError):
        pass
    for scope in ("--global", "--local"):
        mails.add(subprocess.run(["git", "config", scope, "user.email"], capture_output=True, text=True).stdout.strip())
    mails = {m.lower() for m in mails if "@" in m}
    
    plain = re.sub(r"[\"'`\\]", "", re.sub(r"%([0-9a-f]{2})", lambda m: chr(int(m[1], 16)), text))
    holds = any(m in text or m in plain for m in mails)
    reads = re.search(r"git\s+config[^|;&]*user\.email|\.gitconfig|oauthaccount|\.claude\.json", plain)
    net = re.search(r"\b(curl|wget|https?|httpie|xh|nc|ncat|socat|telnet|ssh|scp|rsync|ftp|requests|urllib|fetch|axios)\b", plain)
    
    if (holds or reads) and (tool in ("WebFetch", "WebSearch") or net):
        print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse",
            "permissionDecision": "deny",
            "permissionDecisionReason": "Call carries or reads the user's email. Use a generic client name or ask the user."}}))

    Save it as ~/.claude/hooks/no-email-out.py and register it in ~/.claude/settings.json:

    {"hooks": {"PreToolUse": [{"matcher": "Bash|WebFetch|WebSearch|Monitor",
      "hooks": [{"type": "command", "command": "python3 -I ~/.claude/hooks/no-email-out.py"}]}]}}

    It still lets through an address built from variables or base64. Text matching cannot catch a value the command assembles, and only taking the address away from the model closes that gap.

Verification

A version number tells you the guard is installed. Check that it is enabled, then rerun the probe and watch the server log:

claude --version   # 2.1.317 or later
python3 -c 'import json,os;print(json.load(open(os.path.expanduser("~/.claude/settings.json"))).get("enabledPlugins",{}).get("cc-plugin-privacy@builtin","default (on)"))'

With the hook loaded through --settings, our rerun gave:

L1  PreToolUse:Bash hook error: Call carries or reads the user's email. Use a generic client name or ask the user.
L2  PreToolUse:Bash hook error: Call carries or reads the user's email. Use a generic client name or ask the user.
L4  ok      (curl -s -A "probe-L4/1.0 (+https://example.com/agent)")

Only the L4 request reached the server. Once Anthropic’s fix ships, grep -a -c "The user's email address is" on the new binary should print 0; on 2.1.319 it prints 2.

Sources

Back to all articles

Technologies we work with

  • Argo CD
  • Kubernetes
  • Docker
  • Podman
  • Ansible
  • Jenkins
  • Chef
  • Proxmox VE
  • Google Cloud
  • Linux
  • Red Hat
  • CentOS
  • NGINX
  • Apache
  • Traefik
  • Prometheus
  • Grafana
  • PostgreSQL
  • MySQL
  • MariaDB
  • Redis
  • etcd
  • Qdrant
  • Elasticsearch
  • Logstash
  • Kibana
  • PagerDuty
  • Go
  • Python
  • FastAPI
  • PHP
  • TypeScript
  • React