Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 99 additions & 2 deletions docs/setup/sandbox-troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@
- [Root cause](#root-cause-11)
- [Fix](#fix-11)
- [Notes](#notes-10)
- [Every Bash call fails with `bwrap: Can't create file … Read-only file system`](#every-bash-call-fails-with-bwrap-cant-create-file--read-only-file-system)
- [Symptom](#symptom-12)
- [Root cause](#root-cause-12)
- [Fix](#fix-12)
- [`git status` shows files you never created, such as `.mcp.json` or `.bash_profile`](#git-status-shows-files-you-never-created-such-as-mcpjson-or-bash_profile)
- [Symptom](#symptom-13)
- [Root cause](#root-cause-13)
- [Fix](#fix-13)
- [Adding a new entry](#adding-a-new-entry)

<!-- END doctoc generated TOC please keep comment here to allow auto update -->
Expand Down Expand Up @@ -198,6 +206,12 @@ fails with `No agent running`, and `gpg-connect-agent` cannot start one
(`exit status 2`). Where that filter is active, an agent-dependent
command — a signed `git commit`, `git tag -s`, `ssh-add -l` — must run
with a per-call sandbox bypass, or outside the agent session entirely.
The one setting that lifts the filter is
`sandbox.network.allowAllUnixSockets: true` (`allowUnixSockets` is
macOS-only, so it cannot name the agent socket here). It opens every
socket the sandbox can see, not just the agent's, so set it only
together with the `denyRead` entries in
[Linux needs `allowAllUnixSockets`](secure-agent-setup.md#linux-needs-allowallunixsockets).
The allowlist entries below remain correct and still matter (gpg reads
the keyring through them); they simply do not restore the agent channel
on their own.
Expand Down Expand Up @@ -673,7 +687,7 @@ dial unix ./.apache-magpie-local/run/podman.sock: connect: operation not permitt
The first three lines are the CLI reaching straight for the real daemon socket or the podman machine's ssh identity, both denied by design.
The last two are the CLI reaching the container gateway's own socket instead.
`no such file or directory` means the gateway is not running for this project.
`operation not permitted` means its socket is not in `sandbox.network.allowUnixSockets`.
`operation not permitted` means its socket is not in `sandbox.network.allowUnixSockets` (macOS), or that `sandbox.network.allowAllUnixSockets` is not `true` (Linux).

Inside the sandbox, `podman machine list` prints an empty table even when the machine is running, because the machine directory under `~/.local/share/containers/podman/machine/` is unreadable.
An empty list from inside the sandbox is therefore not evidence that no machine exists.
Expand Down Expand Up @@ -705,7 +719,7 @@ See [Container gateway](secure-agent-setup.md#container-gateway) in the setup gu
| `failed to read identity "…/machine/machine": operation not permitted` | `CONTAINER_HOST` / `DOCKER_HOST` are unset, so the CLI fell back to its default connection instead of the gateway | Add the reference `env` block below to `.claude/settings.local.json` |
| `dial unix /.//.apache-magpie-local/run/podman.sock` — note the leading `/.//` | `CONTAINER_HOST` / `DOCKER_HOST` use a project-relative `unix://./…` value, which the CLIs do **not** resolve against the cwd | Use the absolute `unix:///<project>/…` spelling in the `env` block below |
| `dial unix /<run-dir>/podman.sock: connect: no such file or directory` | The gateway is not running for this project. It exits after its idle timeout (4 h), when another session on the project ends, or when it crashes; the `PreToolUse` `ensure` hook restarts it before the next container command, so this error means that hook is not wired or could not start it | Wire the `ensure` hook per [Container gateway](secure-agent-setup.md#container-gateway), run `~/.claude/scripts/container-gateway-hook.sh start </dev/null` from a terminal in the project root (the hook reads its payload from stdin; with none it uses the current directory), or check `<run-dir>/container-gateway.log` for why it did not start |
| `dial unix /<run-dir>/podman.sock: connect: operation not permitted` | The gateway is running but its socket is missing from `sandbox.network.allowUnixSockets` | Add both gateway sockets as absolute paths, per [Container gateway](secure-agent-setup.md#container-gateway) |
| `dial unix /<run-dir>/podman.sock: connect: operation not permitted` | The gateway is running but its socket is missing from `sandbox.network.allowUnixSockets` (macOS), or `allowAllUnixSockets` is off (Linux, where the seccomp filter blocks every `AF_UNIX` socket) | Add both gateway sockets as absolute paths, per [Container gateway](secure-agent-setup.md#container-gateway); on Linux set `allowAllUnixSockets` with its `denyRead` entries, per [Linux needs `allowAllUnixSockets`](secure-agent-setup.md#linux-needs-allowallunixsockets) |
| `no podman or docker backend found; nothing to serve` in the gateway log, while `podman` works by hand | On macOS the gateway asked `podman machine inspect` for the socket path, and that command renders it from the **caller's** `TMPDIR` | Update the framework: discovery now also probes `getconf DARWIN_USER_TEMP_DIR`/`podman/`, so a hook whose `TMPDIR` differs from the machine's still finds the socket |

```jsonc
Expand Down Expand Up @@ -1280,6 +1294,89 @@ Rationale:
user-scope `~/.claude/settings.json` synced across machines: both paths
name this host's home directory and uid.

---

## Every Bash call fails with `bwrap: Can't create file … Read-only file system`

### Symptom

Every Bash tool call fails before the command runs, whatever the command,
with:

```text
bwrap: Can't create file at <project>/.claude/settings.json: Read-only file system
```

The path named is one of the files the sandbox protects from writes
(`.claude/settings.json`, `.mcp.json`, a shell rc), inside a directory that
`sandbox.filesystem.allowWrite` lists.

### Root cause

Linux only. A `sandbox.filesystem.allowRead` entry that is a parent of an
`allowWrite` path — `allowRead: ["/home/<you>/repos"]` next to
`allowWrite: ["/home/<you>/repos/<project>"]`, under the reference
`denyRead: ["~/"]` — makes bubblewrap mount the parent read-only on top of
the writable child. The write path ends up read-only, and bubblewrap's
first step, creating a placeholder for each write-protected file that does
not exist yet, fails, so the whole command fails. The mount order is
Claude Code's; the trap is in the settings shape.

### Fix

Never list a parent of an `allowWrite` path in `allowRead`. List the
specific directories you need read instead:

```jsonc
{
"sandbox": {
"filesystem": {
"allowRead": [
// not "/home/<you>/repos"
"/home/<you>/repos/<project>",
"/home/<you>/repos/<other-project>"
],
"allowWrite": ["/home/<you>/repos/<project>"]
}
}
}
```

A parent that is itself in `allowWrite` is fine. `sandbox-lint` rejects an
`allowRead` entry that is a strict parent of an `allowWrite` entry and not
writable itself.

---

## `git status` shows files you never created, such as `.mcp.json` or `.bash_profile`

### Symptom

Inside the sandbox, `git status` lists untracked files that are not there
when you look from your own terminal — typically `.claude/settings.json`,
`.mcp.json`, or `.bash_profile`. `stat` shows they are not regular files:

```console
$ stat -c '%F %t,%T' .mcp.json
character special file 1,3
```

### Root cause

Device `1,3` is `/dev/null`. On Linux the sandbox protects a
write-denied path that does not exist on the host — the agent settings
files, `.mcp.json`, shell rc files — by bind-mounting `/dev/null` over a
placeholder at that path, so a sandboxed command cannot create it. Git
inside the sandbox sees the placeholder as a new file. An absent
`denyRead` path gets no placeholder: there is nothing to hide, so nothing
is mounted.

### Fix

None needed: it is not a real file on the host. Do not commit it and do not
delete it. Check from outside the sandbox (your own terminal) whether a real
file is there before acting on it.

## Adding a new entry

When you hit a sandbox-shaped failure not in this list:
Expand Down
72 changes: 70 additions & 2 deletions docs/setup/secure-agent-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
- [Container gateway](#container-gateway)
- [Why install it](#why-install-it-1)
- [Install (user-scope)](#install-user-scope-4)
- [Linux needs `allowAllUnixSockets`](#linux-needs-allowallunixsockets)
- [Egress](#egress)
- [Verify](#verify-4)
- [Trade-offs](#trade-offs-3)
Expand Down Expand Up @@ -561,6 +562,9 @@ The full block below is the two merged, annotated.
]
},
"network": {
// Linux ignores allowUnixSockets and blocks every AF_UNIX socket; the gateway and
// ssh-agent signing need a per-machine `allowAllUnixSockets: true` there, never
// without the host-socket denyRead entries in "Linux needs allowAllUnixSockets" below.
"allowUnixSockets": [ // macOS only (ignored on Linux): sockets a sandboxed Bash may connect(2) to. A read entry alone lets it stat the file, not talk to it.
"/Users/<you>/.gnupg/S.gpg-agent.ssh" // gpg-agent's ssh socket — needed for signed commits and pushes over ssh; absolute path (see "SSH agent / Yubikey appears unreachable" in sandbox-troubleshooting.md)
// Per project, local settings (`.claude/settings.local.json`,
Expand Down Expand Up @@ -2950,6 +2954,58 @@ The CLIs do not resolve it against the cwd: a `unix://` URL's authority is parse

Never add the real daemon socket to `allowUnixSockets` under any name: the framework's `sandbox-lint` tool rejects an entry whose basename is `docker.sock`, `podman.sock`, or ends in `-api.sock`, unless its parent directory is the gateway's run directory, `<project>/.apache-magpie-local/run` or `<git-common-dir>/apache-magpie/run/<worktree-id>`.

### Linux needs `allowAllUnixSockets`

The `allowUnixSockets` entries above are macOS-only: Claude Code ignores them on Linux, where its seccomp filter rejects `socket(AF_UNIX, …)` before any path is consulted ([SSH agent / Yubikey appears unreachable](sandbox-troubleshooting.md#ssh-agent--yubikey-appears-unreachable-from-inside-the-sandbox)).
On Linux the sandboxed `podman` / `docker` CLI reaches the gateway, and a sandboxed `git` reaches the ssh agent to sign, only with `sandbox.network.allowAllUnixSockets: true`, which skips that filter.
Keep the `allowUnixSockets` entries anyway: they are inert on Linux and keep the block valid on a Mac.

`allowAllUnixSockets` lets a sandboxed command connect to **every** Unix socket whose path it can see, and the reference `denyRead: ["~/"]` hides nothing outside your home.
Pair it with `sandbox.filesystem.denyRead` entries, as absolute paths, for the host's daemon and IPC sockets.
Each one is a way out of the sandbox:

| Path | What a sandboxed command could do through it |
|---|---|
| `/run/docker.sock`, `/var/run/docker.sock` | Drive the Docker daemon: root-equivalent when you are in the `docker` group |
| `/run/docker-cli.sock` | The same, through Docker Desktop's CLI socket |
| `/run/podman` | Drive rootful Podman's daemon (`podman.sock`) |
| `/run/user/<uid>` | Run commands outside the sandbox through the systemd user manager, over the user D-Bus (`bus`) or its own `systemd/private` socket (`systemd-run --user`); drive rootless Podman (`podman/podman.sock`); reach editor askpass sockets (`vscode-git-*.sock`) and systemd's ssh agent |
| `/run/dbus` | Talk to the system D-Bus |
| `/tmp/.X11-unix` | Inject keystrokes into your X11 session |
| `/run/WSL` (WSL only) | Launch Windows executables outside the sandbox through the interop sockets |
| `/mnt/wslg` (WSL only) | Reach the WSLg display and audio sockets |

Deny `/run/user/<uid>` whole: the user manager answers on `systemd/private` even with the bus denied, so denying single sockets in it leaves a way out.
One channel stays open whatever `denyRead` says: on Linux the flag also unblocks `AF_VSOCK` sockets (per Claude Code's settings schema), which have no path to deny. On WSL2 that is a channel to the Windows host. Treat it as residual risk.

```jsonc
// per machine: ~/.claude/settings.local.json, or the project's .claude/settings.local.json
{
"sandbox": {
"network": { "allowAllUnixSockets": true },
"filesystem": {
"denyRead": [
"/run/docker.sock", "/var/run/docker.sock", "/run/docker-cli.sock", "/run/podman",
"/run/user/<uid>", "/run/dbus", "/tmp/.X11-unix",
"/run/WSL", "/mnt/wslg" // WSL only
]
}
}
}
```

Replace `<uid>` with the output of `id -u` (for example `/run/user/1000`). Pasted as-is, the entry names a path that does not exist and hides nothing; sandbox-lint and check 12e both flag it.

The gateway's sockets are unaffected: its run directory is inside the project tree or the git directory (`<project>/.apache-magpie-local/run/` or `<git-common-dir>/apache-magpie/run/<worktree-id>/`), never under `/run/user`.
The ssh agent may not be: run `echo "$SSH_AUTH_SOCK"` in your own terminal.
An agent socket under `/tmp` (the classic `ssh-agent`) needs nothing.
When it is under `/run/user/<uid>/`, add the socket path itself to `allowRead`, or the agent's own subdirectory for gpg-agent (`gnupg/`) and gnome-keyring (`keyring/`) — never `/run/user/<uid>/` itself, where systemd's agent socket sits directly and an entry would re-open the whole directory.
`allowRead` takes precedence inside a `denyRead` the same way `~/.gnupg/` does under `~/`; a single-file `allowRead` inside a denied directory has not been verified on Linux, so test signing after adding it.

Keep both halves in one per-machine file. `allowAllUnixSockets` lifts socket blocking on macOS too, so a user-scope `~/.claude/settings.json` [synced across machines](#syncing-user-scope-config-across-machines) would carry it to a Mac without the Linux denials.
Inside the sandbox each denied path that exists is replaced by an empty directory or a `/dev/null` device, so `test -S /run/docker.sock` reports no socket there.
`sandbox-lint` rejects `allowAllUnixSockets: true` in the project's settings layers without `denyRead` cover for every path in the table (it does not read user-scope files), and `setup-isolated-setup-verify` check 12e flags each one that exists on the host and is not denied, in any scope.

### Egress

At start, the gateway resolves the egress gateway's address for each backend and probes it once.
Expand All @@ -2971,7 +3027,7 @@ podman run --rm -v "$HOME/.ssh:/x" alpine true # expected: 403 container-gateway
```

`status` prints a JSON object (`running`, `pid`, `sockets`, `serving`) and exits 0 when the gateway is up for this project, 3 otherwise.
The `podman info` call should succeed from inside the sandbox once the hook has started the gateway and the two `allowUnixSockets` entries are in place.
The `podman info` call should succeed from inside the sandbox once the hook has started the gateway and the two `allowUnixSockets` entries (macOS) or `allowAllUnixSockets` with its `denyRead` entries (Linux) are in place.
The `podman run` call is expected to fail: a bind mount outside the project root (and any `--extra-bind-root`) is exactly what the policy refuses, and the `403` message is the gateway working as intended.

### Trade-offs
Expand Down Expand Up @@ -3653,7 +3709,9 @@ below and report ✓ done / ✗ missing / ⚠ partial, with the evidence
- The project `.claude/settings.json` or
`.claude/settings.local.json` has `env.CONTAINER_HOST` and
`env.DOCKER_HOST`, and both gateway sockets appear in
`sandbox.network.allowUnixSockets`.
`sandbox.network.allowUnixSockets`. On Linux, which ignores
`allowUnixSockets`, the socket half is instead
`sandbox.network.allowAllUnixSockets: true` in any scope.
- No scope (project, project-local, or user) lists a raw
daemon socket in `allowUnixSockets` — an entry whose
basename is `docker.sock`, `podman.sock`, or ends in
Expand All @@ -3664,6 +3722,16 @@ below and report ✓ done / ✗ missing / ⚠ partial, with the evidence
On any ✗, point at
[`docs/setup/sandbox-troubleshooting.md` → Docker / Podman command fails with a socket error](sandbox-troubleshooting.md#docker--podman-command-fails-with-a-socket-error)
rather than re-explaining the fix.
Separately, and even when the rest of this check is n/a (the flag
may be set only for ssh-agent signing): on Linux, when any scope
sets `sandbox.network.allowAllUnixSockets: true`, each of
`/run/docker.sock`, `/var/run/docker.sock`, `/run/docker-cli.sock`,
`/run/podman`, `/run/user/<uid>`, `/run/dbus`, `/tmp/.X11-unix`,
`/run/WSL` and `/mnt/wslg` that exists on this host must be
covered by a `sandbox.filesystem.denyRead` entry (the path or a
directory above it) in some scope; each one that is not is ✗,
naming the path (see
[Linux needs `allowAllUnixSockets`](#linux-needs-allowallunixsockets)).
13. **Eval-harness exclusion**, if installed (optional — report
n/a when neither the exclusion nor the script is present;
not running eval suites is a normal posture). When either is:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,12 @@ project-relative `unix://./…` value against the cwd — the URL
authority is read as a host component, so `unix://./x` dials
`/.//x` — and `allowUnixSockets` has no relative form either.
Nothing gateway-related is committed to `.claude/settings.json`.
On Linux, which ignores `allowUnixSockets`, the same diff also proposes
`sandbox.network.allowAllUnixSockets: true` together with
`sandbox.filesystem.denyRead` entries for each host daemon and IPC
socket path that exists on the machine, never the flag alone; the path
list and the `/run/user/<uid>` caveat are in
[docs/setup/secure-agent-setup.md → Linux needs `allowAllUnixSockets`](../../../../docs/setup/secure-agent-setup.md#linux-needs-allowallunixsockets).
Never propose the real
daemon socket under any name; `tools/sandbox-lint` rejects an
`allowUnixSockets` entry named `docker.sock` / `podman.sock` /
Expand Down
Loading
Loading