Sandbox troubleshooting
September 9, 2026 · View on GitHub
The secure agent setup (secure-agent-setup.md)
runs every Bash subprocess inside a sandbox: Seatbelt on macOS,
bubblewrap on Linux, plus Claude Code's filesystem / network
allowlists. A correct sandbox restricts what the agent can read and
where it can talk; an over-restrictive one breaks legitimate
workflows in ways that look like unrelated bugs ("ssh-agent
unreachable", "address already in use", "Cannot connect to Docker
daemon"). This page is the catalog of those cases — the
symptom you see, the root cause in the sandbox config, and
the fix (a settings.json widening with a one-line rationale).
If you hit a sandbox-shaped failure not listed below, add it here in the same shape — the catalog grows by experience, not by prediction.
Two surfaces make these entries discoverable in-session so a future reader does not have to remember the catalog exists:
- The
setup-isolated-setup-doctorskill probes each catalogued failure mode on demand and links back to the matching entry. Invoke it when you suspect a sandbox restriction; it runs the full probe set even when only one is in question. - The
Sandbox-error hint hook
fires after every Bash tool call, pattern-matches the result
for the literal error strings catalogued below, and prints a
[sandbox-hint] …line pointing at the matching entry — so the catalog reference appears next to the error automatically.
When the catalog grows a new entry, extend both surfaces too:
add a matching probe to the doctor skill, and add a matching
match … hint=… branch to the hint hook. The catalog stays the
source of truth; the doctor and the hook stay the discoverability
layer.
Related:
secure-agent-setup.md— full install walkthrough including the authoritative~/.claude/settings.jsonreference.secure-agent-internals.md— how each layer of the sandbox works and why.
Shape of each entry
Every entry follows the same four sections so a future reader can pattern-match quickly:
- Symptom — the exact error message text the agent (or the user, in a terminal) sees. Verbatim where possible so a grep into this page surfaces the matching entry.
- Root cause — which sandbox layer (Seatbelt / bubblewrap /
Claude Code filesystem allowlist / network allowlist /
permissions.deny) is blocking the call, and why the restriction exists. - Fix — a concrete edit to
~/.claude/settings.json(or the adopter's project-local.claude/settings.local.json, where that scope makes more sense) shown as a JSON snippet. Per-entry rationale so the widening is auditable. - Notes — platform-specific path variants, alternative paths the same agent / runtime might use, when not to apply the widening.
SSH agent / Yubikey appears unreachable from inside the sandbox
Symptom
Any of:
sign_and_send_pubkey: signing failed for ED25519 "user@host": agent refused operation
Could not open a connection to your authentication agent.
ssh-add: error fetching identities for protocol 1: communication with agent failed
Permission denied (publickey).
…on git push, ssh user@host, ssh-add -l, or any operation
that consults ssh-agent. The variant the user reports as
"Yubikey badly detected" — the Yubikey is plugged in and works
outside the sandbox, but the agent inside the sandbox can't reach
its socket.
Root cause
SSH_AUTH_SOCK is passed through the claude-iso clean-env
wrapper's whitelist (see secure-agent-setup.md → The clean-env
wrapper), so the
environment variable is set inside the sandbox. The socket path
it points at is the missing piece: on macOS the path is typically
/private/tmp/com.apple.launchd.*/Listeners, which is not in any
allowRead entry; on Linux it is typically
/run/user/<uid>/keyring/ssh or a gpg-agent variant, only the
gpg-agent path of which is currently allowed
(/run/user/*/gnupg/).
Without read access to the socket file, the agent's ssh /
git push subprocesses get Operation not permitted when they
try to connect(2) the unix-domain socket — but the userland
error surfaces as the "agent unreachable" / "Permission denied"
strings above, which is what makes the cause non-obvious.
Fix
Add the SSH agent socket directories to sandbox.filesystem.allowRead:
// ~/.claude/settings.json
{
"sandbox": {
"filesystem": {
"allowRead": [
// ...existing entries...
"/private/tmp/com.apple.launchd.*/Listeners", // macOS: system launchd-managed ssh-agent socket
"/private/tmp/ssh-*/agent.*" // macOS: openssh-portable variant (rare)
// Linux: `~/.gnupg/` and `/run/user/*/gnupg/` are already in the framework reference;
// add `/run/user/*/keyring/` here if you use gnome-keyring or seahorse for SSH.
]
}
}
}
Per-entry rationale:
/private/tmp/com.apple.launchd.*/Listeners— Apple's launchd manages per-session daemon sockets including the systemssh-agent. The wildcard*matches the launchd UUID; theListenersdirectory holds the actual socket files. This is the default path on macOS./private/tmp/ssh-*/agent.*— fallback for openssh-portable running outside launchd (uncommon on stock macOS, sometimes seen with Homebrew-installed openssh).
Notes
- If you use gpg-agent for SSH (
enable-ssh-supportin~/.gnupg/gpg-agent.conf), no extra entry is needed — the framework reference already includes~/.gnupg/and/run/user/*/gnupg/, which cover the gpg-agent SSH socket (S.gpg-agent.ssh) on both platforms. - If you use Secretive (an alternative macOS Yubikey
agent), the socket lives under
~/Library/Group Containers/<bundle>/socket.ssh; add that specific path toallowReadinstead of the launchd glob. - Do not widen
allowReadto/private/tmp/**— that opens the entire system temp directory, which other processes use for arbitrary files including credentials. Stay specific.
Test cannot bind to a localhost port
Symptom
[Errno 13] Permission denied
[Errno 49] Can't assign requested address
OSError: [Errno 98] Address already in use # red herring when sandbox-related
…from a test that starts a fixture server (pytest with
live_server, requests-mock, an integration test spinning up a
local HTTP listener, a webhook fixture). The same test passes
outside the sandbox.
Root cause
Claude Code's sandbox.network block is allowlist-based on
outbound hosts (egress to named domains), not on inbound
binds. For most listener types this is fine — bind(2) on
127.0.0.1 doesn't go through the network namespace at all on
macOS, and on Linux loopback is allowed by default.
The case that bites is a test that needs to talk to its own
server over the loopback interface: the test binds (works),
the test's HTTP client then tries to GET http://127.0.0.1:NNNN/
(may fail), because the sandbox's network allowlist does not
include 127.0.0.1 or localhost and the egress proxy treats it
as a disallowed destination.
The "Permission denied" / "Address already in use" texts the test runner surfaces are its own framework's generic error strings, not the sandbox's — which makes the root cause hard to spot.
Fix
Add localhost and 127.0.0.1 to the network allowlist:
// ~/.claude/settings.json
{
"sandbox": {
"network": {
"allowedDomains": [
// ...existing entries...
"localhost", // local fixture servers, test webhooks
"127.0.0.1" // same; IP form for tests that use it directly
]
}
}
}
Per-entry rationale:
localhost/127.0.0.1— loopback only. Adding these does not widen the egress surface (no traffic leaves the host); it just lets the sandbox proxy stop treating loopback as a disallowed destination.
Notes
- For tests that need an outbound port (e.g. an integration test
that listens on a port and then a separate process connects from
outside the test's own runtime),
localhostis not enough — you need to allow the actual remote IP inallowedDomains. Those are project-scope concerns; add to.claude/settings.jsonin the adopter repo rather than the user-scope file. - If a test is genuinely incompatible with the sandbox (e.g. it
expects raw socket access to a privileged port), the per-call
escape hatch is
dangerouslyDisableSandbox: truein the Bash tool call — but that surface should be visually loud (thesandbox-bypass-warn.shhook ensures it is). Prefer the allowlist fix above when applicable.
Docker / Podman command fails with a socket error
Symptom
Cannot connect to the Docker daemon at unix:///Users/<user>/.docker/run/docker.sock. Is the docker daemon running?
ERRO[0000] error connecting to /var/run/docker.sock: open /var/run/docker.sock: operation not permitted
Cannot connect to Podman. Please verify your connection to the Linux system using `podman system connection list`
…on any docker / podman / nerdctl invocation. The CLI is
installed and the runtime is running on the host — the sandbox is
just blocking access to its socket.
On macOS with Docker Desktop the failure usually arrives earlier than that, as one of:
zsh: operation not permitted: docker
docker: unknown command: docker compose
The first means the sandbox is blocking the docker binary itself;
the second means it is blocking the CLI plugins. Neither reaches the
socket at all, so the socket allowlist below does not fix them on its
own — see the CLI paths in the same block.
A third form appears once the CLI runs but the connection is still refused, on every platform:
permission denied while trying to connect to the docker API at unix:///var/run/docker.sock
That one is the missing sandbox.network.allowUnixSockets entry, not a
filesystem permission — see below.
Root cause
The runtime CLI talks to its daemon via a unix-domain socket. The
framework's reference ~/.claude/settings.json has
Read(~/.docker/**) in permissions.deny (to keep the agent
from reading Docker credentials stored under ~/.docker/config.json)
and lists ~/.docker in the broader filesystem denyRead set.
Both block the socket file under ~/.docker/run/docker.sock,
which is where Docker.app for Mac drops its socket.
On macOS the same ~/.docker denial also blocks two things that
are not the socket, and that a socket-only allowlist therefore
leaves broken:
- The CLI binary. Docker Desktop installs it inside the denied
directory —
dockeronPATHis~/.docker/bin/docker, a symlink into/Applications/Docker.app. Denied, the shell cannot execute it at all (operation not permitted: docker). - The CLI plugins.
docker composeanddocker buildxare not builtins; they are separate binaries in~/.docker/cli-plugins/. Denied,docker psworks whiledocker composereportsunknown command, which breaks any compose-driven workflow.
Separately, and on every platform: listing a socket in
sandbox.filesystem.allowRead grants permission to read the file,
not to connect to it. Socket connections are gated by their own
sandbox.network.allowUnixSockets list. With the path allowed for
reading but absent from that list, the CLI starts, finds the socket,
and is refused at connect(2):
$ docker ps
permission denied while trying to connect to the docker API at unix:///var/run/docker.sock
Both settings are required; neither substitutes for the other. This is
not macOS-specific — a Linux adopter using /var/run/docker.sock needs
the allowUnixSockets entry just the same.
For Colima the socket lives under ~/.colima/... (not currently
covered by any allow / deny in the framework reference, so it
works by default), and for rootless Podman it lives under
$XDG_RUNTIME_DIR/podman/... (also not covered → works). The
case that fails is specifically Docker.app on macOS plus the
generic ~/.docker denial.
Fix
Allow Bash subprocesses to read the socket file, the CLI, and its
plugins without opening the ~/.docker/ directory generally:
// ~/.claude/settings.json
{
"sandbox": {
"filesystem": {
"allowRead": [
// ...existing entries...
"~/.docker/run/docker.sock", // Docker.app for Mac socket
"~/.colima/default/docker.sock", // Colima default socket (defensive; usually not blocked)
"/var/run/docker.sock", // Linux daemon socket (root-managed install)
"~/.docker/bin/", // Docker Desktop CLI binaries (`docker` itself lives here on macOS)
"~/.docker/cli-plugins/" // `docker compose`, `docker buildx` — separate plugin binaries
]
},
"network": {
// Reading the socket file is not the same permission as connecting
// to it. Without these, the CLI runs but every command is refused
// with "permission denied while trying to connect to the docker API".
"allowUnixSockets": [
"/var/run/docker.sock",
"~/.docker/run/docker.sock"
]
}
},
"permissions": {
"deny": [
// ...existing entries...
"Read(~/.docker/config.json)", // keep this denial — credentials live here
"Read(~/.docker/contexts/**)" // keep this denial — saved contexts
// (Replace the broad `Read(~/.docker/**)` with these two specific paths.)
]
}
}
Per-entry rationale:
~/.docker/run/docker.sock— Docker.app for Mac's socket location. Read access on the socket file is what the docker CLI needs toconnect(2)to the daemon.~/.colima/default/docker.sock— Colima's default; explicit even though it works today, to anticipate a future widening of the generic~/.denial./var/run/docker.sock— Linux systems with daemon Docker; socket is root-managed but world-readable by convention.~/.docker/bin/— Docker Desktop for Mac installs thedockerCLI here (as a symlink into/Applications/Docker.app), so without it the binary cannot be executed and no socket entry matters. Not needed for Homebrew or Linux installs, where the CLI lives on a normalPATHdirectory outside~/.docker.~/.docker/cli-plugins/—docker composeanddocker buildxare plugin binaries, not builtins. Without itdocker pssucceeds butdocker composefails asunknown command.sandbox.network.allowUnixSockets— the connect-side permission, required on every platform.allowReadon the same path only lets a process open the file; the sandbox gates socket connections through this separate list. Verified by removing it while leaving theallowReadentries in place:docker compose versionstill ran, anddocker psfailed withpermission denied while trying to connect to the docker API.- The narrowed
permissions.denykeeps the agent'sReadtool from seeing Docker auth tokens (config.json) and saved contexts (which include host IPs and credentials), while allowing the Bash subprocess to use the socket.
Notes
- For rootless Podman, the socket is at
$XDG_RUNTIME_DIR/podman/podman.sock(typically/run/user/<uid>/podman/podman.sock). Currently allowed by default because the framework reference does not deny/run/user/<uid>/; if a future widening adds such a denial, add/run/user/*/podman/toallowRead. - For CI / image-build workflows that run inside an adopter
repo, prefer adding the socket allow at project scope
(
.claude/settings.local.jsonin the adopter) rather than user scope — that keeps the framework's user-scope reference minimal and makes the widening visible to whoever audits the adopter's repo. - Do not widen
allowReadto~/.docker/**— the directory holds auth tokens and saved contexts; the whole point of the framework'sRead(~/.docker/**)denial is to keep those out of the agent's reach.
Adding a new entry
When you hit a sandbox-shaped failure not in this list:
- Capture the exact symptom (error text, command, what you were trying to do). The error text is what makes the entry greppable for the next person.
- Identify the layer: filesystem (
Operation not permittedon a path), network (refused / timed-out connection to an allowed host's friend), orpermissions.deny(the agent's tool got an "I refuse" without the sandbox even being consulted). - Find the minimal widening — the most specific
allowRead/allowedDomainsentry that resolves the symptom without opening adjacent paths. Stay as specific as the runtime reasonably allows; never widen~/,/var/, or/private/as a whole. - Add an entry to this page in the Shape of each entry form above. Cross-reference adjacent entries when relevant.
If the fix involves dangerouslyDisableSandbox: true rather than
a settings.json widening, document it here too — the bypass is a
legitimate per-call escape hatch, but it should be visible in the
catalog so future readers can see when it's the right call.