blob: 1df9440e645ee3cc4c0aeef08d43138083f3c644 [file] [view]
<!-- SPDX-License-Identifier: Apache-2.0
https://www.apache.org/licenses/LICENSE-2.0 -->
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
**Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)*
- [Sandbox troubleshooting](#sandbox-troubleshooting)
- [Shape of each entry](#shape-of-each-entry)
- [SSH agent / Yubikey appears unreachable from inside the sandbox](#ssh-agent--yubikey-appears-unreachable-from-inside-the-sandbox)
- [Symptom](#symptom)
- [Root cause](#root-cause)
- [Fix](#fix)
- [Notes](#notes)
- [Test cannot bind to a localhost port](#test-cannot-bind-to-a-localhost-port)
- [Symptom](#symptom-1)
- [Root cause](#root-cause-1)
- [Fix](#fix-1)
- [Notes](#notes-1)
- [Docker / Podman command fails with a socket error](#docker--podman-command-fails-with-a-socket-error)
- [Symptom](#symptom-2)
- [Root cause](#root-cause-2)
- [Fix](#fix-2)
- [Notes](#notes-2)
- [Adding a new entry](#adding-a-new-entry)
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
<!-- SPDX-License-Identifier: Apache-2.0
https://www.apache.org/licenses/LICENSE-2.0 -->
# Sandbox troubleshooting
The secure agent setup ([`secure-agent-setup.md`](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-doctor`](../../skills/setup-isolated-setup-doctor/SKILL.md)
skill 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](secure-agent-setup.md#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`](secure-agent-setup.md) — full install
walkthrough including the authoritative `~/.claude/settings.json`
reference.
- [`secure-agent-internals.md`](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:
1. **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.
2. **Root cause** — which sandbox layer (Seatbelt / bubblewrap /
Claude Code filesystem allowlist / network allowlist /
`permissions.deny`) is blocking the call, and why the
restriction exists.
3. **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.
4. **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:
```text
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](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`:
```jsonc
// ~/.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 system
`ssh-agent`. The wildcard `*` matches the launchd UUID; the
`Listeners` directory 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-support` in
`~/.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 to `allowRead` instead of the launchd glob.
- Do **not** widen `allowRead` to `/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
```text
[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:
```jsonc
// ~/.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), `localhost` is not enough — you
need to allow the actual remote IP in `allowedDomains`. Those
are project-scope concerns; add to `.claude/settings.json` in
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: true` in the Bash
tool call — but that surface should be visually loud (the
`sandbox-bypass-warn.sh` hook ensures it is). Prefer the
allowlist fix above when applicable.
---
## Docker / Podman command fails with a socket error
### Symptom
```text
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.
### 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.
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* without opening
the `~/.docker/` directory generally:
```jsonc
// ~/.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)
]
}
},
"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 to `connect(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.
- The narrowed `permissions.deny` keeps the agent's `Read` tool
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/` to `allowRead`.
- For **CI / image-build workflows** that run inside an adopter
repo, prefer adding the socket allow at project scope
(`.claude/settings.local.json` in 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 `allowRead` to `~/.docker/**` — the directory
holds auth tokens and saved contexts; the whole point of the
framework's `Read(~/.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:
1. 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.
2. Identify the layer: filesystem (`Operation not permitted` on a
path), network (refused / timed-out connection to an allowed
host's friend), or `permissions.deny` (the agent's tool got an
"I refuse" without the sandbox even being consulted).
3. Find the minimal widening — the most specific `allowRead` /
`allowedDomains` entry that resolves the symptom without
opening adjacent paths. Stay as specific as the runtime
reasonably allows; never widen `~/`, `/var/`, or `/private/`
as a whole.
4. 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.