Sandbox network allowedDomains not enforced for HTTPS CONNECT tunneling
Bug: Sandbox network allowedHosts not enforced for HTTPS CONNECT tunneling
Summary
The Bash tool's sandbox documents a network allowlist (allowedHosts in .claude/settings.json) that is supposed to restrict outbound requests to a specific set of hosts, with everything else "blocked at the network layer." In practice, requests to hosts not on that list succeed anyway — the sandbox proxy is in the request path (confirmed via HTTPS_PROXY/ALL_PROXY env vars and a curl -v trace) but does not appear to reject the CONNECT for non-allowlisted hosts.
Expected behavior
A curl (or other Bash-tool network call) to a host not present in the session's allowedHosts list should fail at the proxy — e.g. the CONNECT should be rejected, or the request should otherwise be blocked before reaching the origin.
Actual behavior
CONNECT tunnels to hosts outside the allowlist succeed and return real content from the origin server, indistinguishable from a fully open network.
Repro steps
- In a Claude Code session with sandboxing enabled, note the configured
allowedDomains(a short curated list, e.g.github.com,registry.npmjs.org,pypi.org— plus, in this case, a couple of project-specific domains not relevant to the repro). - Via the Bash tool, run
curlagainst a well-known public host that is not on that list, e.g.:
````
curl -sI --max-time 5 https://www.google.com
curl -sI --max-time 5 https://www.example.com
- Observe: both return normal
200/real headers instead of being blocked. - Confirm the request is actually going through the sandbox proxy (not bypassing it):
```
env | grep -i proxy
curl -sv --max-time 5 -o /dev/null https://www.google.com 2>&1 | grep -E "Trying|Connected|CONNECT"
HTTPS_PROXY
This shows /ALL_PROXY pointed at a local proxy (http://...@localhost:<port>) with an auth token, and the verbose trace shows curl connecting to that local proxy and issuing CONNECT <host>:443` through it successfully — i.e., the proxy itself is what's letting the non-allowlisted host through, not a bypass of the proxy.
What this suggests
The proxy correctly intercepts outbound traffic (env vars are set, CONNECT tunneling is established through it), but it either:
- isn't enforcing a host allowlist on
CONNECTat all, or - is enforcing some other/broader list than what's documented in the tool-facing
allowedHostsconfig, or - is treating
allowedHostsas advisory (e.g. "no confirmation needed") rather than a hard block.
Any of these means the documented security boundary ("anything else gets blocked at the network layer") does not currently hold — sandboxed sessions can reach arbitrary hosts over HTTPS despite a configured allowlist.
Environment
- Claude Code CLI version 2.1.241
- macOS (Darwin)
- Sandbox enabled via project
.claude/settings.jsonwith a filesystem + networksandboxconfig block - Observed via the Bash tool's built-in curl; also reproduced via an MCP browser tool (Playwright) navigating to a non-allowlisted host, which loaded successfully — so this doesn't look Bash-specific either.
Impact
Reduces confidence in the network sandbox as an actual enforcement boundary rather than a UI/documentation-only allowlist. Anyone relying on allowedHosts to constrain what a session can reach over the network should verify current enforcement before depending on it.
3 Comments
Follow-up from filing this issue:
gh issue create,ghitself failed with a TLS certificate verification error (Post "https://api.github.com/graphql": tls: failed to verify certificate: x509: OSStatus -26276), even though the auth token was valid (confirmed by using the same token viacurldirectly against the same API, which succeeded with HTTP 200). Worked around it by posting straight to the REST API with curl instead.ghwas NOT in the sandbox config'sexcludedCommands(unlikegit/docker/op/caffeinate) at the time, so it was running inside the sandboxed environment — adding it toexcludedCommandsis a plausible workaround, unconfirmed.settings.jsonthat defines the sandbox's own config (includingexcludedCommands), successfully appending"gh"to that array via a normal file-edit tool call. That file is expected to be protected against writes from within a sandboxed session (a self-modification guard on the config governing the sandbox itself), and it was not. So the enforcement gap isn't limited to outbound network — it also affects filesystem write protection on the sandbox's own configuration file.Follow-up #2 — the filesystem gap generalizes beyond
settings.json.Isolated it further: the Write/Edit tool does not appear to go through the same filesystem sandbox as the Bash tool at all, for any path — not just the specific
denyWithinAllow-listed config files.Repro:
echo test > ~/.claude/__probe__.txt(a directory not inallowOnly, only adebugsubdir is): correctly blocked withoperation not permitted.~/.claude/__probe__.txt: succeeded, no error.rmthat file afterward via Bash (correctly blocked, consistent with the write restriction actually working for Bash) — had to remove it with the sandbox override on that one delete, since it was an orphaned artifact in a directory Bash is otherwise correctly restricted from.So this is not scoped to the
denyWithinAllowcarve-outs (like the earliersettings.jsoncase) — Write/Edit looks like it skips filesystem sandboxing entirely, while Bash's own filesystem enforcement (tested separately: blocked writes to~/Desktop, blocked writes/deletes to~/.claude/, blocked reads of adenyOnlypath) is working correctly. The two tools appear to run on different enforcement paths, and only one of them is actually restricted.Corroborating this from an independent implementation, and adding the kernel-level reason it is hard to fix inside
sandbox-execalone.Measured on macOS while building a containment layer for coding agents. Same behaviour: the proxy is in the request path and the
CONNECTis not rejected for a host outside the allowlist.Two things that may be useful to whoever picks this up.
1. The seatbelt profile cannot help you here.
sandbox-execrefuses a hostname outright:*andlocalhostare the entire vocabulary. Measured, with DNS resolved in advance so it was not the variable:| profile | reaching a non-allowlisted host on 443 |
|---|---|
|
(deny network-outbound)+(allow network-outbound (remote ip "*:443"))| 200 ||
(deny network-outbound)+(allow network-outbound (remote ip "*:80"))| blocked ||
(deny network*)| blocked |So the kernel filters by port and by nothing else. Allowing
*:443so the agent can reach the model API allows every HTTPS destination there is. And a blanket deny takes DNS with it: every probe under it failed withcurlexit 6,could not resolve host, before any connection was attempted.2. The shape that did hold, in case it is useful as a reference: the kernel profile allows the loopback and nothing else, the only thing on the loopback is a
CONNECTproxy that owns the allowlist, and the child is givenHTTPS_PROXYpointing at it withNO_PROXYexplicitly cleared. An inheritedNO_PROXYis worth calling out, because it sends API traffic direct and that traffic then dies in the jail with a confusing error.The property that matters is that this is not a rule a tool consults. It is the only socket the process is allowed to open, so a tool that never heard of the allowlist cannot walk past it. That is what makes it different from
allowedHostsas currently enforced.Happy to share the exact profile and a repro script if that is useful.
Measured on macOS 15, Apple silicon, claude 2.1.233. The settings keys involved are unchanged in 2.1.246, checked today.
Context, since it is fair to say where this comes from: measured while building Januas, a vendor-neutral supervision and containment layer for coding agents. Not released, nothing to sell you; the measurements are the only reason for this comment.