diff --git a/docs/setup/sandbox-troubleshooting.md b/docs/setup/sandbox-troubleshooting.md index 57f8a3626..bfb55994c 100644 --- a/docs/setup/sandbox-troubleshooting.md +++ b/docs/setup/sandbox-troubleshooting.md @@ -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) @@ -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. @@ -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. @@ -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:////…` spelling in the `env` block below | | `dial unix //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 /container-gateway.log` for why it did not start | -| `dial unix //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 //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 @@ -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 /.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//repos"]` next to +`allowWrite: ["/home//repos/"]`, 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//repos" + "/home//repos/", + "/home//repos/" + ], + "allowWrite": ["/home//repos/"] + } + } +} +``` + +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: diff --git a/docs/setup/secure-agent-setup.md b/docs/setup/secure-agent-setup.md index c98c5b646..89af72c8a 100644 --- a/docs/setup/secure-agent-setup.md +++ b/docs/setup/secure-agent-setup.md @@ -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) @@ -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//.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`, @@ -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, `/.apache-magpie-local/run` or `/apache-magpie/run/`. +### 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/` | 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/` 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/", "/run/dbus", "/tmp/.X11-unix", + "/run/WSL", "/mnt/wslg" // WSL only + ] + } + } +} +``` + +Replace `` 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 (`/.apache-magpie-local/run/` or `/apache-magpie/run//`), 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//`, add the socket path itself to `allowRead`, or the agent's own subdirectory for gpg-agent (`gnupg/`) and gnome-keyring (`keyring/`) — never `/run/user//` 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. @@ -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 @@ -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 @@ -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/`, `/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: diff --git a/plugins/magpie-setup/skills/isolated-setup-install/optional-steps.md b/plugins/magpie-setup/skills/isolated-setup-install/optional-steps.md index 119d22118..47bd26cbd 100644 --- a/plugins/magpie-setup/skills/isolated-setup-install/optional-steps.md +++ b/plugins/magpie-setup/skills/isolated-setup-install/optional-steps.md @@ -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/` 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` / diff --git a/plugins/magpie-setup/skills/isolated-setup-verify/SKILL.md b/plugins/magpie-setup/skills/isolated-setup-verify/SKILL.md index 5c7f3b856..c4757e544 100644 --- a/plugins/magpie-setup/skills/isolated-setup-verify/SKILL.md +++ b/plugins/magpie-setup/skills/isolated-setup-verify/SKILL.md @@ -18,7 +18,7 @@ when_to_use: >- capability: capability:platform surface_hash: sha256:ce33cb9437d44992 license: Apache-2.0 -measured_tokens: 7351 +measured_tokens: 7494 --- + +## Snapshot drift check + +cat .apache-magpie.lock: + method: git-branch + url: https://github.com/apache/magpie.git + ref: v0.9.1 + +cat .apache-magpie.local.lock: + method: git-branch + url: https://github.com/apache/magpie.git + ref: v0.9.1 + +Result: lock files match — no drift. + +--- + +## Check 1 — Project .claude/settings.json + +cat .claude/settings.json: +```json +{ + "sandbox": { + "enabled": true, + "network": {"allowedDomains": ["github.com", "api.github.com", "pypi.org"]}, + "filesystem": {"denyRead": ["~/"], "allowRead": ["."], "allowWrite": ["."]} + }, + "permissions": { + "deny": ["Read(~/.aws/**)", "Bash(curl *)"], + "ask": ["Bash(gh pr create *)"] + } +} +``` + +--- + +## Check 12 — Container gateway wired + +Platform: Linux. uname -s → Linux; id -u → 1000 +command -v podman → /usr/bin/podman + +~/.claude/settings.local.json hooks: + SessionStart → /home/alice/.claude/scripts/container-gateway-hook.sh start + SessionEnd → /home/alice/.claude/scripts/container-gateway-hook.sh stop +test -x /home/alice/.claude/scripts/container-gateway-hook.sh → exists, executable + +cat /home/alice/tracker/.claude/settings.local.json: +```json +{ + "env": { + "CONTAINER_HOST": "unix:///home/alice/tracker/.apache-magpie-local/run/podman.sock", + "DOCKER_HOST": "unix:///home/alice/tracker/.apache-magpie-local/run/docker.sock" + }, + "sandbox": { + "network": { + "allowAllUnixSockets": true, + "allowUnixSockets": [ + "/home/alice/tracker/.apache-magpie-local/run/podman.sock", + "/home/alice/tracker/.apache-magpie-local/run/docker.sock" + ] + } + } +} +``` + +sandbox.filesystem.denyRead in every scope: + .claude/settings.json → ["~/"] + .claude/settings.local.json → (none) + ~/.claude/settings.json → ["~/"] + +Host socket paths (test -e on the host): + /run/docker.sock → exists + /var/run/docker.sock → exists + /run/docker-cli.sock → absent + /run/podman → absent + /run/user/1000 → exists + /run/dbus → exists + /tmp/.X11-unix → exists + /run/WSL → absent + /mnt/wslg → absent