A month ago I wrote about a canary that got out. Someone put the Grok Build CLI behind mitmproxy, planted API_KEY=CANARY7F3A9-SECRET-should-not-leave in a repo, and watched it come back verbatim in the capture.
The post ended on a line I still stand by: you cannot audit a promise, you can audit a wire.
The obvious follow-up question was never answered. Fine, I looked at the wire. Now what do I actually do about the fact that my agent's shell can read every token on the box?
Claude Code has grown an answer in stages: masked environment variables since 2.1.199, masked credential files since 2.1.221, and since 2.1.224 on the 7th of August, the options that make it work on real values instead of bare tokens. Everything below was checked on 2.1.231.
The idea: your shell never holds the secret
Credential masking sits between two blunt options you already had.
You could let the agent see GH_TOKEN, and hope. Or you could deny it, at which point gh stops working and the agent starts asking you to run things by hand.
Masking splits the difference. The sandboxed command sees a per-session sentinel, a fake placeholder value. When a request leaves the sandbox for a host you named, the sandbox proxy swaps the sentinel for the real credential on the way out.
The command authenticates. The command never held the secret. Anything it logs, echoes into a transcript, or accidentally posts to a paste service holds the sentinel.
Here is the minimum that works:
{
"sandbox": {
"enabled": true,
"network": {
"tlsTerminate": {},
"allowedDomains": ["*.github.com", "registry.npmjs.org"]
},
"credentials": {
"envVars": [
{ "name": "GH_TOKEN", "mode": "mask", "injectHosts": ["api.github.com"] },
{ "name": "NPM_TOKEN", "mode": "mask" }
]
}
}
}injectHosts is the part to get right. GH_TOKEN is substituted only on requests to api.github.com. NPM_TOKEN has no injectHosts, so it goes to every host in allowedDomains, which is lazier than it looks. Every entry in injectHosts must also be covered by allowedDomains.
tlsTerminate is not optional. The proxy has to see inside the request to swap anything, so it has to terminate TLS itself.
Leave it out and you get this at startup, which I triggered on purpose to check the wording:
⚠ sandbox.credentials mask entries (DEMO_TOKEN) are configured but TLS
termination is unavailable — sandboxed commands see only a sentinel value
and the proxy cannot substitute the real credential on egress, so tools
needing these will fail to authenticate.Read that failure mode carefully, because it is the good kind. Misconfigure this and the sentinel reaches the server unchanged. Your build breaks. Your secret does not leak. It fails closed.
Values with structure
Replacing the whole value suits a bare token. Plenty of credentials are not bare tokens.
extract takes a regular expression and replaces only what group 1 captures, so the rest of the value survives:
{ "name": "DATABASE_URL", "mode": "mask", "extract": "://[^:]+:([^@]+)@" }Now the sandboxed process still gets a parseable connection string with a real host, port and database name. Only the password is a placeholder.
Pair it with onExtractNoMatch, which decides what happens when the pattern finds nothing. The default is warn, and the default passes the variable through unmasked. That is the wrong choice for anything that matters. If the secret might be there and your pattern might miss it, set deny so the variable is unset instead, or error to stop sandbox setup until you fix it.
For a JWT, use decode instead. It cannot be combined with extract:
{
"name": "SERVICE_JWT",
"mode": "mask",
"decode": "jwt",
"maskClaims": ["sub", "email"],
"injectHosts": ["api.internal.example.com"]
}Claude Code verifies the value really is a JWT and hands the sandbox a structurally valid fake one, so code that decodes the token keeps working instead of throwing. maskClaims narrows it further: mask the claims that identify a human, leave iss and exp readable so your debugging still makes sense.
AWS, and the three requests that cannot be re-signed
AWS is the awkward case, because a SigV4 request is signed over its own contents. Substituting a key into a signed request produces a request with a broken signature.
The proxy handles it by detecting the access key's sentinel and re-signing after substitution. Which means you must mask AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY together. Mask the secret alone and the proxy cannot detect the request at all, so it goes to AWS signed with a placeholder and fails there.
The conventional variable names are paired automatically. Custom names need awsPairs:
{
"sandbox": {
"credentials": {
"awsPairs": [
{
"accessKeyIdVar": "MY_KEY_ID",
"secretAccessKeyVar": "MY_SECRET_KEY",
"sessionTokenVar": "MY_SESSION_TOKEN"
}
]
}
}
}Three request forms carry signatures the proxy cannot recompute: aws-chunked streaming uploads, presigned URLs, and SigV4A asymmetric signatures. By default these fail at the proxy rather than going out broken. The sigv4 setting relaxes that per form with passthrough, which forwards the request anyway so the calling tool gets AWS's own rejection instead of a proxy error. Use it to read the real error, then go and fix the config.
Your repo cannot switch this on
This is the design decision I like most, and the one worth testing rather than trusting.
Masking authorises the proxy to send your real credential to hosts listed in a config file. A config file is a thing that arrives when you clone something. So mask entries, tlsTerminate and awsPairs are honoured only from user settings, managed settings, and the --settings flag. A repository's .claude/settings.json is ignored.
I checked rather than assumed. I put identical mask entries in two scopes and ran both in a single session: DEMO_TOKEN via --settings, PROJ_TOKEN via the project's .claude/settings.json. The startup warning named DEMO_TOKEN only. The repo's entry was dropped without a word.
Silently, which is worth knowing. A colleague who puts this in the project config gets no error, just a mask that never happens.
This is the same boundary I traced when I looked at what a repo can and cannot enforce about permissions: a repository can always narrow what an agent may do, and never widen it.
What this does not buy you
Three limits, stated plainly, because a control you misunderstand is worse than one you skip.
It substitutes credentials, and only credentials. What else rides along in the request goes uninspected. Every concern from the Grok capture still stands: if a client decides to upload your repository to an allowed host, masking watches it go. This plugs the .env hole. It does not plug the upload hole.
You are now terminating your own TLS. tlsTerminate is still marked experimental, and enabling it means an intercepting proxy sits inside your loop holding real credentials in memory. That is a defensible trade against a shell full of live tokens, and it is a trade. The proxy is now the thing worth trusting.
The allowlist underneath it has failed before. Masking rides on the sandbox proxy, and that proxy's hostname allowlist carried a parser differential for five and a half months. Aonan Guan showed that a hostname like attacker-host.com\x00.google.com passed the JavaScript endsWith() check and then resolved somewhere else entirely, because libc's getaddrinfo() stops reading at the null byte. Every release from v2.0.24 through v2.1.89 was affected, silently patched in v2.1.90 on the 1st of April with no CVE against Claude Code itself. His own conclusion is the right frame for all of the above: treat the vendor sandbox as defence in depth, and put your real egress controls at the network or hypervisor level, outside the agent's reach.
One practical note for Linux and WSL2, which cost me a confused ten minutes: the sandbox needs socat alongside bubblewrap, and without it Claude Code quietly runs your commands unsandboxed with a warning at the top of the session. Run /sandbox and read the Dependencies tab before you assume any of this is active. The sandbox itself has to be working before masking means anything.
The July capture worked because one person distrusted the client enough to read the bytes. This is the first control I have seen that makes the bytes boring on purpose: let the agent work, let the request authenticate, and make sure the thing worth stealing was never in the room.
Go configure yours, then go look at the wire anyway.