- Add CREDS_FILE/CREDS_MOUNT detection after mkdir ~/.claudebox - Conditional --bind in exec bwrap via BWRAP_ARGS array - Mirror conditional bind in --dry-run display block - Read-write mount (not ro-bind) for OAuth token refresh - Silent skip when credentials file absent (no error/warning) - Refactor exec bwrap to BWRAP_ARGS array for conditional mount support
21 KiB
Domain Pitfalls
Domain: Bubblewrap sandbox wrappers for CLI tools on NixOS Researched: 2026-04-09 Confidence: MEDIUM (training data only, no live verification available)
Critical Pitfalls
Mistakes that cause sandbox escapes, broken tools, or full rewrites.
Pitfall 1: Environment Variable Leaks via Inherited Env
What goes wrong: Using --unshare-all or selectively binding vars but forgetting that bwrap inherits the parent environment by default. Every env var the shell has leaks into the sandbox unless you explicitly clear it. Variables like DBUS_SESSION_BUS_ADDRESS, SSH_AUTH_SOCK, XDG_RUNTIME_DIR, GNOME_KEYRING_CONTROL, GPG_AGENT_INFO, AWS_PROFILE, KUBECONFIG, and DOCKER_HOST silently pass through.
Why it happens: bwrap does NOT start with an empty environment. People assume --unshare-* flags affect env vars -- they do not. Namespace unsharing and environment are completely orthogonal.
Consequences: The entire security model of claudebox collapses. Claude Code can read SSH_AUTH_SOCK, connect to the agent, and sign things. AWS/GCP tokens pass through. The sandbox is theater.
Prevention: Use env -i before the bwrap call, then explicitly set only allowlisted variables:
exec env -i \
HOME="$HOME" \
TERM="$TERM" \
PATH="$SANDBOX_PATH" \
LANG="$LANG" \
bwrap [flags] ...
Alternatively, --clearenv is available in bwrap 0.8.0+. Verify the version in nixpkgs before relying on it.
Detection: Run env inside the sandbox. If it shows more than your allowlist, you have a leak. Build this as an automated test.
Phase: Must be correct from Phase 1. This is the core security invariant.
Pitfall 2: Missing /dev Nodes Causing Silent Failures
What goes wrong: A minimal --dev /dev mount is used, but tools need specific device nodes. Claude Code (Node.js) needs /dev/null, /dev/zero, /dev/urandom, and crucially /dev/fd, /dev/stdin, /dev/stdout, /dev/stderr. Git needs /dev/tty for credential prompts. Node.js needs /dev/shm for V8's shared memory segments. Missing /dev/ptmx and /dev/pts means no PTY allocation, breaking interactive features.
Why it happens: --dev /dev in bwrap creates a minimal devtmpfs, but the exact set of nodes depends on bwrap version. People test simple commands and miss that complex runtimes need more.
Consequences: Node.js crashes or hangs. Git operations that prompt for input hang forever. nix shell commands fail because nix-daemon communication breaks. The sandbox "works" for trivial tests but fails under real use.
Prevention: Use --dev /dev (not --dev-bind /dev /dev which exposes host devices) and then verify these exist inside:
/dev/null,/dev/zero,/dev/full/dev/random,/dev/urandom/dev/fd(symlink to/proc/self/fd)/dev/stdin,/dev/stdout,/dev/stderr/dev/shm(tmpfs mount)/dev/ptsand/dev/ptmx(for PTY)/dev/tty(for git, ssh prompts)
If --dev /dev does not provide /dev/shm, add --tmpfs /dev/shm. If PTY is missing, bind-mount from host: --dev-bind /dev/pts /dev/pts.
Detection: Run ls -la /dev/ inside the sandbox. Run node -e "process.stdout.write('test')". Run git status in a repo. If any hang or error, missing dev nodes.
Phase: Phase 1. Without correct /dev, nothing works.
Pitfall 3: Nix Store and Nix Daemon Socket Access
What goes wrong: Mounting /nix/store read-only is remembered, but the nix-daemon socket at /nix/var/nix/daemon-socket/socket is forgotten. Without it, nix shell, nix build, and comma (,) all fail because they need to talk to the daemon for store operations. Additionally, /nix/var/nix/db may be needed for local queries.
Why it happens: People think of Nix store as just the read-only /nix/store path. But nix shell nixpkgs#foo needs the daemon to fetch and realize store paths. The daemon socket is in a completely different path.
Consequences: The entire "Claude can self-install tools via comma" feature is dead. Claude is stuck with whatever is in the pre-declared PATH. This defeats a core design goal.
Prevention:
--ro-bind /nix/store /nix/store \
--ro-bind /nix/var/nix/db /nix/var/nix/db \
--ro-bind /nix/var/nix/daemon-socket /nix/var/nix/daemon-socket \
The socket itself needs to be accessible (not just the directory). Ensure the user inside the sandbox is in the nixbld group or that the daemon socket permissions allow access (they usually do for all users on NixOS).
Also mount --ro-bind /etc/nix /etc/nix for nix.conf (channels, substituters, trusted keys).
Detection: Run nix shell nixpkgs#hello -c hello inside the sandbox. If it hangs or errors with "cannot connect to daemon", the socket is not mounted.
Phase: Phase 1. This is a core requirement per PROJECT.md.
Pitfall 4: DNS Resolution Fails Inside Sandbox
What goes wrong: DNS resolution breaks because /etc/resolv.conf, /etc/nsswitch.conf, and the NSS libraries are not available inside the sandbox. On NixOS specifically, /etc/resolv.conf is often a symlink into the Nix store or managed by systemd-resolved (pointing to a stub resolver at 127.0.0.53), and the NSS shared libraries live in the Nix store at paths determined at build time.
Why it happens: People bind-mount /etc/resolv.conf but forget that glibc's NSS needs nsswitch.conf plus the actual .so files for nss_dns, nss_files, nss_resolve. On NixOS these are in the Nix store, not in /lib. Without them, getaddrinfo() returns EAI_NONAME even though resolv.conf is present.
Consequences: curl, git clone, nix shell (which fetches from cache.nixos.org), and Claude Code's own API calls all fail. The sandbox is offline despite having network access.
Prevention:
--ro-bind /etc/resolv.conf /etc/resolv.conf \
--ro-bind /etc/nsswitch.conf /etc/nsswitch.conf \
--ro-bind /etc/ssl /etc/ssl \
--ro-bind /etc/hosts /etc/hosts \
Since /nix/store is already mounted and your tools come from there, their RUNPATH/LD_LIBRARY_PATH should resolve the NSS .so files from the store. If you use --symlink for /etc/resolv.conf because it is itself a symlink, make sure the symlink target is also mounted.
On NixOS with systemd-resolved, the stub resolver at 127.0.0.53 works fine inside the sandbox since network namespaces are not used (per PROJECT.md, no network isolation).
Detection: Run curl -s https://cache.nixos.org or getent hosts github.com inside the sandbox. If they fail, DNS is broken.
Phase: Phase 1. Without DNS, nothing network-dependent works.
Pitfall 5: /tmp Handling Breaks Build Tools and Nix
What goes wrong: Using --tmpfs /tmp (correct instinct -- don't share host /tmp) but then Nix builds fail because the nix-daemon expects to be able to create build directories in /tmp that are accessible to both the daemon and the client. Also, some tools use /tmp for Unix domain sockets (X11, Wayland, PulseAudio) whose paths are referenced by env vars that were cleared.
Why it happens: /tmp is both a security-sensitive directory (shared temp files = symlink attacks, info leaks) and a critical coordination point for IPC.
Consequences: nix build and nix shell fail with permission errors or "cannot create temp directory". Node.js os.tmpdir() works but may fill a small tmpfs. Build operations that need significant temp space fail silently.
Prevention: Use --tmpfs /tmp but size it generously:
--tmpfs /tmp # defaults to 50% of RAM, which is usually fine
For Nix specifically: the daemon runs outside the sandbox, so it uses its own /tmp. The client inside the sandbox only needs /tmp for its own temp files, not for build directories. This means --tmpfs /tmp works fine for nix shell -- the potential issue is only if you try to run nix-build in single-user mode inside the sandbox (don't).
Set TMPDIR=/tmp explicitly in the env allowlist so tools don't inherit a host-specific TMPDIR pointing to a non-existent path.
Detection: Run mktemp and nix shell nixpkgs#hello -c hello inside the sandbox.
Phase: Phase 1.
Pitfall 6: Git Broken Inside Sandbox
What goes wrong: Git fails in multiple ways inside a bwrap sandbox:
- Git config not found:
~/.gitconfigis not mounted, so user identity (name, email) is missing. Commits fail. - Safe directory check: Git 2.35.2+ rejects repositories owned by a different user. Inside bwrap, UID mapping can cause the repo files to appear owned by a different UID, triggering
fatal: detected dubious ownership in repository. - Git credential helpers: Configured helpers (e.g.,
git-credential-libsecret,gh auth) reference binaries and sockets not in the sandbox. - Git hooks: Pre-commit hooks may invoke tools not available in the minimal PATH.
- SSH remotes:
~/.sshis intentionally hidden, sogit push/pullover SSH fails.
Why it happens: Git has grown a large surface area of external dependencies (config, credentials, hooks, gpg signing). Sandboxing the filesystem breaks all of them.
Consequences: Claude cannot commit (the primary use case). Or worse, commits succeed but with wrong identity, triggering CI failures or attribution issues.
Prevention:
# Mount gitconfig read-only (strip credential helper lines if needed)
--ro-bind "$HOME/.gitconfig" "$HOME/.gitconfig" \
# OR generate a minimal gitconfig inside the sandbox
# with just user.name and user.email
# For safe.directory, add the CWD:
git config --global safe.directory "$PWD"
# Or set GIT_CONFIG_GLOBAL to a sandbox-specific config
For the safe.directory issue: since claudebox does NOT use --unshare-user (UID mapping), this should not trigger. But verify -- if you do use user namespaces, the repo UID won't match and git will refuse.
For credentials: Claude Code should not push to remotes. If it tries, failing is the correct behavior. Document this as intentional.
For hooks: pre-commit hooks need tools in PATH. Either include common hook tools (node, python) in the sandbox PATH or accept that hooks may fail.
Detection: Run git log, git diff, git commit inside the sandbox. Check git config --list for expected values.
Phase: Phase 1. Git is in the core PATH per requirements.
Moderate Pitfalls
Pitfall 7: /proc Mount Leaks Host Information
What goes wrong: Using --proc /proc (correct) but not realizing that /proc exposes the host's process list, network config, and system info unless PID namespace is unshared. /proc/net/tcp reveals all host connections. /proc/*/environ of other processes may leak secrets.
Why it happens: --proc /proc mounts a procfs, but its contents depend on whether --unshare-pid is used.
Consequences: Claude Code can read host process info. Not a direct secret leak, but violates the principle of minimal exposure.
Prevention: Use --unshare-pid --proc /proc. This gives the sandbox its own PID namespace, so /proc only shows sandbox processes. Note: --unshare-pid requires a new mount namespace (--unshare-all or --unshare-pid standalone). Verify the bwrap invocation order.
Detection: Run ls /proc/ inside the sandbox. If you see hundreds of PIDs, PID namespace is not isolated.
Phase: Phase 1, but not blocking. Enhancement after basic functionality works.
Pitfall 8: TTY/PTY Not Properly Forwarded
What goes wrong: Claude Code is an interactive CLI tool that needs a proper terminal. Inside bwrap, if the controlling TTY is not forwarded, isatty() returns false, disabling color output, interactive prompts, and progress indicators. Worse, if Claude Code tries to allocate a PTY (for running subcommands), it fails without /dev/pts.
Why it happens: bwrap by default inherits the parent's stdio file descriptors, so basic TTY works. But PTY allocation for subprocesses needs /dev/pts and /dev/ptmx properly mounted.
Consequences: Claude Code "works" but looks broken -- no colors, no interactive prompts. Subprocess execution may hang.
Prevention: Ensure these are available:
--dev /dev \ # provides basic TTY
--dev-bind /dev/pts /dev/pts \ # PTY allocation
Verify TERM is in the env allowlist. Verify the stdin/stdout/stderr fds are inherited (they are by default in bwrap, no special flag needed).
Detection: Run tput colors and tty inside the sandbox. Check that Claude Code shows colored output.
Phase: Phase 1. Claude Code is a terminal app.
Pitfall 9: XDG and Cache Directories Missing
What goes wrong: Tools inside the sandbox expect XDG_CONFIG_HOME, XDG_CACHE_HOME, XDG_DATA_HOME, and XDG_RUNTIME_DIR to exist and be writable. Node.js writes to ~/.npm, ~/.node_repl_history. Nix wants ~/.cache/nix. Without writable cache dirs, tools fail with EACCES or ENOENT or silently degrade.
Why it happens: The allowlist env model correctly strips these vars, but the underlying directories also need to exist and be writable. XDG_RUNTIME_DIR is particularly tricky -- it is per-user, often /run/user/1000, and some tools refuse to work without it.
Consequences: nix shell may fail to cache anything. Node.js writes warnings to stderr on every invocation. Tools that need a config dir crash on first run.
Prevention:
# Map a persistent cache dir
--bind "$HOME/.claudebox/cache" "$HOME/.cache" \
--bind "$HOME/.claudebox/local" "$HOME/.local" \
# Create a tmpfs for XDG_RUNTIME_DIR
--tmpfs /run/user/$(id -u) \
# Set the env vars
XDG_CACHE_HOME="$HOME/.cache"
XDG_RUNTIME_DIR="/run/user/$(id -u)"
Create $HOME/.claudebox/cache and $HOME/.claudebox/local directories in the wrapper script before launching bwrap.
Detection: Run echo $XDG_RUNTIME_DIR and ls -la ~/.cache inside the sandbox.
Phase: Phase 1. Required for Nix and Node.js operation.
Pitfall 10: Symlink Resolution Across Mount Boundaries
What goes wrong: On NixOS, many paths under /etc and /usr are symlinks into the Nix store. Bind-mounting the symlink does not follow it -- you get the symlink, but the target is only accessible if /nix/store is also mounted. People bind-mount /etc/resolv.conf not realizing it is a symlink to /run/systemd/resolve/stub-resolv.conf or a Nix store path.
Why it happens: NixOS is symlink-heavy by design. The entire /etc is largely managed through symlinks. Bwrap --ro-bind mounts the file/symlink literally, so the target must be reachable.
Consequences: Bind mounts silently succeed but the file is empty or inaccessible. DNS breaks, SSL certs are missing, etc.
Prevention: In the wrapper script, resolve symlinks before mounting:
resolve_path() {
readlink -f "$1"
}
--ro-bind "$(resolve_path /etc/resolv.conf)" /etc/resolv.conf \
Or mount entire directory trees that are known to be symlink farms (/etc/ssl, /etc/static).
Since /nix/store is already mounted read-only, most NixOS symlinks will resolve correctly. The issue is paths like /run/systemd/resolve/ which are outside the store.
Detection: Run cat /etc/resolv.conf and ls -la /etc/ssl/certs/ca-certificates.crt inside the sandbox.
Phase: Phase 1. NixOS-specific but critical.
Pitfall 11: SSL/TLS Certificate Chain Missing
What goes wrong: HTTPS requests fail with certificate validation errors because the CA certificate bundle is not mounted. On NixOS, the cert bundle is at a Nix store path symlinked from /etc/ssl/certs/ca-certificates.crt and/or /etc/pki/tls/certs/ca-bundle.crt. Some tools also check SSL_CERT_FILE and NIX_SSL_CERT_FILE environment variables.
Why it happens: The env allowlist strips SSL_CERT_FILE and NIX_SSL_CERT_FILE. The filesystem mounts don't include /etc/ssl. Both must be present for HTTPS to work.
Consequences: curl https://... fails. nix shell cannot download from cache.nixos.org. Claude Code cannot reach the Anthropic API. Game over.
Prevention:
--ro-bind /etc/ssl /etc/ssl \
--ro-bind /etc/pki /etc/pki \ # if it exists
Add NIX_SSL_CERT_FILE and SSL_CERT_FILE to the env allowlist, pointing to the cert bundle path. On NixOS:
NIX_SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
Detection: Run curl -v https://cache.nixos.org inside the sandbox. Check for certificate errors.
Phase: Phase 1. Without this, nothing network-related works.
Minor Pitfalls
Pitfall 12: Locale Data Missing
What goes wrong: Passing LANG=en_US.UTF-8 in the allowlist but not mounting the locale data. On NixOS, locale data lives in the Nix store (referenced by LOCALE_ARCHIVE).
Prevention: Add LOCALE_ARCHIVE to the env allowlist, keeping its original value (which points into /nix/store, already mounted).
Phase: Phase 1. Easy to include.
Pitfall 13: Home Directory Confusion
What goes wrong: HOME inside the sandbox points to the real home directory path, but most of it is not mounted. Tools try to read ~/.bashrc, ~/.profile, ~/.config/* and fail. Worse, if HOME is bind-mounted entirely, all secrets are exposed.
Prevention: Mount only the specific home subdirectories needed:
--tmpfs "$HOME" \ # empty home
--bind "$HOME/.claudebox" "$HOME/.claude" \ # claude config
--bind "$CWD" "$CWD" \ # working directory
# Then selectively mount safe config files
Set HOME in the env allowlist to the real path so paths are consistent.
Phase: Phase 1.
Pitfall 14: bwrap Argument Order Matters
What goes wrong: bwrap processes mount arguments in order. A later --tmpfs /foo overwrites an earlier --bind /bar /foo. People declare mounts in logical groups but don't realize that order determines the final mount table. A --tmpfs $HOME after --bind ... $HOME/.claude wipes the bind mount.
Prevention: Order mounts from general to specific. tmpfs base directories first, then bind mounts on top:
--tmpfs "$HOME" \ # 1. empty home base
--bind ... "$HOME/.claude" \ # 2. specific dirs on top
Detection: mount or cat /proc/mounts inside the sandbox to verify the mount table.
Phase: Phase 1.
Pitfall 15: Hardcoded Paths in Nix-Built Binaries
What goes wrong: Nix-built binaries have hardcoded RUNPATH and interpreter paths pointing into /nix/store. This is fine as long as /nix/store is mounted. But if you try to use non-Nix binaries or if you accidentally mount a partial /nix/store, binaries fail with "no such file or directory" (the dynamic linker is missing).
Prevention: Always mount ALL of /nix/store read-only, not just specific paths:
--ro-bind /nix/store /nix/store # the whole thing, not individual derivations
Phase: Phase 1.
Phase-Specific Warnings
| Phase Topic | Likely Pitfall | Mitigation |
|---|---|---|
| Basic bwrap invocation | Env leak (#1), /dev (#2), DNS (#4), SSL (#11) | Test with env, curl https://, node -e inside sandbox |
| Nix/comma integration | Daemon socket (#3), /tmp (#5), symlinks (#10) | Test nix shell nixpkgs#hello -c hello and , cowsay |
| Git operations | Git config/ownership (#6) | Test git log, git diff, git commit in a real repo |
| Interactive use | TTY (#8), XDG dirs (#9) | Test full Claude Code session, check colors and prompts |
| Pre-launch env audit | Env allowlist completeness (#1) | Print env before and after, diff against allowlist |
| Hardening | /proc isolation (#7) | Add --unshare-pid after basic functionality works |
The Meta-Pitfall: Testing Only the Happy Path
The single biggest risk with sandbox wrappers is declaring success after echo hello works inside bwrap. Every pitfall above manifests only under real workloads. The test plan must include:
env-- verify env is cleancurl https://api.anthropic.com-- verify DNS + SSLnix shell nixpkgs#hello -c hello-- verify nix-daemongit log && git diff-- verify git worksnode -e "console.log('test')"-- verify Node.js runtime- Actually run
claude --dangerously-skip-permissionsand have a conversation - Have Claude run a build command, install a tool with comma, edit a file
If all seven pass, the sandbox is solid.
Sources
- bubblewrap documentation and man page (training data, MEDIUM confidence)
- NixOS filesystem layout knowledge (training data, HIGH confidence -- well-established, unlikely to have changed)
- Git safe.directory behavior from Git 2.35.2+ (training data, HIGH confidence)
- Node.js /dev requirements (training data, MEDIUM confidence)
- General Linux namespace/mount/sandbox knowledge (training data, HIGH confidence)
Note: Web search and fetch tools were unavailable during this research. All findings are from training data. The bwrap-specific mount behaviors and NixOS symlink patterns are well-established and unlikely to have changed, but the --clearenv flag availability should be verified against the current nixpkgs bwrap version.