- 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
15 KiB
Architecture Patterns
Domain: Nix bubblewrap sandbox wrapper Researched: 2026-04-09
Recommended Architecture
claudebox is a single Nix derivation producing a shell script. The script has five logical stages that execute sequentially before execing into the sandboxed Claude process.
claudebox (entry)
|
v
[1. Argument Parsing] --yes/-y flag, passthrough args for claude
|
v
[2. Environment Build] Start empty, allowlist safe vars from host
|
v
[3. Env Audit Display] Show what's entering the sandbox, prompt user
|
v
[4. bwrap Invocation] Namespace + mount table + env + exec chain
| |
| +-- Mount table (ro: /nix/store, /etc/resolv.conf, ...)
| +-- Mount table (rw: CWD, ~/.claudebox -> ~/.claude)
| +-- Mount table (tmpfs: /tmp, /home)
| +-- Namespace config (unshare user, pid, ipc)
| +-- Env vars (--clearenv + explicit --setenv per var)
|
v
[5. exec claude] --dangerously-skip-permissions + user args
Component Boundaries
| Component | Responsibility | Notes |
|---|---|---|
Nix derivation (default.nix / flake.nix) |
Pins all runtime deps, builds wrapper via writeShellApplication |
Closure includes coreutils, git, curl, jq, rg, fd, nix, comma, claude-code |
| Argument parser | Handles --yes/-y, collects passthrough args |
Simple case/shift loop, no getopt needed |
| Env builder | Constructs the --setenv flag list from allowlist |
Reads host vars, filters through allowlist, builds array |
| Env auditor | Displays env to user, prompts for confirmation | Skipped with --yes; uses stderr for display |
| Mount table | Defines all filesystem bindings for bwrap | Static mounts + dynamic CWD mount |
| bwrap exec | Assembles and execs the bwrap command | Final exec bwrap ... -- claude ... |
The bwrap Invocation Structure
bubblewrap flags are order-sensitive for mounts (later mounts overlay earlier ones) but not for namespace flags. The canonical structure:
exec bwrap \
# --- Namespace isolation ---
--unshare-user \
--unshare-pid \
--unshare-ipc \
--unshare-cgroup \
--die-with-parent \
\
# --- Environment (start clean) ---
--clearenv \
--setenv HOME "$sandbox_home" \
--setenv PATH "$sandbox_path" \
--setenv TERM "$TERM" \
# ... more --setenv flags from allowlist ...
\
# --- Base filesystem (read-only) ---
--ro-bind /nix/store /nix/store \
--ro-bind /etc/resolv.conf /etc/resolv.conf \
--ro-bind /etc/ssl /etc/ssl \
--ro-bind /etc/nix /etc/nix \
--ro-bind /etc/passwd /etc/passwd \
--ro-bind /etc/group /etc/group \
--ro-bind /etc/nsswitch.conf /etc/nsswitch.conf \
\
# --- Nix daemon socket (required for nix commands) ---
--bind /nix/var/nix/daemon-socket /nix/var/nix/daemon-socket \
--ro-bind /nix/var/nix/db /nix/var/nix/db \
--ro-bind /nix/var/nix/profiles /nix/var/nix/profiles \
\
# --- tmpfs layers ---
--tmpfs /tmp \
--tmpfs /run \
\
# --- Proc/dev (needed for process management) ---
--proc /proc \
--dev /dev \
\
# --- User home (isolated) ---
--tmpfs "$sandbox_home" \
\
# --- Persistent Claude config ---
--bind "$HOME/.claudebox" "$sandbox_home/.claude" \
\
# --- Working directory (read-write) ---
--bind "$(pwd)" "$(pwd)" \
--chdir "$(pwd)" \
\
# --- XDG cache for nix/comma ---
--bind "$HOME/.claudebox/cache" "$sandbox_home/.cache" \
\
-- \
claude --dangerously-skip-permissions "$@"
Flag ordering rationale:
- Namespace flags first (they configure the sandbox type)
--clearenvbefore any--setenv(clear then populate)- Read-only system mounts before read-write user mounts (base before overlay)
--tmpfsfor home before--bindinto home (create the mount point, then bind into it)--chdirlast before--(sets starting directory)--separates bwrap flags from the command to execute
How /nix/store Works Inside bwrap
The Nix store is the critical piece. Here is how each layer works:
Read-only store access (--ro-bind /nix/store /nix/store):
- All store paths (the closure of the wrapper script) are immediately available
- Programs in PATH resolve because PATH points to
/nix/store/...-coreutils/binetc. - This is a bind mount, not a copy -- zero overhead
Nix daemon socket (--bind /nix/var/nix/daemon-socket):
nixcommands (build, shell, run) communicate with the Nix daemon via a Unix socket at/nix/var/nix/daemon-socket/socket- The daemon runs OUTSIDE the sandbox as root -- it handles store writes
- Inside the sandbox, the user can request builds but the daemon does the actual
/nix/storewriting - This is why
/nix/storecan be--ro-bindeven though nix builds "write" to it: the daemon writes from outside
Nix DB access (--ro-bind /nix/var/nix/db):
- The Nix database (SQLite) tells
nixwhat's installed and what paths are valid - Read-only is sufficient; the daemon handles mutations
Nix profiles (--ro-bind /nix/var/nix/profiles):
- Needed for
nixto resolve channels/registries - Read-only is fine
Result: nix shell nixpkgs#python3 -c python3 works inside the sandbox. The daemon fetches/builds the derivation, writes to the store (outside sandbox), and the new store path becomes visible through the existing --ro-bind mount (because bind mounts reflect the source's live state).
How comma (,) Works Inside the Sandbox
comma is a wrapper around nix shell. When Claude runs , ripgrep:
- comma resolves
ripgrepto a nixpkgs attribute usingnix-index(a prebuilt database) - comma runs
nix shell nixpkgs#ripgrep -c rg ... - Nix daemon fetches/builds the derivation outside the sandbox
- The result appears in
/nix/storewhich is bind-mounted - The command executes
Requirements for comma to work:
nix-indexdatabase must exist. Two options:- Pre-populate in the derivation (larger closure, stale)
- Bind-mount host's
~/.cache/nix-indexread-only (recommended -- uses host's existing DB)
- The
nixcommand must be in PATH - The Nix daemon socket must be accessible
Recommended approach: Bind-mount the host nix-index database:
--ro-bind "$HOME/.cache/nix-index" "$sandbox_home/.cache/nix-index"
Or if using nix-index-database flake (common on NixOS), bind-mount its store path.
Data Flow
Host environment
|
|-- [env vars] --> allowlist filter --> --setenv flags --> sandbox env
|
|-- [/nix/store] --ro-bind--> sandbox /nix/store
|-- [nix daemon socket] --bind--> sandbox can request builds
|-- [CWD] --bind (rw)--> sandbox CWD (Claude edits code here)
|-- [~/.claudebox/] --bind (rw)--> sandbox ~/.claude (config persists)
|-- [~/.claudebox/cache/] --bind (rw)--> sandbox ~/.cache
|
|-- [~/.ssh, ~/.gnupg, ~/.aws, ...] --> NOT MOUNTED (invisible)
|
v
sandbox
|-- claude --dangerously-skip-permissions
|-- reads/writes CWD (code)
|-- reads/writes ~/.claude (config, CLAUDE.md, etc.)
|-- can run: git, curl, jq, rg, fd, nix, comma
|-- can install tools via comma/nix shell
|-- CANNOT see secrets
~/.claudebox to ~/.claude Mapping
The bind mount --bind "$HOME/.claudebox" "$sandbox_home/.claude" means:
- Outside sandbox:
~/.claudebox/is the real directory on disk - Inside sandbox: It appears as
~/.claude/(where Claude Code expects its config) - Claude Code reads/writes
~/.claude/settings.json,~/.claude/CLAUDE.md, etc. -- all actually stored in~/.claudebox/ - The real
~/.claude/on the host (if it exists) is never visible inside the sandbox - First-run setup:
mkdir -p ~/.claudeboxbefore first launch
Contents to pre-seed in ~/.claudebox/:
CLAUDE.mdwith sandbox-aware instructions (how to use comma, what tools are available)settings.jsonif needed for Claude Code config
Patterns to Follow
Pattern 1: writeShellApplication with runtimeInputs
What: Use pkgs.writeShellApplication to create the wrapper, with all tools in runtimeInputs
Why: Automatically sets up PATH, adds set -euo pipefail, shellcheck-validates the script
{ pkgs }:
pkgs.writeShellApplication {
name = "claudebox";
runtimeInputs = with pkgs; [
bubblewrap
coreutils
# These go into the wrapper's PATH, not the sandbox's PATH
];
text = builtins.readFile ./claudebox.sh;
}
Important distinction: runtimeInputs sets the PATH of the wrapper script itself (needs bwrap). The sandbox's internal PATH is constructed separately by the script and passed via --setenv PATH.
Pattern 2: Constructing Sandbox PATH from Nix Store Paths
What: Build the sandbox's PATH from explicit Nix store paths, not from the wrapper's PATH
# In the Nix expression, interpolate store paths into the script
sandboxPath = lib.makeBinPath [
pkgs.coreutils
pkgs.git
pkgs.curl
pkgs.jq
pkgs.ripgrep
pkgs.fd
pkgs.nix
pkgs.comma
claude-code # however this is packaged
];
Then in the shell script: --setenv PATH "${sandboxPath}". This guarantees the sandbox PATH contains exactly and only the intended tools, all as /nix/store/... paths.
Pattern 3: Env Allowlist as Array
What: Define allowed env vars as a bash array, loop to build --setenv flags
allowed_vars=(
HOME PATH TERM EDITOR VISUAL
LANG LC_ALL LC_CTYPE
COLORTERM FORCE_COLOR
NO_COLOR
XDG_RUNTIME_DIR
CLAUDE_CODE_API_KEY
ANTHROPIC_API_KEY
)
env_args=()
for var in "${allowed_vars[@]}"; do
if [[ -n "${!var:-}" ]]; then
env_args+=(--setenv "$var" "${!var}")
fi
done
HOME and PATH get overridden with sandbox-specific values after this loop.
Pattern 4: Pre-launch Audit on stderr
What: Print the env vars that will enter the sandbox, prompt on stderr
if [[ "${skip_audit}" != "true" ]]; then
echo "=== claudebox: environment entering sandbox ===" >&2
for var in "${allowed_vars[@]}"; do
if [[ -n "${!var:-}" ]]; then
echo " ${var}=${!var}" >&2
fi
done
echo "" >&2
read -rp "Proceed? [Y/n] " answer < /dev/tty
if [[ "${answer}" =~ ^[Nn] ]]; then
echo "Aborted." >&2
exit 1
fi
fi
Anti-Patterns to Avoid
Anti-Pattern 1: Using --dev-bind Instead of --ro-bind for /nix/store
What: Mounting /nix/store read-write inside the sandbox
Why bad: The sandbox process could write to the store, bypassing the Nix daemon. No security benefit and potential store corruption.
Instead: --ro-bind /nix/store /nix/store -- the daemon handles writes from outside.
Anti-Pattern 2: Env Denylist
What: Starting with the full host env and removing known-bad vars
Why bad: New secrets (e.g., VAULT_TOKEN, OPENAI_API_KEY) leak automatically. You must know every possible secret name.
Instead: --clearenv + explicit --setenv for each allowed var.
Anti-Pattern 3: Bind-Mounting All of /home
What: --bind /home /home for convenience
Why bad: Exposes ~/.ssh, ~/.gnupg, ~/.aws, ~/.config/gcloud, age keys, everything
Instead: --tmpfs $HOME then selectively bind specific directories.
Anti-Pattern 4: Forgetting --die-with-parent
What: Omitting --die-with-parent from bwrap flags
Why bad: If the wrapper script is killed, the sandbox process becomes orphaned and keeps running
Instead: Always include --die-with-parent.
Anti-Pattern 5: Bind-Mounting /nix/store But Not the Daemon Socket
What: Read-only store mount without daemon access
Why bad: nix shell, nix build, and comma all fail because they cannot talk to the daemon. Tools are frozen to what's in PATH.
Instead: Also bind the daemon socket and /nix/var/nix/db.
Component Build Order
Build and test each component incrementally:
Stage 1: Minimal bwrap exec (get a shell)
- Hardcode everything, no env audit, no argument parsing
- Goal:
bwrap --ro-bind /nix/store /nix/store --bind $(pwd) $(pwd) ... -- /bin/sh - Validates: mount table works, namespace config doesn't crash
- Test: Can you run
lsinside the sandbox? Can you see/nix/store?
Stage 2: Run Claude inside bwrap
- Replace
/bin/shwithclaude --dangerously-skip-permissions - Add the
~/.claudebox->~/.claudebind mount - Add proper env setup (HOME, PATH, TERM, API key)
- Test: Does Claude launch? Can it read/write CWD?
Stage 3: Nix/comma inside the sandbox
- Add daemon socket mount
- Add nix db/profiles mounts
- Add nix-index database mount for comma
- Test: Can Claude run
, python3and get a working Python?
Stage 4: Env audit + argument parsing
- Add the allowlist builder
- Add the pre-launch audit display
- Add
--yes/-yflag - Test: Does the audit show correct vars? Does
-yskip it?
Stage 5: Nix packaging
writeShellApplicationwrapper- Construct sandbox PATH via
lib.makeBinPath - Wire into flake
- Test:
nix run .#claudeboxworks end-to-end
Stage 6: Polish
- Default CLAUDE.md with sandbox instructions
- Error messages for missing
~/.claudebox - XDG_RUNTIME_DIR handling
Scalability Considerations
Not applicable -- this is a single-user local tool. The architecture is a shell script wrapping a single process.
Key Technical Notes
/nix/store Bind Mount Reflects Live Changes
When bwrap does --ro-bind /nix/store /nix/store, it creates a bind mount. Bind mounts in Linux reflect the live state of the source. So when the Nix daemon (running outside) adds new paths to /nix/store, they immediately appear inside the sandbox through the existing mount. This is why nix shell works: the daemon builds, writes the result to /nix/store, and the sandbox sees it instantly.
--unshare-net Is Intentionally Omitted
The project explicitly keeps network access (Claude needs API access, git needs remotes, curl needs endpoints). Network isolation is out of scope per PROJECT.md -- Claude Code's own proxy handles domain allowlisting.
User Namespace Requirement
--unshare-user requires user namespaces to be enabled in the kernel (sysctl kernel.unprivileged_userns_clone=1). NixOS has this enabled by default. Without user namespaces, bwrap needs setuid -- but on NixOS this is handled by the bubblewrap package and security.allowUserNamespaces (defaults to true).
XDG_RUNTIME_DIR
Some tools (including potentially Claude Code) expect XDG_RUNTIME_DIR to exist. Options:
--tmpfs /run/user/$(id -u)and--setenv XDG_RUNTIME_DIR /run/user/$(id -u)- Or simply don't pass it and let tools fall back to
/tmp
Recommend the tmpfs approach for maximum compatibility.
Sources
- bubblewrap documentation and manpage (training data, HIGH confidence -- bwrap is stable and rarely changes API)
- Nix daemon architecture (training data, HIGH confidence -- fundamental Nix design)
- nixpkgs
writeShellApplicationpatterns (training data, HIGH confidence) - Linux bind mount semantics (training data, HIGH confidence -- kernel behavior)
- comma/nix-index mechanics (training data, MEDIUM confidence -- verify comma's current invocation style)