Gsd/Phase 04 Auth Passthrough #1

Merged
toph merged 18 commits from gsd/phase-04-auth-passthrough into main 2026-04-10 12:27:33 +00:00
5 changed files with 1252 additions and 1066 deletions
Showing only changes of commit b2ece43a03 - Show all commits

View file

@ -1,384 +1,443 @@
# Architecture Patterns # Architecture Research
**Domain:** Nix bubblewrap sandbox wrapper **Domain:** bwrap sandbox wrapper — network isolation, profiles, auth passthrough
**Researched:** 2026-04-09 **Researched:** 2026-04-10
**Confidence:** HIGH (existing codebase read directly; new feature patterns verified against nixpkgs, official Claude Code docs, and upstream slirp4netns/pasta documentation)
## Recommended Architecture ## Standard Architecture
claudebox is a single Nix derivation producing a shell script. The script has five logical stages that execute sequentially before `exec`ing into the sandboxed Claude process. ### System Overview — v2.0 with new features
``` ```
claudebox (entry) claudebox invocation
| |
v v
[1. Argument Parsing] --yes/-y flag, passthrough args for claude ┌───────────────────────────────────────────────────────────────────┐
│ claudebox.sh (writeShellApplication, Nix store) │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌──────────────────────────┐ │
│ │ Arg parse │ │Profile load │ │ Instance dir resolution │ │
│ │ --profile │ │ ~/.claudebox│ │ ~/.claudebox/instances/ │ │
│ │ --network │ │ /profiles/ │ │ <cwd-hash>/.claude/ │ │
│ └──────┬──────┘ └──────┬──────┘ └────────────┬─────────────┘ │
│ └────────────────┴──────────────┬─────────┘ │
│ v │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Env builder │ │
│ │ - sandbox-generated vars (HOME, USER, PATH, SHELL ...) │ │
│ │ - host allowlist (TERM, LANG, ANTHROPIC_API_KEY ...) │ │
│ │ - profile env vars injected here │ │
│ │ - CLAUDEBOX_EXTRA_ENV escape hatch │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ | │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Mount builder │ │
│ │ - core mounts (nix store, /etc/*, /proc, /dev, tmpfs) │ │
│ │ - auth passthrough (ro-bind ~/.claude/.credentials.json) │ │
│ │ - instance dir (bind ~/.claudebox/instances/<hash>) │ │
│ │ - profile extra mounts appended here │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ | │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Network tier decision │ │
│ │ full → no --unshare-net, share host network │ │
│ │ inet → --unshare-net + pasta sidecar (internet, no LAN) │ │
│ │ none → --unshare-net, no sidecar (offline) │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ | │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Pre-launch: env audit display + confirmation │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ | │
│ full/none: exec bwrap ... | inet: bwrap ... &
│ | pasta $BWRAP_PID │
│ | wait $BWRAP_PID │
└───────────────────────────────────────────────────────────────────┘
| |
v ┌──────────────────────────┴───────────────────────┐
[2. Environment Build] Start empty, allowlist safe vars from host │ bwrap sandbox │
| │ ~/.claudebox → ~/.claudebox/instances/<hash>
v │ ~/.claude symlink → ~/.claudebox │
[3. Env Audit Display] Show what's entering the sandbox, prompt user │ ~/.claude/.credentials.json (read-only) │
| │ CWD (read-write) │
v │ profile devshell PATH prepended to SANDBOX_PATH │
[4. bwrap Invocation] Namespace + mount table + env + exec chain │ network: full | inet (via pasta TAP) | none │
| | └──────────────────────────────────────────────────┘
| +-- 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 Responsibilities
| Component | Responsibility | Notes | | Component | Responsibility | Status (v2.0) |
|-----------|---------------|-------| |-----------|---------------|----------------|
| **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 | | `flake.nix` | Nix derivation, `runtimeInputs`, `SANDBOX_PATH` injection | Modified — add `pkgs.passt` |
| **Argument parser** | Handles `--yes`/`-y`, collects passthrough args | Simple `case`/`shift` loop, no getopt needed | | `claudebox.sh` — arg parse | CLI flags | Modified — add `--profile`, `--network` |
| **Env builder** | Constructs the `--setenv` flag list from allowlist | Reads host vars, filters through allowlist, builds array | | `claudebox.sh` — profile loader | Read `~/.claudebox/profiles/<name>.json` | New function |
| **Env auditor** | Displays env to user, prompts for confirmation | Skipped with `--yes`; uses stderr for display | | `claudebox.sh` — instance resolver | Hash CWD → `~/.claudebox/instances/<hash>/`, create if missing | New function |
| **Mount table** | Defines all filesystem bindings for bwrap | Static mounts + dynamic CWD mount | | `claudebox.sh` — env builder | Accumulate `--setenv` args | Modified — add profile env injection |
| **bwrap exec** | Assembles and execs the bwrap command | Final `exec bwrap ... -- claude ...` | | `claudebox.sh` — mount builder | Accumulate bwrap mount args | Modified — auth ro-bind, instance bind, profile mounts |
| `claudebox.sh` — network setup | Decide `--unshare-net` + pasta sidecar | New function |
| `claudebox.sh` — package injector | Resolve profile packages via `nix build`, prepend to `SANDBOX_PATH` | New function |
| `claudebox.sh` — exec block | `exec bwrap` or `bwrap & wait` depending on tier | Modified — split by network tier |
| Profile store | `~/.claudebox/profiles/<name>.json` | New on-disk schema |
| Instance store | `~/.claudebox/instances/<cwd-hash>/` | New on-disk layout |
| Auth source | `~/.claude/.credentials.json` (host, read-only mount) | New mount only |
### The bwrap Invocation Structure ## Recommended Project Structure
bubblewrap flags are order-sensitive for mounts (later mounts overlay earlier ones) but not for namespace flags. The canonical structure: ```
claudebox/
├── flake.nix # add pkgs.passt to runtimeDeps
├── claudebox.sh # main script — extended with new sections
└── profiles/ # optional bundled example profiles
└── example.json
~/.claudebox/ # runtime state on host
├── CLAUDE.md # existing
├── SANDBOX.md # existing, overwritten each launch
├── profiles/ # user-defined profiles
│ ├── default.json
│ ├── work.json
│ └── offline.json
└── instances/ # per-project conversation history
├── a3f7b2c1d9e1f430/ # sha256 of /home/user/projects/foo (16 hex chars)
│ └── .claude/ # conversations, settings scoped to this project
│ ├── settings.local.json
│ └── projects/
└── d9e1f430a3f7b2c1/
└── .claude/
```
### Structure Rationale
- **`profiles/`:** Flat JSON keeps profiles shell-readable with `jq` without a Nix dependency at profile-load time. A `.nix` variant is possible for devshell injection but adds complexity; JSON is preferred for v2.0.
- **`instances/<hash>/`:** Hashing the absolute CWD path gives stable, collision-resistant names without encoding slashes. `sha256sum` truncated to 16 hex chars is sufficient — 2^64 space, no practical collision risk.
- **`instances/<hash>/.claude/`:** Claude Code reads `~/.claude/` for all state (conversation history in `projects/`, settings in `settings.local.json`). Mapping this per-project gives isolated history automatically without any changes to Claude Code itself.
## Architectural Patterns
### Pattern 1: Instance Dir via CWD Hash
**What:** Derive a stable instance directory from `sha256sum` of the absolute CWD. Create on first use. Bind-mount it as `~/.claudebox` inside the sandbox (preserving the existing `~/.claudebox → ~/.claude` symlink that already exists in the script).
**When to use:** Every launch. Replaces the current single `~/.claudebox` bind-mount target.
**Trade-offs:** One `sha256sum` call and one `mkdir -p` per launch. Negligible latency. History is siloed per CWD, which is the desired behavior.
**Integration point in existing code (line 345-346 of claudebox.sh):**
```bash ```bash
exec bwrap \ # EXISTING (line ~345 in current claudebox.sh):
# --- Namespace isolation --- # --bind "$HOME/.claudebox" "$HOME/.claudebox" \
--unshare-user \ # --symlink "$HOME/.claudebox" "$HOME/.claude" \
--unshare-pid \
--unshare-ipc \ # REPLACE WITH:
--unshare-cgroup \ INSTANCE_HASH=$(printf '%s' "$CWD" | sha256sum | cut -c1-16)
--die-with-parent \ INSTANCE_DIR="$HOME/.claudebox/instances/$INSTANCE_HASH"
\ mkdir -p "$INSTANCE_DIR/.claude"
# --- Environment (start clean) ---
--clearenv \ # In bwrap call:
--setenv HOME "$sandbox_home" \ # --bind "$INSTANCE_DIR" "$HOME/.claudebox" \
--setenv PATH "$sandbox_path" \ # --symlink "$HOME/.claudebox" "$HOME/.claude" \ (unchanged)
--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:** The `~/.claudebox → ~/.claude` symlink line is unchanged. The SANDBOX.md write and CLAUDE.md prepend logic (lines 107-154) currently targets `$HOME/.claudebox/` — these should continue targeting `$HOME/.claudebox/` on the host (the shared root), not the instance dir. SANDBOX.md is global to all instances; CLAUDE.md is too. Only conversation history (inside `.claude/`) is per-instance.
1. Namespace flags first (they configure the sandbox type)
2. `--clearenv` before any `--setenv` (clear then populate)
3. Read-only system mounts before read-write user mounts (base before overlay)
4. `--tmpfs` for home before `--bind` into home (create the mount point, then bind into it)
5. `--chdir` last before `--` (sets starting directory)
6. `--` separates bwrap flags from the command to execute
### How /nix/store Works Inside bwrap ### Pattern 2: Auth Passthrough as Read-Only Mount
The Nix store is the critical piece. Here is how each layer works: **What:** Mount `~/.claude/.credentials.json` from the host into the sandbox at the same path, read-only. No other files from the host `~/.claude/` are mounted.
**Read-only store access (`--ro-bind /nix/store /nix/store`):** **When to use:** Always (unconditional in v2.0).
- All store paths (the closure of the wrapper script) are immediately available
- Programs in PATH resolve because PATH points to `/nix/store/...-coreutils/bin` etc.
- This is a bind mount, not a copy -- zero overhead
**Nix daemon socket (`--bind /nix/var/nix/daemon-socket`):** **Why only `.credentials.json`:** Verified against official Claude Code docs — on Linux, credentials are stored at `~/.claude/.credentials.json` (mode 0600). The host `~/.claude/` otherwise contains all historical conversation state. We do not want that leaking into the sandbox; only the auth token is needed.
- `nix` commands (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/store` writing
- This is why `/nix/store` can be `--ro-bind` even though nix builds "write" to it: the daemon writes from outside
**Nix DB access (`--ro-bind /nix/var/nix/db`):** **Path resolution inside sandbox:** The sandbox's `~/.claude/` is the instance dir (via `~/.claudebox → ~/.claude` symlink). The credential file must be bound inside this path:
- The Nix database (SQLite) tells `nix` what'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 `nix` to 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`:
1. comma resolves `ripgrep` to a nixpkgs attribute using `nix-index` (a prebuilt database)
2. comma runs `nix shell nixpkgs#ripgrep -c rg ...`
3. Nix daemon fetches/builds the derivation outside the sandbox
4. The result appears in `/nix/store` which is bind-mounted
5. The command executes
**Requirements for comma to work:**
- `nix-index` database must exist. Two options:
- Pre-populate in the derivation (larger closure, stale)
- Bind-mount host's `~/.cache/nix-index` read-only (recommended -- uses host's existing DB)
- The `nix` command must be in PATH
- The Nix daemon socket must be accessible
**Recommended approach:** Bind-mount the host nix-index database:
```bash
--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 ~/.claudebox` before first launch
Contents to pre-seed in `~/.claudebox/`:
- `CLAUDE.md` with sandbox-aware instructions (how to use comma, what tools are available)
- `settings.json` if 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
```nix
{ 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
```nix
# 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
```bash ```bash
allowed_vars=( AUTH_CREDS="$HOME/.claude/.credentials.json"
HOME PATH TERM EDITOR VISUAL if [[ -f "$AUTH_CREDS" ]]; then
LANG LC_ALL LC_CTYPE # In bwrap mount args (after the instance dir bind and symlink):
COLORTERM FORCE_COLOR MOUNT_ARGS+=(--ro-bind "$AUTH_CREDS" "$HOME/.claudebox/.claude/.credentials.json")
NO_COLOR # Which resolves to ~/.claude/.credentials.json inside sandbox
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
```bash
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 fi
``` ```
## Anti-Patterns to Avoid Wait — the symlink chain inside sandbox is: `~/.claudebox` is the instance dir, `~/.claude` symlinks to `~/.claudebox`. So `~/.claude/.credentials.json``~/.claudebox/.credentials.json``$INSTANCE_DIR/.credentials.json`. The ro-bind should target `$HOME/.claudebox/.credentials.json` inside bwrap's view of the namespace (which is the instance dir bind target). Simpler alternative: bind directly to the resolved path `$INSTANCE_DIR/.credentials.json` on the host before launching bwrap.
### Anti-Pattern 1: Using --dev-bind Instead of --ro-bind for /nix/store ```bash
**What:** Mounting /nix/store read-write inside the sandbox # Simpler: copy (or bind) creds into instance dir before launch
**Why bad:** The sandbox process could write to the store, bypassing the Nix daemon. No security benefit and potential store corruption. # ro-bind from host ~/.claude/.credentials.json
**Instead:** `--ro-bind /nix/store /nix/store` -- the daemon handles writes from outside. # to instance dir path that becomes ~/.claude/.credentials.json inside sandbox
if [[ -f "$HOME/.claude/.credentials.json" ]]; then
MOUNT_ARGS+=(--ro-bind "$HOME/.claude/.credentials.json" \
"$INSTANCE_DIR/.credentials.json")
fi
# Then inside sandbox: ~/.claude/.credentials.json exists read-only
```
### Anti-Pattern 2: Env Denylist **Credential precedence note (HIGH confidence — official docs):** Claude Code selects credentials in this order: cloud provider env vars → `ANTHROPIC_AUTH_TOKEN``ANTHROPIC_API_KEY``apiKeyHelper``CLAUDE_CODE_OAUTH_TOKEN` → OAuth file at `~/.claude/.credentials.json`. The existing host-allowlist for `ANTHROPIC_API_KEY` therefore continues to take precedence over the passthrough file if both are present.
**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 ### Pattern 3: Tiered Network via Sidecar Process
**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:** Three network tiers controlled by `--network` flag (default: `full`):
**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 - `full` — no `--unshare-net`; sandbox shares host network. Current behavior.
**What:** Read-only store mount without daemon access - `inet``--unshare-net` + pasta sidecar; internet access via userspace NAT, no LAN, no Tailscale, no host loopback services.
**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. - `none``--unshare-net` alone; loopback only, fully offline.
**Instead:** Also bind the daemon socket and /nix/var/nix/db.
## Component Build Order **When to use:** Expose via `--network inet|none|full` CLI flag. Profile `network` field sets the default for that profile (overridable by CLI flag).
Build and test each component incrementally: **pasta vs slirp4netns decision — use pasta:**
- Both `pkgs.passt` (version `2025_09_19.623dbf6`) and `pkgs.slirp4netns` (version `1.3.3`) are in nixpkgs and verified available.
- pasta is the current default in Podman 5 and RHEL 9.5; actively maintained.
- pasta avoids NAT (it forwards at Layer-4 via native sockets), giving better performance and simpler DNS.
- pasta supports `--no-map-gw` to prevent host gateway access and `--no-tcp-ports --no-udp-ports` to block incoming connections.
- slirp4netns has `--disable-host-loopback` but still routes to LAN by default.
- **Add `pkgs.passt` to `runtimeDeps` in `flake.nix`.** Keep `slirp4netns` as a named alternative but do not add it by default.
### Stage 1: Minimal bwrap exec (get a shell) **Mechanism for `inet` tier — critical exec change:**
- 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 `ls` inside the sandbox? Can you see `/nix/store`?
### Stage 2: Run Claude inside bwrap The current script ends with `exec bwrap ...` (line 327, claudebox.sh). `exec` replaces the shell process, leaving no parent to launch a sidecar. For `inet` mode, the script must fork instead:
- Replace `/bin/sh` with `claude --dangerously-skip-permissions`
- Add the `~/.claudebox` -> `~/.claude` bind 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 ```bash
- Add daemon socket mount if [[ "$NETWORK_TIER" == "inet" ]]; then
- Add nix db/profiles mounts BWRAP_PIDFILE=$(mktemp)
- Add nix-index database mount for comma trap 'rm -f "$BWRAP_PIDFILE"' EXIT
- Test: Can Claude run `, python3` and get a working Python? bwrap \
--unshare-net \
--pidfile "$BWRAP_PIDFILE" \
"${ENV_ARGS[@]}" \
... (all other mount args) ... \
-- "${SANDBOX_CMD[@]}" &
BWRAP_PID=$!
# Wait for bwrap to write its PID (namespace is ready when pidfile is non-empty)
until [[ -s "$BWRAP_PIDFILE" ]]; do sleep 0.05; done
SANDBOX_PID=$(cat "$BWRAP_PIDFILE")
# Attach pasta to the network namespace
pasta --config-net --no-tcp-ports --no-udp-ports "$SANDBOX_PID" &
PASTA_PID=$!
wait "$BWRAP_PID"
STATUS=$?
kill "$PASTA_PID" 2>/dev/null || true
exit "$STATUS"
elif [[ "$NETWORK_TIER" == "none" ]]; then
exec bwrap --unshare-net "${ENV_ARGS[@]}" ... -- "${SANDBOX_CMD[@]}"
else # full (default)
exec bwrap "${ENV_ARGS[@]}" ... -- "${SANDBOX_CMD[@]}"
fi
```
### Stage 4: Env audit + argument parsing **DNS for `inet` mode:** pasta provides DNS forwarding automatically through its virtual gateway. The existing `/etc/resolv.conf` ro-bind must be REMOVED for `inet` mode (the sandbox resolv.conf would point to host DNS on the host network, unreachable in the new namespace). pasta configures the TAP device with `10.0.2.100` and routes DNS queries through its gateway. A synthetic `/etc/resolv.conf` inside the sandbox pointing to `10.0.2.3` (pasta's default gateway DNS) should be provided via `--ro-bind` from a tempfile.
- Add the allowlist builder
- Add the pre-launch audit display
- Add `--yes`/`-y` flag
- Test: Does the audit show correct vars? Does `-y` skip it?
### Stage 5: Nix packaging **`--pidfile` flag availability:** bwrap has supported `--pidfile` since version 0.4.0. The nixpkgs version is 0.9.x, so this is available. (HIGH confidence — verified against bwrap manpage.)
- `writeShellApplication` wrapper
- Construct sandbox PATH via `lib.makeBinPath`
- Wire into flake
- Test: `nix run .#claudebox` works end-to-end
### Stage 6: Polish ### Pattern 4: Profile Schema (JSON)
- Default CLAUDE.md with sandbox instructions
- Error messages for missing `~/.claudebox`
- XDG_RUNTIME_DIR handling
## Scalability Considerations **What:** A flat JSON file at `~/.claudebox/profiles/<name>.json` describing what a named profile adds to the baseline sandbox.
Not applicable -- this is a single-user local tool. The architecture is a shell script wrapping a single process. **Schema:**
```json
{
"name": "work",
"network": "inet",
"env": {
"AWS_PROFILE": "work"
},
"extra_env_passthrough": ["MY_ORG_TOKEN", "VAULT_TOKEN"],
"mounts": [
{ "host": "~/.aws/credentials", "sandbox": "~/.aws/credentials", "mode": "ro" }
],
"packages": ["awscli2", "kubectl"]
}
```
## Key Technical Notes **Loading with `jq` (already in `runtimeDeps`):**
```bash
PROFILE_NAME="${CLAUDEBOX_PROFILE:-${1#--profile=}}" # or parsed via arg loop
PROFILE_FILE="$HOME/.claudebox/profiles/${PROFILE_NAME}.json"
if [[ -f "$PROFILE_FILE" ]]; then
PROFILE_NETWORK=$(jq -r '.network // empty' "$PROFILE_FILE")
mapfile -t PROFILE_PACKAGES < <(jq -r '.packages[]? // empty' "$PROFILE_FILE")
mapfile -t PROFILE_PASSTHROUGH < <(jq -r '.extra_env_passthrough[]? // empty' "$PROFILE_FILE")
# ... etc
fi
```
### /nix/store Bind Mount Reflects Live Changes **Profile env injection:** Profile `env` entries are treated like `CLAUDEBOX_EXTRA_ENV` values — they are sandbox-side constants (not host passthrough). The `extra_env_passthrough` list extends `HOST_ALLOWLIST` dynamically, allowing host env vars not in the hardcoded allowlist to pass through when a profile explicitly permits them. This preserves the allowlist security model: the profile, not the shell environment, decides what's allowed.
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 ### Pattern 5: Nix Package Injection
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 **What:** Profile `packages` field lists nixpkgs attribute names. Resolved via `nix build --no-link --print-out-paths` before launch. Results prepended to `SANDBOX_PATH`.
`--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 **Why `nix build` not `nix shell`:** `nix shell nixpkgs#pkg` spawns a child shell and cannot inject into the *parent's* `SANDBOX_PATH` variable. `nix build --print-out-paths` returns the store path, which can be prepended to the PATH string before it's passed to `--setenv`.
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. ```bash
EXTRA_BIN_PATHS=""
for pkg in "${PROFILE_PACKAGES[@]}"; do
pkg_out=$(nix build --no-link --print-out-paths "nixpkgs#${pkg}" 2>/dev/null) || {
echo "Warning: could not resolve profile package: $pkg" >&2
continue
}
EXTRA_BIN_PATHS="${pkg_out}/bin:${EXTRA_BIN_PATHS}"
done
SANDBOX_PATH="${EXTRA_BIN_PATHS}${SANDBOX_PATH}"
```
**Latency:** Zero if the store path is already built/cached. First-time use triggers a download; thereafter cached. The existing `nix` daemon socket bind ensures builds work inside the script (the script itself runs with full host access, not inside bwrap).
**devshell injection (deferred):** Full `nix develop .#devShell` integration — where a project's own `flake.nix` devShell is evaluated and its environment injected — is significantly more complex (requires evaluating the flake, capturing `buildEnv`, etc.). Defer to a later milestone. The `packages` field covers the practical 80% use case.
## Data Flow
### Launch Flow (inet tier, with profile)
```
claudebox --profile work --network inet
|
[Arg parse] → PROFILE_NAME=work, NETWORK_TIER=inet
|
[Profile load] ~/.claudebox/profiles/work.json
→ PROFILE_NETWORK=inet, PROFILE_PACKAGES=[awscli2],
PROFILE_ENV={AWS_PROFILE=work}, PROFILE_MOUNTS=[~/.aws/credentials]
|
[Instance resolve]
INSTANCE_HASH = sha256("$CWD")[0:16]
mkdir -p ~/.claudebox/instances/$INSTANCE_HASH/.claude
|
[Package resolve]
nix build nixpkgs#awscli2 → /nix/store/xxx-awscli2
SANDBOX_PATH = /nix/store/xxx-awscli2/bin:$SANDBOX_PATH
|
[Env build]
base vars (HOME, USER, PATH=SANDBOX_PATH, SHELL, ...)
+ profile env (AWS_PROFILE=work)
+ host allowlist (TERM, LANG, ANTHROPIC_API_KEY if set, ...)
+ profile extra_env_passthrough (MY_ORG_TOKEN if set on host)
|
[Mount build]
core mounts (nix store, /etc/*, proc, dev, tmpfs ...)
+ instance dir bind (→ ~/.claudebox inside sandbox)
+ ~/.claudebox → ~/.claude symlink (unchanged)
+ auth creds ro-bind (~/.claude/.credentials.json)
+ profile mounts (~/.aws/credentials ro)
|
[DNS tempfile for inet mode]
echo "nameserver 10.0.2.3" > /tmp/resolv-$$.conf
(replaces the /etc/resolv.conf ro-bind)
|
[Env audit display + confirmation]
|
[Network: inet]
bwrap --unshare-net --pidfile $PIDFILE ... &
BWRAP_PID=$!
until [[ -s $PIDFILE ]]; do sleep 0.05; done
pasta --config-net --no-tcp-ports --no-udp-ports $(cat $PIDFILE) &
wait $BWRAP_PID → exit with its status
|
[Inside sandbox]
~/.claude/ = instance dir for this CWD (isolated history)
~/.claude/.credentials.json (ro, host OAuth token)
awscli2 in PATH
AWS_PROFILE=work in env
~/.aws/credentials accessible (ro)
internet via pasta, no LAN, no Tailscale, no host loopback
```
### State Locations (host)
| Purpose | Host Path | Sandbox Path | Mode |
|---------|-----------|-------------|------|
| Shared claudebox config | `~/.claudebox/` | n/a (host-side only) | rw |
| SANDBOX.md / CLAUDE.md | `~/.claudebox/SANDBOX.md` | n/a | rw |
| Per-project history | `~/.claudebox/instances/<hash>/` | `~/.claudebox/` | rw (bind) |
| Per-project .claude | `~/.claudebox/instances/<hash>/.claude/` | `~/.claude/` (via symlink) | rw |
| Host auth token | `~/.claude/.credentials.json` | `~/.claude/.credentials.json` | ro |
| Profile definitions | `~/.claudebox/profiles/<name>.json` | not mounted | — |
## Anti-Patterns
### Anti-Pattern 1: Mounting entire `~/.claude` for auth passthrough
**What people do:** `--bind ~/.claude ~/.claude` to get credentials working.
**Why it's wrong:** Exposes all conversation history, settings, and any file Claude Code stores in `~/.claude`. Also breaks per-project isolation because the host `.claude` overwrites the instance dir.
**Do this instead:** Mount only `~/.claude/.credentials.json` read-only. Keep the instance dir as the effective `~/.claude` target inside the sandbox.
### Anti-Pattern 2: `exec bwrap` for all network tiers
**What people do:** Keep `exec bwrap` unconditionally for simplicity even when adding `inet` mode.
**Why it's wrong:** `exec` replaces the shell process. There is no parent process left to launch the pasta sidecar after the bwrap namespace is created.
**Do this instead:** Branch on `NETWORK_TIER`: use `exec bwrap` for `full` and `none` (no sidecar needed); use `bwrap ... &` + `wait` for `inet`.
### Anti-Pattern 3: Hardcoding secrets in profile JSON
**What people do:** `"env": { "AWS_SECRET_ACCESS_KEY": "hardcoded-value" }` in a profile file.
**Why it's wrong:** Profile files at `~/.claudebox/profiles/` are plaintext. Hardcoded secrets become plaintext files on disk.
**Do this instead:** Use `extra_env_passthrough` to declare which host env vars are allowed into the sandbox. The value must be set in the host shell environment before running claudebox. The profile says "permit this variable" not "store this value."
### Anti-Pattern 4: Per-profile `nix develop` evaluation at launch
**What people do:** `nix develop .#profileEnv` to inject a full devshell environment at launch time.
**Why it's wrong:** `nix develop` evaluates a flake, which takes hundreds of milliseconds to seconds even with caching. It also requires the profile to point to a valid flake with a devShell output.
**Do this instead:** Profile `packages` field lists nixpkgs attribute names. Resolve via `nix build --no-link --print-out-paths`. Fast (cached after first use), no flake required. Full devshell support is a later milestone.
### Anti-Pattern 5: Removing `/etc/resolv.conf` mount for all tiers when adding inet support
**What people do:** Remove the resolv.conf bind globally when adding network isolation, breaking `full` mode DNS.
**Why it's wrong:** `full` and `none` tiers still rely on the `/etc/resolv.conf` bind-mount. Only `inet` mode needs it replaced with a pasta-managed DNS address.
**Do this instead:** Branch the resolv.conf mount on `NETWORK_TIER`: `full`/`none` keep the ro-bind from host; `inet` provides a tempfile pointing to pasta's gateway DNS (`nameserver 10.0.2.3`).
## Integration Points
### New vs Modified Components
| Component | Change Type | What Changes |
|-----------|------------|--------------|
| `flake.nix` | Modified | Add `pkgs.passt` to `runtimeDeps` (one line) |
| `claudebox.sh` — arg parse | Modified | Add `--profile NAME`, `--network full\|inet\|none` flags |
| `claudebox.sh` — exec block | Modified | Split `exec bwrap` into three branches by `NETWORK_TIER` |
| `claudebox.sh` — mount builder | Modified | Replace fixed `~/.claudebox` bind with instance dir bind; add auth creds ro-bind; add profile mounts |
| `claudebox.sh` — env builder | Modified | Add profile env injection after `CLAUDEBOX_EXTRA_ENV` block |
| `claudebox.sh` — profile loader | New function | `jq` parse of `~/.claudebox/profiles/<name>.json` |
| `claudebox.sh` — instance resolver | New function | `sha256sum` of CWD, `mkdir -p`, set `INSTANCE_DIR` |
| `claudebox.sh` — network setup | New function | Set `NETWORK_TIER` var; build `--unshare-net` flag; build pasta invocation for `inet` |
| `claudebox.sh` — package injector | New function | `nix build` loop, build `EXTRA_BIN_PATHS` |
| `~/.claudebox/profiles/` | New on-disk | User-created JSON profile files |
| `~/.claudebox/instances/` | New on-disk | Auto-created per-project dirs |
### Build Order (dependency-ordered)
1. **Auth passthrough** — Smallest change: add one conditional ro-bind for `~/.claude/.credentials.json`. Validates that the instance dir and auth file coexist correctly before instance dir is fully wired up. No new Nix deps. Unblocks all downstream features that require Claude to authenticate inside the sandbox.
2. **Per-project instance dirs** — Replace the single `~/.claudebox` bind-mount target with `~/.claudebox/instances/<hash>`. Depends on auth passthrough (step 1) having the credential bind path logic stable. No new Nix deps. After this, each project has isolated conversation history.
3. **Tiered network — `none` tier** — Add `--network` flag and implement `none` with `--unshare-net` alone. No new deps; bwrap is already present. Validates the exec→wait refactor structural change independently of pasta. Low risk: if offline mode is broken, `full` mode is unaffected.
4. **Tiered network — `inet` tier (pasta)** — Add `pkgs.passt` to flake, implement pasta sidecar for `inet`. Depends on step 3 having the exec→wait refactor validated. DNS tempfile handling belongs here. This is the highest-risk step (new external process, timing dependency on `--pidfile`).
5. **Named profiles (env + mounts + network tier)** — Add `--profile` flag, implement JSON profile loader with `jq`, inject env/mounts/network-tier from profile. Depends on steps 2 and 4 being stable (profiles can control all three axes). Pure shell logic, no new deps.
6. **Nix package injection** — Add `packages` field to profile schema, implement `nix build` loop. Last because it has latency risk and is independently testable without touching any other subsystem.
## Sources ## Sources
- bubblewrap documentation and manpage (training data, HIGH confidence -- bwrap is stable and rarely changes API) - Existing codebase (`claudebox.sh` lines 1351, `flake.nix`): direct read — HIGH confidence
- Nix daemon architecture (training data, HIGH confidence -- fundamental Nix design) - Claude Code credential storage path on Linux (`~/.claude/.credentials.json`): official docs at `https://code.claude.com/docs/en/authentication` — HIGH confidence
- nixpkgs `writeShellApplication` patterns (training data, HIGH confidence) - Claude Code credential precedence order: same official docs — HIGH confidence
- Linux bind mount semantics (training data, HIGH confidence -- kernel behavior) - pasta/passt PID-based namespace attachment and `--config-net` flag: `https://passt.top/passt/about/` — MEDIUM confidence (pasta standalone bwrap integration not documented with exact flag combinations; verify `--no-tcp-ports` and DNS gateway address during implementation)
- comma/nix-index mechanics (training data, MEDIUM confidence -- verify comma's current invocation style) - slirp4netns `--configure --disable-host-loopback` pattern: `https://github.com/rootless-containers/slirp4netns` manpage — MEDIUM confidence
- nixpkgs package availability: verified locally via `nix eval``pkgs.passt` version `2025_09_19.623dbf6`, `pkgs.slirp4netns` version `1.3.3` — HIGH confidence
- bwrap `--pidfile` flag: bwrap 0.4.0+ manpage; current nixpkgs has 0.9.x — HIGH confidence
- pasta as Podman 5 / RHEL 9.5 default: WebSearch corroborated — MEDIUM confidence
---
*Architecture research for: claudebox v2.0 — network isolation, profiles, auth passthrough*
*Researched: 2026-04-10*

View file

@ -1,141 +1,197 @@
# Feature Landscape # Feature Landscape
**Domain:** CLI sandbox wrapper (Nix/bubblewrap) for AI coding agents **Domain:** CLI sandbox wrapper (Nix/bubblewrap) for AI coding agents
**Researched:** 2026-04-09 **Researched:** 2026-04-10 (v2.0 milestone update — network isolation, profiles, auth passthrough)
**Confidence:** MEDIUM (based on training data for firejail, bubblejail, nixpak, bwrap; web verification unavailable) **Confidence:** MEDIUM-HIGH (web-verified for auth files, slirp4netns mechanics, Claude Code storage layout; MEDIUM for devshell injection patterns)
## Reference Projects Surveyed ---
| Project | Approach | Relevance to claudebox | ## v1.0 Features (Already Built — Reference Only)
|---------|----------|----------------------|
| **firejail** | SUID sandbox with 1000+ app profiles, seccomp, caps, filesystem overlays | Gold standard for feature breadth; overkill for claudebox's scope |
| **bubblejail** | Python wrapper around bwrap with service-based profiles (DBus, Pulse, GPU, etc.) | Closest UX model -- wraps bwrap with declarative config |
| **nixpak** | Nix module system for generating bwrap-wrapped Nix packages | Closest tech model -- Nix-native bwrap wrapping |
| **nix-bubblewrap** | Simple Nix functions for bwrap wrapping | Minimal reference for the Nix derivation approach |
| **flatpak** | Full container runtime with portals | Portal concept (controlled host access) is relevant |
## Table Stakes Core sandbox, env allowlist, secret path hiding, minimal PATH, pre-launch audit, comma/nix tool provisioning, SANDBOX.md injection, --check/--dry-run/--shell modes. Do not re-implement.
Features users expect. Missing = the wrapper is broken or useless for its stated purpose (secrets isolation for AI agents). ---
## v2.0 New Features Under Research
### Table Stakes for v2.0
Features required for the milestone to be considered complete. Without these, the milestone goals are unmet.
| Feature | Why Expected | Complexity | Notes | | Feature | Why Expected | Complexity | Notes |
|---------|--------------|------------|-------| |---------|--------------|------------|-------|
| **Filesystem isolation** | Core value proposition -- secrets must be invisible | Low | bwrap `--ro-bind`, `--bind`, `--tmpfs` for mount control. CWD read-write, everything else denied or read-only | | **Host auth passthrough** | Without it, every sandbox launch requires re-authenticating Claude Code — subscriptions and API keys are unusable | LOW | Mount `~/.claude/.credentials.json` read-only into `~/.claudebox/.credentials.json`. On Linux, Claude stores auth in `~/.claude/.credentials.json` (mode 0600). A read-only bind mount passes the file into the sandbox; Claude Code reads it on startup. No write access needed — token refresh writes back to the same file, so the mount must permit writes or use a copy-on-launch approach. |
| **Environment allowlist** | Denylist misses unknown vars; allowlist is secure-by-default | Low | `--clearenv` + explicit `--setenv` per var. All sandbox tools do this | | **Per-project instance isolation** | Running claudebox in two different projects should not share conversation history, todos, or project settings. Currently both projects write to the same `~/.claudebox/projects/` keyed by encoded path — works but muddles state if `~/.claudebox` is wiped or shared | LOW-MEDIUM | Claude Code stores sessions at `~/.claude/projects/{path-encoded}/`. A per-project instance dir `~/.claudebox/instances/<hash>/` contains its own `.claude/`; bind-mount that instead of `~/.claudebox` directly. Hash = SHA1 or MD5 of CWD absolute path. Instance dir auto-created on first launch. |
| **Secret path hiding** | `~/.ssh`, `~/.gnupg`, `~/.aws`, `~/.config/gcloud`, age keys, Tailscale state must never be visible | Low | Simply don't mount them. With bwrap's default deny, this is the natural state | | **Named profiles** | Users need repeatable configurations for different types of work (e.g., "web" profile with cloud creds, "offline" profile with no network) | MEDIUM | Profile = named config at `~/.claudebox/profiles/<name>.conf` (or `.toml`). Contains: extra env vars to pass, extra mount paths (ro or rw), extra Nix packages, network tier. Activated via `--profile foo` or `CLAUDEBOX_PROFILE=foo`. Merges with defaults; profile settings additive on top of base config. |
| **Minimal PATH** | Prevent access to host tools that might leak info or have side effects | Low | Construct PATH from explicit Nix store paths only | | **Tiered network isolation** | "full" (current, no restriction), "internet-only" (blocks LAN/Tailscale/localhost), "none" (fully offline) serve distinct use cases: internet-only prevents Claude from reaching internal services; none is for sensitive codebases | HIGH | Three tiers: `full` = current behavior (host network shared). `none` = `--unshare-net` in bwrap, no connectivity. `internet-only` = `--unshare-net` + slirp4netns as separate process providing NAT'd internet via TAP device (`--disable-host-loopback` to block LAN). This is the complex tier. |
| **Nix store read-only mount** | Required for `nix shell` and comma to work inside sandbox | Low | `--ro-bind /nix/store /nix/store` | | **Nix devshell injection** | Projects with `flake.nix` defining a devShell should be able to make those packages available to Claude inside the sandbox | MEDIUM-HIGH | Two viable approaches: (a) resolve devShell's `buildInputs` to store paths and add them to `PATH`/mounts before launch; (b) run `nix print-dev-env` to get env vars and merge them. Approach (b) is cleaner — `nix print-dev-env .#devShell` emits JSON of env vars including `PATH` modifications; parse and forward into sandbox ENV_ARGS. |
| **Persistent config directory** | Claude Code needs `~/.claude` to persist auth, settings, conversation state | Low | Bind-mount `~/.claudebox` as `~/.claude` inside sandbox |
| **Pre-launch env audit** | User must see exactly what enters the sandbox before launch | Low | Print env vars, prompt for confirmation. `--yes`/`-y` to skip |
| **Working `/tmp`** | Many tools need tmpdir; Claude Code writes temp files | Low | `--tmpfs /tmp` |
| **Working `/dev` basics** | `/dev/null`, `/dev/urandom`, `/dev/zero` needed by most programs | Low | bwrap `--dev /dev` provides these |
| **Proc filesystem** | Node.js (Claude Code runtime) needs `/proc` for process info | Low | `--proc /proc` |
| **Exit code passthrough** | Wrapper must forward the wrapped command's exit code | Low | `exec bwrap ... claude` handles this |
| **Signal forwarding** | Ctrl+C must reach Claude Code, not just kill the wrapper | Low | `exec` makes this automatic; no intermediate shell |
## Differentiators ### Differentiators for v2.0
Features that set claudebox apart from generic sandbox wrappers. Not expected in a basic bwrap wrapper, but valuable for the AI agent use case. Features that go beyond minimum requirements and add meaningful value.
| Feature | Value Proposition | Complexity | Notes | | Feature | Value Proposition | Complexity | Notes |
|---------|-------------------|------------|-------| |---------|-------------------|------------|-------|
| **Tool self-provisioning via comma** | Claude can `nix shell` or `, <tool>` to get any dev tool on demand without pre-declaration | Low | Already planned. Unique to Nix-based sandboxes. No other sandbox tool has this | | **Profile inheritance / `extends`** | A "work" profile that extends "default" instead of rewriting it avoids duplication and drift | LOW | Simple key in profile config: `extends = "default"`. Load base profile first, merge overrides. Two levels sufficient; no deep inheritance chains needed. |
| **Injected system prompt** | Claude knows it's sandboxed, knows how to use comma/nix for tools, behaves accordingly | Low | `CLAUDE_SYSTEM_PROMPT` or `~/.claudebox/CLAUDE.md` | | **Network tier shown in env audit** | User sees "Network: internet-only" in pre-launch audit alongside env vars | LOW | Extend existing audit display to include active profile name and network tier. Auditability is the core UX principle of claudebox. |
| **Env var leak detection** | Scan env vars for patterns that look like secrets (API keys, tokens, passwords) and warn even if they're on the allowlist | Medium | Regex scan for `.*KEY.*`, `.*TOKEN.*`, `.*SECRET.*`, `.*PASSWORD.*`, base64-ish strings. Firejail does similar with `--private-etc` | | **Instance dir auto-cleanup** | Old instance dirs from deleted projects waste disk space over time | LOW | Add `claudebox --gc` that removes instance dirs for paths that no longer exist on disk. |
| **Mount audit log** | Log exactly what paths are mounted and how (ro/rw) for post-hoc review | Low | Print mount table at launch (behind `--verbose` flag) | | **Profile `--list` and `--show`** | Discoverability of what profiles exist and what they contain | LOW | `claudebox --list-profiles` and `claudebox --show-profile foo`. Text output, no interactive UX needed. |
| **Project-local tool declarations** | `.claudebox.toml` or `.claudebox/tools.txt` in project root listing extra Nix packages to pre-install | Medium | Deferred per PROJECT.md, but the hook point should exist | | **`nix print-dev-env` opt-in flag** | Devshell injection requires `nix eval` which can be slow; should be explicit opt-in not auto | LOW | `--devshell` flag or `devshell = true` in profile. Never run `nix print-dev-env` implicitly. |
| **Dry-run mode** | `--dry-run` prints the full bwrap command without executing | Low | Debugging aid. firejail has `--debug`; bubblejail has similar |
| **Multiple working directories** | Mount additional paths read-only or read-write beyond CWD | Low | `--mount-ro /path` and `--mount-rw /path` flags |
| **Git credential isolation** | Provide git with a sandbox-specific credential helper or `.gitconfig` so Claude can push without accessing host SSH keys | Medium | Mount a sandbox `.gitconfig` with only HTTPS credential helpers; never SSH agent |
| **Sandbox health check** | `claudebox --check` verifies bwrap works, required Nix packages exist, config dir is set up | Low | Good onboarding UX |
## Anti-Features ### Anti-Features for v2.0
Features to deliberately NOT build. Either out of scope, security risks, or wrong layer. Features that seem natural extensions but should be explicitly avoided.
| Anti-Feature | Why Avoid | What to Do Instead | | Anti-Feature | Why Avoid | What to Do Instead |
|--------------|-----------|-------------------| |--------------|-----------|-------------------|
| **Network isolation / firewall** | Claude Code already has domain allowlisting via its proxy. Network filtering in bwrap is fragile (needs netns, slirp4netns). Wrong layer | Trust Claude Code's built-in network controls | | **Domain-level network allowlists** | Already declared out of scope in PROJECT.md. slirp4netns can be combined with iptables for domain filtering, but complexity is extreme and maintenance burden is high | Three tiers (full/internet-only/none) cover the actual use cases |
| **GUI / X11 / Wayland passthrough** | Claude Code is a CLI tool. Mounting display sockets opens a massive attack surface for zero benefit | Don't mount any display sockets | | **Writable auth file mounts** | Mounting `.credentials.json` read-write means Claude could overwrite or corrupt tokens | Mount read-only; Claude Code reads tokens, does not need to write them during a session (token refresh path is rare and can be handled by relaunching) |
| **Audio / PulseAudio / PipeWire** | No audio needed for a coding agent | Don't mount audio sockets | | **Auto-detect and inject all devShell vars** | Running `nix print-dev-env` silently on every launch for any project with a `flake.nix` breaks the "no surprises" principle and is slow | Explicit `--devshell` flag only |
| **DBus access** | No desktop integration needed. DBus is a common sandbox escape vector | Don't mount DBus sockets | | **Profile passwords / encryption** | Profiles contain env var names, mount paths, package names — not secret values. API keys still come from host env via allowlist | Keep profile files plaintext; they don't need to store secrets |
| **Configurable security profiles** | v1 is one hardcoded security posture. Profiles add complexity and misconfiguration risk. Firejail's profile system is a maintenance burden | One secure default. Profiles in a later version if needed | | **Per-profile CLAUDE.md injection** | Profiles should not override sandbox system prompt — that breaks the security invariant that Claude always knows it's sandboxed | SANDBOX.md injection is unconditional; profiles can only add mounts/env/packages/network |
| **Seccomp syscall filtering** | bwrap's namespace isolation is sufficient for the threat model (AI agent leaking secrets, not malicious binary exploitation). Seccomp adds complexity and breaks tools unpredictably | Rely on filesystem/env isolation. Add seccomp only if threat model changes | | **Multi-profile activation** | "Merge profile A and profile B" creates order-dependent ambiguity | One active profile per launch. Use `extends` for composition. |
| **Capability dropping** | Same reasoning as seccomp -- the threat is data exfiltration via the agent, not privilege escalation | bwrap already drops caps by default in user namespaces | | **Automatic profile detection from project** | Detecting profile from `.claudebox.toml` in project dir requires trusting project-local config, which is a sandbox escape vector | Profiles live in `~/.claudebox/profiles/` only. User chooses profile at launch. |
| **Persistent overlay filesystem** | Flatpak-style overlay FS adds complexity. A simple bind mount for `~/.claudebox` is sufficient | Use bind mounts |
| **Docker/OCI container wrapping** | Nix + bwrap is lighter, faster, and doesn't need a daemon | Stay with bwrap |
| **Automatic updates / self-update** | This is a Nix derivation. Updates come through the flake | Use `nix flake update` |
| **Remote/distributed sandboxing** | This runs on one machine (endurance). No need for remote execution | Out of scope |
| **Permission prompting inside sandbox** | `--dangerously-skip-permissions` is deliberate -- bwrap IS the permission layer. Adding prompts back would be redundant and annoying | The sandbox boundary replaces Claude's permission prompts |
## Feature Dependencies ---
## Feature Dependencies (v2.0)
``` ```
Filesystem isolation ─┐ Per-project instance isolation
Environment allowlist ─┤ └──requires──> Auth passthrough works correctly
Secret path hiding ────┤ (auth must be in instance dir or globally available)
Minimal PATH ──────────┼── Core sandbox (all required together)
Nix store mount ───────┤
Working /tmp,/dev,/proc┘
Nix store mount ──────── Tool self-provisioning (comma) Named profiles
└── Project-local tool declarations (future) └──provides──> Network tier configuration
└──provides──> Extra env vars (additive to base allowlist)
└──provides──> Extra mount paths
└──provides──> Nix package list for PATH injection
Pre-launch env audit ─── Env var leak detection (enhancement of audit) Tiered network isolation
└──requires──> "none" tier: just --unshare-net (trivial, no deps)
└──requires──> "internet-only" tier: slirp4netns binary + process management
└──requires──> slirp4netns in runtimeInputs
└──requires──> pid-file or fd-passing to connect slirp4netns to bwrap net ns
Persistent config dir ── Injected system prompt (lives in config dir) Nix devshell injection
└──requires──> `nix print-dev-env` available inside pre-launch environment
└──requires──> CWD has a flake.nix (validated before attempting)
└──enhances──> Named profiles (devshell can be a profile option)
Core sandbox ─────────── Dry-run mode (needs bwrap command assembled first) Profile inheritance (extends)
Core sandbox ─────────── Mount audit log (needs mount list) └──requires──> Named profiles exist first
Core sandbox ─────────── Sandbox health check (validates core works)
Multiple working dirs ── Independent (optional mount flag)
Git credential isolation ── Independent (optional .gitconfig mount)
``` ```
## MVP Recommendation ### Dependency Notes
Prioritize for v1: - **Auth passthrough and instance isolation interact:** If each instance dir is fully self-contained, auth files must either be in the instance dir (copied/linked from `~/.claude`) or mounted globally. The cleanest approach: mount `~/.claude/.credentials.json` read-only directly into `~/.claudebox/.credentials.json` unconditionally, keep it separate from instance isolation (which only scopes project history and todos).
1. **Core sandbox** (all table stakes) -- this is the entire point - **internet-only tier is a process management problem:** slirp4netns must be started before bwrap, connected to bwrap's network namespace via the `--netns-type=path` approach or by passing the network namespace fd. bwrap's `--userns-block-fd` / `--info-fd` / `--json-status-fd` flags provide synchronization primitives for this. This is the highest-complexity feature in the milestone.
2. **Tool self-provisioning via comma** -- already planned, low complexity, high value
3. **Injected system prompt** -- low complexity, dramatically improves Claude's behavior in the sandbox
4. **Pre-launch env audit with `--yes` flag** -- already planned, essential UX
5. **Dry-run mode** -- trivial to implement, invaluable for debugging
Defer: - **Nix devshell injection does not require profile system:** It can be an independent `--devshell` flag. Profile system can optionally set `devshell = true`. The two features are parallel, not sequential.
- **Env var leak detection**: Nice but not critical for v1 since the allowlist is hand-curated anyway
- **Project-local tool declarations**: Explicitly deferred in PROJECT.md
- **Git credential isolation**: Complex edge case; can be added when needed
- **Multiple working directories**: Can be added as a flag later without architectural changes
- **Mount audit log**: Low priority, `--dry-run` covers most of this need
## Lessons from Reference Projects ---
### From firejail ## Implementation Complexity Ranking
- **Profiles are a maintenance nightmare.** Firejail maintains 1000+ profiles and they constantly break on updates. claudebox should have ONE hardcoded config.
- **Allowlist beats denylist.** Firejail's `--whitelist` is more secure than its `--blacklist`. claudebox already chose this correctly.
- **`--quiet` and `--debug` flags matter.** Users want silence by default and verbosity on demand.
### From bubblejail Ordered from lowest to highest complexity, within the v2.0 scope:
- **Service-based decomposition is elegant but overkill.** Bubblejail breaks permissions into "services" (Network, Audio, GPU, etc.). For a single-purpose tool, this is unnecessary complexity.
- **Desktop file generation is a nice touch** for GUI apps, but irrelevant for CLI.
### From nixpak | Feature | Complexity | Key Challenge |
- **Nix module system for bwrap config is powerful** but adds abstraction. For a personal tool, a `writeShellApplication` with inline bwrap args is simpler and more transparent. |---------|------------|---------------|
- **Sloth (nixpak's helper)** provides a nice API for filesystem permissions. Worth looking at the mount specification approach even if not using the library directly. | Auth passthrough | LOW | Identify correct files; decide ro vs rw; one-time mount addition |
- **FHS compatibility layer** is available but not needed -- Claude Code runs from Nix store. | Network tier: none | LOW | Add `--unshare-net` to bwrap call when tier=none |
| Profile --list/--show | LOW | File glob + pretty-print |
| Network tier in audit display | LOW | Extend existing audit code |
| Per-project instance isolation | LOW-MEDIUM | Hash CWD, mkdir, adjust bind mount target |
| Named profiles (parsing + merge) | MEDIUM | Config file format, merge logic, validation |
| Nix devshell injection | MEDIUM | `nix print-dev-env` output parsing, env merge, PATH extension |
| Network tier: internet-only | HIGH | slirp4netns process lifecycle, bwrap netns synchronization, resolv.conf injection |
### From flatpak ---
- **Portals** (controlled access to host resources via DBus) are the right idea but wrong mechanism for CLI. The equivalent for claudebox is explicit mount flags.
- **"No access by default, grant explicitly"** is the correct security model and claudebox follows it. ## Auth Passthrough: Key Findings
Claude Code on Linux stores auth at `~/.claude/.credentials.json` (mode 0600). This file contains OAuth tokens. It is the only file strictly required for subscription/API key auth to work. (Source: inventivehq.com Claude Code configuration guide, verified 2026-04-10.)
Mount strategy:
- `--ro-bind "$HOME/.claude/.credentials.json" "$HOME/.claudebox/.credentials.json"` — read-only passthrough
- If file does not exist (user not authenticated), skip the mount silently; Claude Code will prompt for auth at first launch and write to the instance dir
- `settings.json` and `settings.local.json` in `~/.claude/` contain user preferences — mounting these read-only is optional but reduces friction (user's saved settings carry over)
---
## Per-Project Instance Isolation: Key Findings
Claude Code stores per-project data at `~/.claude/projects/{encoded-path}/` where encoding replaces `/` and `.` with `-`. (Source: milvus.io Claude Code local storage deep-dive, verified 2026-04-10.) Per-project instance dirs in claudebox would contain their own `~/.claude/`-equivalent, keeping conversation history, todos, and shell snapshots scoped per project.
Instance dir path: `~/.claudebox/instances/$(echo -n "$CWD" | sha256sum | cut -c1-16)/`
The bind-mount that currently maps `~/.claudebox``~/.claude` inside the sandbox would instead map `~/.claudebox/instances/<hash>``~/.claude`. Auth files should NOT live inside instance dirs (they are cross-project); they should be mounted separately.
---
## Tiered Network Isolation: Key Findings
**Tier: none** — `--unshare-net` added to bwrap flags. No other changes. Claude Code cannot reach any network. DNS fails. Nix store is local so tool installation via comma still works.
**Tier: internet-only** — Requires slirp4netns (in nixpkgs as `pkgs.slirp4netns`). The pattern:
1. bwrap is invoked with `--unshare-net` plus `--info-fd 4` to emit the sandbox PID
2. A wrapper script reads the PID from the info fd
3. slirp4netns is started: `slirp4netns --configure --disable-host-loopback <pid> tap0`
4. `--disable-host-loopback` prevents access to host's 127.x.x.x, 10.x.x.x (LAN), and Tailscale addresses
5. A custom `resolv.conf` pointing to slirp4netns's built-in DNS (10.0.2.3) is bind-mounted
Important: bwrap's `--info-fd` is a synchronization mechanism — bwrap blocks until the info fd is closed, allowing the wrapper to set up the network namespace before Claude Code starts. (Source: bwrap man page, slirp4netns GitHub README, verified 2026-04-10.)
slirp4netns does not require root privileges. It is in nixpkgs and works on NixOS.
**Tier: full** — Current behavior, no changes.
---
## Named Profiles: Design Recommendation
Config format: plain shell-style or TOML. Given the project is pure shell script with no TOML parser available in runtimeInputs, **shell key=value format** (like `.env` files) is simplest:
```sh
# ~/.claudebox/profiles/web.conf
network=internet-only
devshell=false
env=MY_EXTRA_VAR,ANOTHER_VAR
mount_ro=/home/user/shared-docs
packages=awscli2 kubectl
extends=default
```
Parsing: `source`-able if shell syntax, or simple `grep`/`awk` for key=value. Shell `source` is dangerous (arbitrary code execution) — use `awk -F= '{print $1, $2}'` parser instead.
---
## Nix Devshell Injection: Design Recommendation
`nix print-dev-env --json .#devShell` (or just `.` for default devShell) outputs a JSON object with all environment variables the devShell would set, including a modified `PATH`. Parsing this with `jq` (already in runtimeInputs) and merging into `ENV_ARGS` is the right approach.
Caveats:
- `nix print-dev-env` runs Nix evaluation — can be slow (2-10s) for complex flakes
- Must be run from CWD before entering sandbox
- Not all devShell vars are safe to forward (some reference host paths outside /nix/store)
- Should filter to only PATH modifications and explicitly safe vars
---
## Sources ## Sources
- firejail documentation and features page (training data, HIGH confidence for feature list -- firejail is well-documented and stable) - [Claude Code Authentication Docs](https://code.claude.com/docs/en/authentication) — auth file locations (HIGH confidence)
- bubblejail GitHub repository (training data, MEDIUM confidence -- less popular project) - [inventivehq.com Claude Code config guide](https://inventivehq.com/knowledge-base/claude/where-configuration-files-are-stored) — file list verification (MEDIUM confidence)
- nixpak GitHub repository and NixOS discourse discussions (training data, MEDIUM confidence) - [Claude Code .credentials.json bug issue #1414](https://github.com/anthropics/claude-code/issues/1414) — confirms Linux credential file path (HIGH confidence)
- bubblewrap man page and documentation (training data, HIGH confidence -- stable API) - [milvus.io Claude Code local storage](https://milvus.io/blog/why-claude-code-feels-so-stable-a-developers-deep-dive-into-its-local-storage-design.md) — project dir encoding, storage layout (MEDIUM confidence)
- flatpak documentation on sandboxing and portals (training data, HIGH confidence) - [slirp4netns README](https://github.com/rootless-containers/slirp4netns) — network isolation mechanics, --disable-host-loopback (HIGH confidence)
- [slirp4netns man page](https://github.com/rootless-containers/slirp4netns/blob/master/slirp4netns.1.md) — --disable-host-loopback details (HIGH confidence)
- [bubblewrap issue #392](https://github.com/containers/bubblewrap/issues/392) — slirp4netns+bwrap integration status (not merged into bwrap; external process approach needed) (HIGH confidence)
- [bwrap man page](https://manpages.debian.org/unstable/bubblewrap/bwrap.1.en.html) — --info-fd synchronization mechanism (HIGH confidence)
- Training data: nix print-dev-env JSON output format, jq availability (MEDIUM confidence — verify exact flags)
**Note:** Web search and fetch were unavailable during this research session. All findings are based on training data. The core features of these tools (bwrap flags, firejail profiles, nixpak architecture) are stable and well-established, so confidence remains reasonable despite the inability to verify against current sources. ---
*Feature research updated for v2.0 milestone: network isolation, profiles, auth passthrough, instance isolation, devshell injection*
*Researched: 2026-04-10*

View file

@ -1,401 +1,450 @@
# Domain Pitfalls # Pitfalls Research
**Domain:** Bubblewrap sandbox wrappers for CLI tools on NixOS **Domain:** Bubblewrap sandbox wrappers for CLI tools on NixOS — v2.0 additions
**Researched:** 2026-04-09 **Researched:** 2026-04-10
**Confidence:** MEDIUM (training data only, no live verification available) **Confidence:** MEDIUM-HIGH (combination of live web research + authoritative sources + training data)
This file supersedes the v1.0 pitfalls from 2026-04-09 and focuses on the four new feature areas of the v2.0 milestone: tiered network isolation (slirp4netns), per-project instance isolation, named profiles, and host auth passthrough.
---
## Critical Pitfalls ## Critical Pitfalls
Mistakes that cause sandbox escapes, broken tools, or full rewrites. ### Pitfall 1: Auth Passthrough Read-Only Mount Breaks OAuth Token Refresh
### Pitfall 1: Environment Variable Leaks via Inherited Env **What goes wrong:**
Mounting `~/.claude/.credentials.json` as `--ro-bind` (read-only) into the sandbox to provide auth passthrough seems correct from a security standpoint. In practice, Claude Code's OAuth flow performs a read-refresh-write cycle on `.credentials.json` on every session startup and periodically when the access token approaches expiry. A read-only mount causes the write to fail silently or with a cryptic EACCES error, breaking authentication.
**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:**
The mental model is "auth is a secret, secrets should be read-only." But Claude Code on Linux stores the entire credentials object — including the refreshToken — in `.credentials.json`, and OAuth access tokens expire. The refresh flow reads the current token, requests a new access+refresh token pair from Anthropic's auth server, and writes the new pair back. If the write fails, the next invocation has an expired access token and a still-valid refresh token it can't update, eventually causing 401 errors.
**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. Additionally, OAuth refresh tokens are single-use server-side. If a concurrent claude session inside the sandbox refreshes the token and can't write back, the original on-disk token is now invalid too. The user gets locked out.
**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. **How to avoid:**
Do not mount `.credentials.json` read-only. Instead, mount `~/.claude` (or the specific subset of auth files) with `--bind` (read-write). Alternatively, keep credentials on the read-write `~/.claudebox` instance directory and symlink or copy on launch, updating the host copy on exit — but this is more complex.
**Prevention:** Use `env -i` before the bwrap call, then explicitly set only allowlisted variables: The simplest correct approach: mount `~/.claude` read-write for auth files only, and keep the per-project conversation history and settings on the instance-scoped `~/.claudebox/instances/<hash>/.claude/`. Use a two-directory structure:
```bash ```
exec env -i \ ~/.claudebox/
HOME="$HOME" \ auth/ # writable bind-mount to host ~/.claude auth files
TERM="$TERM" \ instances/<hash>/ # per-project instance (conversation history, settings)
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. Inside the sandbox:
```
~/.claude -> ~/.claudebox/instances/<hash>/ (conversation history)
~/.claude/.credentials.json -> (from ~/.claudebox/auth/.credentials.json via symlink or separate bind)
```
**Detection:** Run `env` inside the sandbox. If it shows more than your allowlist, you have a leak. Build this as an automated test. **Warning signs:**
- Authentication works on first launch but fails after a few days
- Claude Code asks to re-authenticate on every launch
- Error messages mentioning 401, expired token, or authentication failure appear without the user having changed anything
**Phase:** Must be correct from Phase 1. This is the core security invariant. **Phase to address:** Auth passthrough phase (Phase 1 of v2.0). Must be correct before any other feature.
--- ---
### Pitfall 2: Missing /dev Nodes Causing Silent Failures ### Pitfall 2: slirp4netns Requires Background Process Coordination — Not a Simple bwrap Flag
**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. **What goes wrong:**
Developers treating "internet-only network isolation" as a bwrap option, similar to `--unshare-net`. There is no single bwrap flag for "internet but no LAN." The correct approach requires:
**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. 1. Creating a new network namespace via `--unshare-net`
2. Launching `slirp4netns` as a separate background process (before or concurrent with bwrap)
3. Connecting slirp4netns to the network namespace via the sandbox process's PID
4. Configuring the TAP device (`ip link set tap0 up`, `ip addr add 10.0.2.100/24 dev tap0`, `ip route add default via 10.0.2.2`)
5. Writing an `/etc/resolv.conf` inside the sandbox pointing to slirp4netns's built-in DNS at `10.0.2.3`
**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. This is a non-trivial process orchestration problem in bash: you must start bwrap, capture the sandbox PID before it execs, start slirp4netns targeting that PID, wait for slirp4netns to signal readiness (`--ready-fd`), configure the network interface inside the namespace, then let the sandbox proceed.
**Prevention:** Use `--dev /dev` (not `--dev-bind /dev /dev` which exposes host devices) and then verify these exist inside: **Why it happens:**
The conceptual model of bwrap as a self-contained invocation (set flags, exec, done) breaks down for network namespacing. slirp4netns is a peer process, not a child, and must outlive the bwrap invocation. All existing production users (podman, rootless containers, Nix daemon for fixed-output derivations) implement this in C or Go, not in bash. There is no documented bash-only reference implementation.
- `/dev/null`, `/dev/zero`, `/dev/full` **How to avoid:**
- `/dev/random`, `/dev/urandom` Implement the slirp4netns setup using bwrap's `--sync-fd` mechanism:
- `/dev/fd` (symlink to `/proc/self/fd`)
- `/dev/stdin`, `/dev/stdout`, `/dev/stderr`
- `/dev/shm` (tmpfs mount)
- `/dev/pts` and `/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`. 1. Open a pipe: `coproc ... { exec bwrap --unshare-net --sync-fd 4 ... }`
2. Read the PID from the sync fd before bwrap execs the actual command
3. Start `slirp4netns --configure --ready-fd 5 "$BWRAP_PID" tap0 &`
4. Wait for slirp4netns ready signal on fd 5
5. Release the sync fd to let bwrap proceed
**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. Alternatively, use `--userns-block-fd` to block bwrap until network setup completes. This is what Guix daemon and Podman do.
**Phase:** Phase 1. Without correct /dev, nothing works. The `--ready-fd` flag on slirp4netns writes a byte when initialization (TAP up + routing configured) is complete. Do not proceed without it — there is a window where the TAP device exists but has no route, causing the first DNS queries to fail.
**Warning signs:**
- Network works "most of the time" but occasionally fails at startup (race condition — proceeding before slirp4netns is ready)
- DNS fails inside sandbox but ping 10.0.2.2 works (route configured, DNS not yet set up)
- `nix shell` fails inside internet-only mode (missing /etc/resolv.conf pointing to 10.0.2.3)
**Phase to address:** Network isolation phase. Expect this to be the most complex phase. Plan extra time.
--- ---
### Pitfall 3: Nix Store and Nix Daemon Socket Access ### Pitfall 3: slirp4netns DNS Breaks When Host Uses systemd-resolved
**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. **What goes wrong:**
On the host, `/etc/resolv.conf` typically points to `127.0.0.53` (systemd-resolved stub) or a loopback address (dnsmasq). Inside the sandbox with `--unshare-net`, the network namespace has no loopback access to the host's DNS resolver. Bind-mounting the host's `/etc/resolv.conf` into the sandbox gives a file pointing to an address unreachable from the new namespace.
**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. slirp4netns provides a built-in DNS resolver at `10.0.2.3`, but this is only active inside the slirp4netns virtual network. You must create or bind-mount a custom `resolv.conf` inside the sandbox that says `nameserver 10.0.2.3`.
**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. **Why it happens:**
The existing claudebox script already bind-mounts `/etc/resolv.conf` from the host. When adding network isolation, this existing mount becomes wrong for the internet-only tier. Developers add slirp4netns but forget to also replace the resolv.conf.
**Prevention:** **How to avoid:**
Before launching bwrap for internet-only mode, write a temporary resolv.conf:
```bash ```bash
--ro-bind /nix/store /nix/store \ RESOLV_TMP=$(mktemp)
--ro-bind /nix/var/nix/db /nix/var/nix/db \ echo "nameserver 10.0.2.3" > "$RESOLV_TMP"
--ro-bind /nix/var/nix/daemon-socket /nix/var/nix/daemon-socket \ trap 'rm -f "$RESOLV_TMP"' EXIT
# Then in bwrap:
--ro-bind "$RESOLV_TMP" /etc/resolv.conf
``` ```
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). For the "full network" tier, the existing host resolv.conf bind-mount is correct. Make the resolv.conf source conditional on network tier.
Also mount `--ro-bind /etc/nix /etc/nix` for nix.conf (channels, substituters, trusted keys). **Warning signs:**
- `curl https://...` fails with "Could not resolve host" inside internet-only sandbox
- `nix shell` hangs at "downloading..." indefinitely
- DNS works in full-network mode but not in internet-only mode
**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 to address:** Network isolation phase, DNS subsection.
**Phase:** Phase 1. This is a core requirement per PROJECT.md.
--- ---
### Pitfall 4: DNS Resolution Fails Inside Sandbox ### Pitfall 4: slirp4netns Process Leaks When Sandbox Exits Abnormally
**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. **What goes wrong:**
slirp4netns is launched as a background process that must be killed when the sandbox exits. If the sandbox process is killed with SIGKILL, the bash trap handler does not run, and the slirp4netns process becomes an orphan owned by init. On long-running systems, these accumulate. Podman has a documented bug where zombie slirp4netns processes pile up.
**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. **Why it happens:**
bash `trap ... EXIT` handles normal exits, SIGTERM, and SIGINT but not SIGKILL. There is no portable way to register a SIGKILL handler. The `--die-with-parent` flag on bwrap causes bwrap to die if its parent (the wrapper script) dies, but the reverse (killing bwrap kills slirp4netns) is not automatic.
**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. **How to avoid:**
Use `slirp4netns --exit-fd` to give slirp4netns a file descriptor that it monitors. When the fd is closed (because the holding process exited), slirp4netns exits itself. This is the correct mechanism.
**Prevention:**
```bash ```bash
--ro-bind /etc/resolv.conf /etc/resolv.conf \ # Open a pipe; slirp4netns holds the read end
--ro-bind /etc/nsswitch.conf /etc/nsswitch.conf \ exec {EXIT_FD}<>/tmp/slirp-exit-pipe
--ro-bind /etc/ssl /etc/ssl \ slirp4netns --exit-fd "$EXIT_FD" --ready-fd "$READY_FD" "$BWRAP_PID" tap0 &
--ro-bind /etc/hosts /etc/hosts \ SLIRP_PID=$!
# Close the fd in the parent when done (or on EXIT trap)
trap "exec {EXIT_FD}>&-; kill $SLIRP_PID 2>/dev/null || true; rm -f ..." EXIT
``` ```
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. Note: `--exit-fd` requires slirp4netns 0.4.0+. Verify nixpkgs version.
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). **Warning signs:**
- `ps aux | grep slirp4netns` shows accumulating processes after repeated claudebox runs
- Memory usage grows gradually on systems with heavy claudebox usage
- Killing claudebox with Ctrl+C leaves a slirp4netns running
**Detection:** Run `curl -s https://cache.nixos.org` or `getent hosts github.com` inside the sandbox. If they fail, DNS is broken. **Phase to address:** Network isolation phase, cleanup subsection.
**Phase:** Phase 1. Without DNS, nothing network-dependent works.
--- ---
### Pitfall 5: /tmp Handling Breaks Build Tools and Nix ### Pitfall 5: Per-Project Hash Collides for Git Worktrees
**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. **What goes wrong:**
Per-project instance directories are keyed by hashing the project path (CWD). Claude Code itself uses this same approach (`~/.claude/projects/<hash-of-path>/`). When the user uses git worktrees, the main repo at `/home/user/myproject` and the worktree at `/home/user/myproject-feature` get different hashes and different instance directories. This splits conversation history and project memory across multiple isolated instances, even though they're branches of the same repository.
**Why it happens:** `/tmp` is both a security-sensitive directory (shared temp files = symlink attacks, info leaks) and a critical coordination point for IPC. Worse: if the worktree is checked out inside the main repo (at `/home/user/myproject/.worktrees/feature`), claudebox's CWD hash approach and Claude Code's internal path hash approach may disagree, creating double-isolation where the user thinks they're resuming a session but they're in a fresh one.
**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. **Why it happens:**
Hashing CWD is simple and correct for the non-worktree case. The edge case is only apparent during development workflows that use worktrees heavily (which is increasingly common with Claude Code being used for parallel feature development).
**Prevention:** Use `--tmpfs /tmp` but size it generously: **How to avoid:**
Before computing the instance hash, attempt to resolve the canonical repo root:
```bash ```bash
--tmpfs /tmp # defaults to 50% of RAM, which is usually fine canonical_project_root() {
local cwd="$1"
# If we're in a git worktree, resolve to the main worktree's root
local git_common
git_common=$(git -C "$cwd" rev-parse --git-common-dir 2>/dev/null) || { echo "$cwd"; return; }
# git-common-dir returns the .git directory for the common (main) worktree
# Strip the /.git suffix to get the project root
echo "${git_common%/.git}"
}
INSTANCE_KEY=$(canonical_project_root "$CWD")
INSTANCE_HASH=$(printf '%s' "$INSTANCE_KEY" | sha256sum | cut -c1-16)
``` ```
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). This is a best-effort approach. Document the worktree behavior clearly so users know what to expect.
Set `TMPDIR=/tmp` explicitly in the env allowlist so tools don't inherit a host-specific `TMPDIR` pointing to a non-existent path. **Warning signs:**
- User reports "it forgot everything I told it" when switching to a worktree
- Multiple instance directories accumulate for what the user thinks is one project
- `~/.claudebox/instances/` grows unexpectedly large
**Detection:** Run `mktemp` and `nix shell nixpkgs#hello -c hello` inside the sandbox. **Phase to address:** Per-project isolation phase.
**Phase:** Phase 1.
---
### Pitfall 6: Git Broken Inside Sandbox
**What goes wrong:** Git fails in multiple ways inside a bwrap sandbox:
1. **Git config not found:** `~/.gitconfig` is not mounted, so user identity (name, email) is missing. Commits fail.
2. **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`.
3. **Git credential helpers:** Configured helpers (e.g., `git-credential-libsecret`, `gh auth`) reference binaries and sockets not in the sandbox.
4. **Git hooks:** Pre-commit hooks may invoke tools not available in the minimal PATH.
5. **SSH remotes:** `~/.ssh` is intentionally hidden, so `git push/pull` over 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:**
```bash
# 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 ## Moderate Pitfalls
### Pitfall 7: /proc Mount Leaks Host Information ### Pitfall 6: Profile Config Format Creates Bash Parsing Complexity
**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. **What goes wrong:**
Named profiles (`--profile foo`) must be stored in a config format that the bash script can parse. Using anything beyond simple `KEY=VALUE` pairs (e.g., TOML, YAML, JSON) requires either parsing tools inside the Nix derivation or adding jq/tomlq/yq as runtime dependencies specifically for the profile system. Profile config often needs list values (extra mounts, extra packages), which flat KEY=VALUE cannot represent cleanly.
**Why it happens:** `--proc /proc` mounts a procfs, but its contents depend on whether `--unshare-pid` is used. Attempting to parse nested structures in bash leads to fragile code that breaks on paths with spaces, special characters, or newlines — all common in practice.
**Consequences:** Claude Code can read host process info. Not a direct secret leak, but violates the principle of minimal exposure. **Why it happens:**
Profile configs naturally want to describe lists (extra packages to add to PATH, extra bind mounts, extra env vars). The temptation is to use a "real" config format. But the wrapper script is bash, and adding a config language parser adds dependencies and complexity.
**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. **How to avoid:**
Use shell-sourceable profile files (`~/.claudebox/profiles/foo.sh`) that are sourced (not parsed) by the wrapper script. The profile file sets variables following a declared schema:
**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:
```bash ```bash
--dev /dev \ # provides basic TTY # ~/.claudebox/profiles/foo.sh
--dev-bind /dev/pts /dev/pts \ # PTY allocation PROFILE_NETWORK_TIER=internet-only
PROFILE_EXTRA_ENV=(SOME_VAR ANOTHER_VAR)
PROFILE_EXTRA_MOUNTS=(/data/myproject/secrets:/run/secrets:ro)
PROFILE_EXTRA_PACKAGES=(pkgs.python3 pkgs.postgresql)
``` ```
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). The main script sources the profile with `source "$profile_file"` after validating it contains no dangerous patterns. This avoids a config parser entirely.
**Detection:** Run `tput colors` and `tty` inside the sandbox. Check that Claude Code shows colored output. Risk: sourcing arbitrary files is a code injection vector if profile files are world-writable. Validate file permissions (must be owned by and only writable by the current user) before sourcing.
**Phase:** Phase 1. Claude Code is a terminal app. **Warning signs:**
- Profile parsing breaks on project paths containing spaces
- Lists of packages must be comma-separated, semicolon-separated, and newline-separated (inconsistency)
- Bash arrays can't be exported across source boundaries (requires workarounds)
**Phase to address:** Profile system phase.
--- ---
### Pitfall 9: XDG and Cache Directories Missing ### Pitfall 7: Nix Devshell Injection Requires Realizing Store Paths Before bwrap
**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. **What goes wrong:**
Profile-specified packages (`PROFILE_EXTRA_PACKAGES`) must be resolved to actual Nix store paths before the bwrap call, so they can be added to `SANDBOX_PATH` and potentially bind-mounted. Attempting to run `nix shell nixpkgs#python3` inside the sandbox to "inject" a package only works if Nix daemon access is available inside the sandbox — and adds startup latency.
**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. The correct approach is to resolve packages to store paths outside the sandbox before constructing the bwrap command. This requires a `nix build` or `nix eval` call in the pre-launch phase. If packages need to be fetched, this adds significant startup time (potentially 30-120 seconds for uncached packages) with no progress indication.
**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. **Why it happens:**
The natural thought is "I'll just add the package to nix shell inside the sandbox." But that re-introduces the build step inside the sandbox, and the sandbox PATH doesn't include the injected package for non-shell invocations.
**Prevention:** **How to avoid:**
In pre-launch (outside bwrap), resolve each profile package:
```bash ```bash
# Map a persistent cache dir pkg_path=$(nix build --no-link --print-out-paths "nixpkgs#${pkg}" 2>/dev/null)
--bind "$HOME/.claudebox/cache" "$HOME/.cache" \ EXTRA_PATH="${EXTRA_PATH}:${pkg_path}/bin"
--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. Cache this resolution: store the resolved store paths in `~/.claudebox/profiles/foo.resolved` with a lockfile and invalidate on flake lock update or nixpkgs channel change. Avoid re-resolving on every launch.
**Detection:** Run `echo $XDG_RUNTIME_DIR` and `ls -la ~/.cache` inside the sandbox. Show progress to the user when packages need to be fetched: "Resolving profile packages (first run may take a moment)..."
**Phase:** Phase 1. Required for Nix and Node.js operation. **Warning signs:**
- claudebox start time grows from ~1 second to 30+ seconds after adding profile packages
- Profile package resolution is re-run on every launch even when nothing changed
- `SANDBOX_PATH` doesn't include profile packages because they were resolved inside the sandbox
**Phase to address:** Profile + Nix devshell injection phase.
--- ---
### Pitfall 10: Symlink Resolution Across Mount Boundaries ### Pitfall 8: Multiple Concurrent Instances of Same Project Race on Instance Directory
**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. **What goes wrong:**
If a user runs two `claudebox` invocations in the same project directory (common when doing parallel work or forgetting a background session), both instances compute the same project hash and attempt to use the same `~/.claudebox/instances/<hash>/` directory simultaneously. Claude Code writes conversation history to JSONL files in that directory. Concurrent writes without coordination produce corrupted JSONL files.
**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. This is not hypothetical: Claude Code already has a documented OAuth token refresh race condition when multiple instances run concurrently (GitHub issue #24317, #27933).
**Consequences:** Bind mounts silently succeed but the file is empty or inaccessible. DNS breaks, SSL certs are missing, etc. **Why it happens:**
The instance directory scheme assumes one session per project. Concurrent sessions of the same project break this assumption.
**Prevention:** In the wrapper script, resolve symlinks before mounting: **How to avoid:**
Add a lockfile to the instance directory:
```bash ```bash
resolve_path() { LOCK_FILE="$INSTANCE_DIR/.claudebox.lock"
readlink -f "$1" exec {LOCK_FD}>"$LOCK_FILE"
} if ! flock -n "$LOCK_FD"; then
--ro-bind "$(resolve_path /etc/resolv.conf)" /etc/resolv.conf \ echo "Another claudebox session is already running for this project." >&2
echo "Use --force to run anyway (conversation history may be interleaved)." >&2
exit 1
fi
``` ```
Or mount entire directory trees that are known to be symlink farms (`/etc/ssl`, `/etc/static`). Or allow concurrent sessions but assign distinct JSONL sub-directories per session (using a timestamp suffix), accepting that conversation history is session-scoped not project-scoped.
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. **Warning signs:**
- Corrupted `~/.claude/projects/` JSONL files after running two terminals in the same project
- "Unexpected end of JSON input" errors in Claude Code on startup
- Session history appears partially missing
**Detection:** Run `cat /etc/resolv.conf` and `ls -la /etc/ssl/certs/ca-certificates.crt` inside the sandbox. **Phase to address:** Per-project isolation phase.
**Phase:** Phase 1. NixOS-specific but critical.
--- ---
### Pitfall 11: SSL/TLS Certificate Chain Missing ### Pitfall 9: IPv6 Tentative Address Delay Causes First-Connection Failures with slirp4netns
**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. **What goes wrong:**
When slirp4netns configures a TAP device with an IPv6 address, the Linux kernel puts the address into "tentative" state and runs Duplicate Address Detection (DAD). DAD takes several seconds to complete. During this window, outgoing connections to IPv6 addresses fail. The first `curl` or `nix shell` command issued immediately after sandbox startup may fail with a connection error, even though the same command succeeds one second later.
**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. This is documented in the Guix daemon slirp4netns implementation as a reason to explicitly disable DAD.
**Consequences:** `curl https://...` fails. `nix shell` cannot download from cache.nixos.org. Claude Code cannot reach the Anthropic API. Game over. **Why it happens:**
Standard IPv6 address assignment always includes DAD. Most production container runtimes disable DAD for virtual interfaces because they know the address is unique within a private namespace. This is non-obvious unless you've worked with container networking before.
**Prevention:** **How to avoid:**
After configuring the TAP device inside the namespace, disable DAD:
```bash ```bash
--ro-bind /etc/ssl /etc/ssl \ # Inside the network namespace (or via nsenter):
--ro-bind /etc/pki /etc/pki \ # if it exists sysctl -w net.ipv6.conf.tap0.accept_dad=0
sysctl -w net.ipv6.conf.tap0.dad_transmits=0
``` ```
Add `NIX_SSL_CERT_FILE` and `SSL_CERT_FILE` to the env allowlist, pointing to the cert bundle path. On NixOS: Or use `ip link set tap0 addrgenmode none` before assigning the address.
```bash Alternatively, if IPv6 is not needed for the use case, only configure IPv4 on the TAP device and let IPv6 be absent. Most Anthropic API endpoints and Nix binary caches resolve over IPv4.
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. **Warning signs:**
- First network request after sandbox start intermittently fails
- Problem is timing-dependent and hard to reproduce consistently
- `sleep 3 && curl https://...` works but `curl https://...` immediately after sandbox start fails
**Phase:** Phase 1. Without this, nothing network-related works. **Phase to address:** Network isolation phase, IPv6 subsection.
--- ---
## Minor Pitfalls ### Pitfall 10: Profile-Defined Extra Mounts Can Expose Secrets
### Pitfall 12: Locale Data Missing **What goes wrong:**
Allowing profiles to define arbitrary extra bind mounts via `PROFILE_EXTRA_MOUNTS` breaks the core security invariant if users put secret paths there. A profile for a "cloud deployment" project might mount `~/.aws` or `~/.ssh` — and this is exactly what the profile system is meant to support. But it means the "secrets never enter the sandbox" guarantee becomes conditional on user discipline.
**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`). The meta-risk: a compromised or misconfigured profile file (`~/.claudebox/profiles/work.sh`) can silently mount secrets without the user reviewing the audit display.
**Prevention:** Add `LOCALE_ARCHIVE` to the env allowlist, keeping its original value (which points into `/nix/store`, already mounted). **Why it happens:**
The profile system is designed to give users power to mount what they need. The same power that makes profiles useful makes them dangerous if misused. The env audit (pre-launch review) exists for env vars, but mounts are not currently in the audit display.
**Phase:** Phase 1. Easy to include. **How to avoid:**
Extend the pre-launch env audit to display active mounts from the profile:
--- ```
Active profile: work
### Pitfall 13: Home Directory Confusion Network tier: internet-only
Extra mounts:
**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. /data/myproject/config -> /run/config (read-only)
~/.aws -> ~/.aws (read-write) <-- HIGHLIGHTED IN RED
**Prevention:** Mount only the specific home subdirectories needed: Extra packages: python3, postgresql
```bash
--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. Highlight any mount that includes known-secret paths (`~/.ssh`, `~/.gnupg`, `~/.aws`, `~/.config/gcloud`, age key paths) in red with a warning. Do not block — the user may intentionally want to give Claude cloud access — but make it visible.
**Phase:** Phase 1. **Warning signs:**
- Profile silently mounts credentials without user awareness
- Pre-launch audit shows env vars but not mounts, giving false sense of security
- "It worked in the cloud project" — user discovers retrospectively that AWS keys were accessible
**Phase to address:** Profile system phase, audit integration subsection.
--- ---
### Pitfall 14: bwrap Argument Order Matters ## Technical Debt Patterns
**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. | Shortcut | Immediate Benefit | Long-term Cost | When Acceptable |
|----------|-------------------|----------------|-----------------|
**Prevention:** Order mounts from general to specific. tmpfs base directories first, then bind mounts on top: | Auth files mounted read-only | Simpler, "more secure" | Authentication breaks after token expiry | Never for active sessions |
| Shell-sourceable profile files without permission check | Avoids parser complexity | Code injection via malicious profile file | Never — always check ownership/permissions |
```bash | Skip slirp4netns --ready-fd synchronization | Simpler startup code | Race condition at sandbox start causes intermittent network failures | Never |
--tmpfs "$HOME" \ # 1. empty home base | Single global ~/.claudebox/ directory for all instances | Avoids hash computation | Concurrent sessions corrupt shared state | Never if per-project isolation is a stated goal |
--bind ... "$HOME/.claude" \ # 2. specific dirs on top | Re-run `nix build` for profile packages on every launch | Always up-to-date | 30+ second startup penalty | Never for interactive use; acceptable in CI |
``` | Hardcode `nameserver 10.0.2.3` in resolv.conf without checking slirp4netns version | Simple | Breaks if slirp4netns DNS address changes in future versions | Only as MVP, document and add TODO |
**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 ## Integration Gotchas
**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). | Integration | Common Mistake | Correct Approach |
|-------------|----------------|------------------|
**Prevention:** Always mount ALL of `/nix/store` read-only, not just specific paths: | Claude Code OAuth on Linux | Mounting `~/.claude/.credentials.json` read-only | Mount the auth directory read-write; credentials need to be refreshed on disk |
| slirp4netns + bwrap | Launching slirp4netns after bwrap execs | Capture bwrap child PID before exec using `--sync-fd`; start slirp4netns targeting that PID |
```bash | systemd-resolved host DNS | Bind-mounting host `/etc/resolv.conf` into network-isolated sandbox | Write a fresh resolv.conf pointing to `10.0.2.3` when using slirp4netns tier |
--ro-bind /nix/store /nix/store # the whole thing, not individual derivations | Git worktrees | Hash CWD for project identity | Resolve git common dir to get canonical project root before hashing |
``` | Nix devshell packages | Resolve packages inside sandbox using nix shell | Pre-resolve store paths outside sandbox before bwrap invocation; cache results |
| Multiple concurrent sessions | No coordination | Lockfile on instance directory or per-session sub-directories |
**Phase:** Phase 1.
--- ---
## Phase-Specific Warnings ## Security Mistakes
| Phase Topic | Likely Pitfall | Mitigation | | Mistake | Risk | Prevention |
|-------------|---------------|------------| |---------|------|------------|
| Basic bwrap invocation | Env leak (#1), /dev (#2), DNS (#4), SSL (#11) | Test with `env`, `curl https://`, `node -e` inside sandbox | | Sourcing profile files without permission validation | Arbitrary code execution if profile file is modified by another process or user | Check `stat` — file must be owned by current user and not group/world writable |
| Nix/comma integration | Daemon socket (#3), /tmp (#5), symlinks (#10) | Test `nix shell nixpkgs#hello -c hello` and `, cowsay` | | Displaying extra mounts in audit but not highlighting secret paths | User doesn't notice `~/.ssh` is being mounted | Highlight known secret paths in red in the audit display |
| Git operations | Git config/ownership (#6) | Test `git log`, `git diff`, `git commit` in a real repo | | Relying on slirp4netns `--disable-host-loopback` for LAN isolation | Does not block access to non-loopback LAN addresses | slirp4netns `--disable-host-loopback` only blocks 127.x.x.x; true LAN isolation requires additional iptables rules inside the namespace |
| Interactive use | TTY (#8), XDG dirs (#9) | Test full Claude Code session, check colors and prompts | | Storing instance hash as CWD path | Path is predictable; could be used to pre-create a malicious instance directory | Include the uid in the hash: `sha256sum(uid:path)` |
| Pre-launch env audit | Env allowlist completeness (#1) | Print env before and after, diff against allowlist | | Profile files with plaintext secrets | Profile file itself becomes a secret file that must be protected | Profile files should reference env var names, not values; actual values come from host environment at launch time |
| 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: ## "Looks Done But Isn't" Checklist
1. `env` -- verify env is clean - [ ] **Auth passthrough:** Verify token refresh still works 24 hours after initial auth by checking that `.credentials.json` is writable inside the sandbox
2. `curl https://api.anthropic.com` -- verify DNS + SSL - [ ] **Internet-only network:** Verify LAN addresses (192.168.x.x, 10.x.x.x) are unreachable but github.com and api.anthropic.com work
3. `nix shell nixpkgs#hello -c hello` -- verify nix-daemon - [ ] **Network offline tier:** Verify `curl https://github.com` times out, but `nix shell` still works (Nix daemon socket is a Unix socket, not network; must remain mounted)
4. `git log && git diff` -- verify git works - [ ] **Per-project isolation:** Verify two different projects get different instance directories and their conversation histories don't mix
5. `node -e "console.log('test')"` -- verify Node.js runtime - [ ] **slirp4netns cleanup:** Verify `ps aux | grep slirp4netns` shows no processes after claudebox exits normally AND after Ctrl+C
6. Actually run `claude --dangerously-skip-permissions` and have a conversation - [ ] **Profile audit display:** Verify the pre-launch audit shows active profile, network tier, extra mounts, AND extra env vars — not just env vars
7. Have Claude run a build command, install a tool with comma, edit a file - [ ] **Profile permission check:** Verify sourcing a world-writable profile file is rejected with a clear error
- [ ] **Concurrent sessions:** Verify running two claudebox instances in the same project does not corrupt JSONL history
If all seven pass, the sandbox is solid. ---
## Recovery Strategies
| Pitfall | Recovery Cost | Recovery Steps |
|---------|---------------|----------------|
| Auth passthrough breaks token refresh | MEDIUM | Re-authenticate via `claude /login`; fix mount to be read-write; may need to revoke and re-issue OAuth token |
| slirp4netns process leak | LOW | `pkill slirp4netns`; add --exit-fd to prevent recurrence |
| Instance directory corruption from concurrent sessions | MEDIUM | Delete corrupted JSONL files in `~/.claudebox/instances/<hash>/.claude/projects/`; conversation history lost but auth/settings preserved |
| Profile sources and mounts wrong packages | LOW | Remove or edit profile file; re-launch |
| IPv6 DAD causes intermittent startup failures | LOW | Add DAD disable to TAP device setup; or retry first connection |
| Wrong resolv.conf in network-isolated sandbox | LOW | Fix pre-launch resolv.conf generation for internet-only tier |
---
## Pitfall-to-Phase Mapping
| Pitfall | Prevention Phase | Verification |
|---------|------------------|--------------|
| Auth passthrough read-only breaks OAuth refresh (#1) | Auth passthrough phase | `touch ~/.claude/.credentials.json` inside sandbox succeeds; token refresh after 24h |
| slirp4netns process coordination complexity (#2) | Network isolation phase | `--dry-run` shows correct bwrap flags; network works on first launch |
| DNS breaks with systemd-resolved in isolated namespace (#3) | Network isolation phase | `curl https://cache.nixos.org` works in internet-only mode |
| slirp4netns process leak (#4) | Network isolation phase | No orphan processes after 10 start/stop cycles |
| Git worktree hash collision (#5) | Per-project isolation phase | Two worktrees of same repo share instance directory |
| Profile config parsing fragility (#6) | Profile system phase | Profile with path containing spaces works correctly |
| Nix devshell injection startup latency (#7) | Profile + devshell phase | Cached packages resolve in <1 second on second launch |
| Concurrent session race on instance directory (#8) | Per-project isolation phase | Two parallel sessions warn/block appropriately |
| IPv6 DAD delay (#9) | Network isolation phase | First `curl` after sandbox start succeeds consistently |
| Profile mounts exposing secrets silently (#10) | Profile system phase | Secret path mounts appear highlighted in pre-launch audit |
---
## Sources ## Sources
- bubblewrap documentation and man page (training data, MEDIUM confidence) - Claude Code GitHub issues: OAuth refresh race condition (#24317, #27933), credentials.json on Linux (confirmed at code.claude.com/docs/en/authentication) — HIGH confidence
- NixOS filesystem layout knowledge (training data, HIGH confidence -- well-established, unlikely to have changed) - slirp4netns man page and rootless-containers/slirp4netns GitHub — HIGH confidence (official source)
- Git safe.directory behavior from Git 2.35.2+ (training data, HIGH confidence) - Guix daemon slirp4netns implementation (mail-archive.com/guix-commits, April 2025) — HIGH confidence (authoritative implementation reference)
- Node.js /dev requirements (training data, MEDIUM confidence) - bubblewrap issue #392 (slirp4netns feature request) — MEDIUM confidence
- General Linux namespace/mount/sandbox knowledge (training data, HIGH confidence) - bubblewrap issue #633 (die-with-parent race condition) — HIGH confidence
- bubblewrap issue #504 (abstract network namespace sharing) — HIGH confidence
- Claude Code project storage structure (confirmed via inventivehq.com knowledge base and milvus.io deep dive) — HIGH confidence
- Claude Code GitHub issue #34437 (worktrees share project directory) — HIGH confidence
- Podman zombie slirp4netns issue #9777 — HIGH confidence
- IPv6 DAD behavior from Guix implementation notes — 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. ---
*Pitfalls research for: claudebox v2.0 — network isolation, per-project profiles, auth passthrough*
*Researched: 2026-04-10*

View file

@ -1,335 +1,373 @@
# Technology Stack # Technology Stack
**Project:** claudebox **Project:** claudebox v2.0 — Network Isolation & Profiles
**Researched:** 2026-04-09 **Researched:** 2026-04-10
**Note:** Research conducted from training data only (web/shell tools unavailable). Versions should be verified against current nixpkgs before implementation. **Confidence:** HIGH (network isolation), HIGH (auth passthrough), MEDIUM (profile config format), MEDIUM (devshell injection)
## Recommended Stack **Scope:** NEW additions only. Existing validated stack (writeShellApplication, bubblewrap, comma-with-db, coreutils/git/curl/jq/ripgrep/fd/nix/nodejs) is carried forward unchanged.
### Core: Nix Derivation via `writeShellApplication` ---
| Technology | Version | Purpose | Why | Confidence | ## New Runtime Dependencies
|------------|---------|---------|-----|------------|
| `writeShellApplication` | nixpkgs stable | Produce the `claudebox` wrapper script | Generates a shellcheck-validated bash script in the Nix store with runtime PATH wired to declared `runtimeInputs`. Superior to `writeShellScriptBin` because it runs shellcheck at build time and sets `set -euo pipefail` automatically. | HIGH |
| `bubblewrap` (bwrap) | 0.9.x+ | Sandbox runtime | Unprivileged user-namespace sandbox. In nixpkgs as `bubblewrap`. No setuid needed on NixOS (user namespaces enabled by default). | HIGH |
| `claude-code` | CLI | The wrapped tool | Provided by Anthropic's npm/standalone installer. Assumed pre-installed or passed as input. | HIGH |
### Runtime Dependencies (runtimeInputs) ### Add to `runtimeInputs` / `runtimeDeps`
| Package | Purpose | Why This One | Confidence | | Package | Nixpkgs Name | Version | Purpose | Why | Confidence |
|---------|---------|--------------|------------| |---------|-------------|---------|---------|-----|------------|
| `bubblewrap` | Sandbox | The whole point | HIGH | | `slirp4netns` | `pkgs.slirp4netns` | 1.3.3 (June 2025) | User-mode networking for internet-only tier | Only tool enabling unprivileged network namespace → internet without root. Creates TAP device in bwrap's `--unshare-net` namespace and routes traffic through host userspace. | HIGH |
| `coreutils` | Basic shell utils | `env`, `cat`, `echo`, `mkdir`, etc. | HIGH | | `util-linux` (`unshare`) | `pkgs.util-linux` | current nixpkgs | Namespace coordination helper | `unshare` is used internally for PID capture; `nsenter` may be needed. Most is covered by coreutils but `unshare` is in util-linux. Actually bwrap handles namespace creation itself — **only needed if you need `readlink` or `nsenter` beyond coreutils.** Defer unless needed. | MEDIUM |
| `git` | Version control | Claude Code requires git for repo operations | HIGH |
| `curl` | HTTP requests | Claude Code's MCP and tool use | HIGH |
| `jq` | JSON processing | Env audit display, config manipulation | HIGH |
| `ripgrep` | Search | Claude Code's preferred grep | HIGH |
| `fd` | File finding | Claude Code's preferred find | HIGH |
| `nix` | Package manager | Required for `nix shell` inside sandbox | HIGH |
| `comma` | On-demand packages | Runs `nix shell nixpkgs#<pkg> -c <cmd>` via `, <cmd>` syntax | HIGH |
| `nix-index` | Package database | Required by comma to resolve command -> package mapping | HIGH |
| `bash` | Shell | bwrap needs a shell to exec into | HIGH |
| `nodejs` | Runtime | Claude Code is a Node.js application | HIGH |
### NOT in runtimeInputs (Important) **Note on `slirp4netns` architecture:** slirp4netns must run on the **host** side and be given the PID of the bwrap'd process's network namespace. It cannot run inside the sandbox. This means `slirp4netns` must be in `runtimeInputs` so it is available in the wrapper script's PATH pre-bwrap exec.
| Package | Why Excluded | **No new Nix flake inputs needed** — `pkgs.slirp4netns` is in nixpkgs unstable.
|---------|-------------|
| `gnupg` | Secret material -- explicitly hidden |
| `openssh` | Secret material -- explicitly hidden |
| `age` / `agenix` | Secret material -- explicitly hidden |
| `tailscale` | Infrastructure access -- explicitly hidden |
## Key Nix Functions ---
### `writeShellApplication` -- Use This ## Network Isolation: Tiered Architecture
```nix ### Three Tiers
pkgs.writeShellApplication {
name = "claudebox";
runtimeInputs = with pkgs; [
bubblewrap coreutils git curl jq ripgrep fd
nix comma nix-index bash nodejs
];
text = ''
# Script body here -- bwrap invocation
# PATH is automatically set to include all runtimeInputs
# set -euo pipefail is automatic
# shellcheck runs at build time
'';
}
```
**Confidence:** HIGH -- this is the standard nixpkgs pattern for wrapper scripts. | Tier | bwrap Flag | slirp4netns | Effect |
|------|-----------|-------------|--------|
| `full` (default) | *(no change)* | Not used | Full host network access. Current v1.0 behavior. |
| `internet` | `--unshare-net` | Yes, with `--disable-host-loopback` | Internet access via NAT. No LAN, no Tailscale, no localhost. |
| `none` | `--unshare-net` | Not used | Fully offline. Loopback only (localhost works inside sandbox). |
### Why NOT These Alternatives ### Internet Tier: The Shell Pattern
| Alternative | Why Not | The internet tier requires process coordination because slirp4netns runs on the host and must be given the bwrap child's PID. `exec bwrap` cannot be used — you need the PID. The pattern:
|-------------|---------|
| `writeShellScriptBin` | No shellcheck, no automatic `set -euo pipefail`, no `runtimeInputs` wiring. You'd have to manually construct PATH. |
| `makeWrapper` / `wrapProgram` | Designed for wrapping existing binaries with env vars/flags. Overkill and wrong abstraction -- we're writing a new script, not patching an existing binary. |
| `symlinkJoin` + `makeWrapper` | Pattern for combining multiple derivations. Not needed -- we have one script. |
| `stdenv.mkDerivation` | Too heavy. `writeShellApplication` is a specialized shortcut for exactly this use case. |
| `runCommand` / `writeScript` | Lower-level, no shellcheck, no runtimeInputs. |
## Bubblewrap Flags
### Namespace Isolation
```bash ```bash
# Create a ready pipe for synchronization
ready_fd_r ready_fd_w
exec {ready_fd_r}<> <(:) # or use mkfifo
# Launch bwrap in background to get its PID
bwrap \ bwrap \
--unshare-user # New user namespace (maps to root inside) --unshare-net \
--unshare-pid # New PID namespace (can't see host PIDs) --info-fd 4 \ # bwrap writes JSON with child PID to this FD
--unshare-ipc # New IPC namespace ... \
--unshare-uts # New UTS namespace (hostname isolation) -- "$CLAUDE_BIN" ... &
# NOTE: --unshare-net is OUT OF SCOPE per project constraints BWRAP_PID=$!
# NOTE: --unshare-cgroup usually unnecessary for this use case
# Read child PID from bwrap's --info-fd output
# (bwrap writes {"child-pid": N} to fd 4)
read -r CHILD_PID < <(...)
# Start slirp4netns targeting that PID's network namespace
slirp4netns \
--configure \
--mtu=65520 \
--disable-host-loopback \
--ready-fd 5 \ # slirp4netns signals ready on this FD
"$CHILD_PID" tap0 &
SLIRP_PID=$!
# Wait for slirp4netns to signal ready
read -r _ <&5
# Now wait for bwrap to finish
wait "$BWRAP_PID"
EXIT_CODE=$?
# Clean up slirp4netns
kill "$SLIRP_PID" 2>/dev/null
exit "$EXIT_CODE"
``` ```
**Confidence:** HIGH -- these are standard bwrap namespace flags. **bwrap `--info-fd` flag:** bwrap writes `{"child-pid": N}` (JSON) to the specified file descriptor when the child starts. This is the correct way to get the sandboxed process's PID for slirp4netns targeting. This avoids the two-terminal pattern seen in general slirp4netns docs.
### Filesystem Mounts **slirp4netns `--ready-fd` flag:** slirp4netns writes `"1"` to this FD when TAP interface is configured. Use this to know when the network is ready before proceeding.
**slirp4netns default network config inside sandbox:** 10.0.2.100 (host IP), 10.0.2.2 (gateway), 10.0.2.3 (DNS). These are hardcoded defaults that work for most cases.
**`--disable-host-loopback`:** Prevents the sandbox from connecting to 127.0.0.1 on the host side. This is the key flag for blocking LAN/Tailscale services. Combined with a new network namespace, the sandbox cannot see host LAN addresses (192.168.x.x, 100.x.x.x Tailscale ranges) because it has no routes to them.
**Confidence:** HIGH for the approach. MEDIUM for exact bash FD plumbing — test the `--info-fd` JSON parsing.
### None Tier: Simple
```bash ```bash
bwrap \ bwrap --unshare-net ... -- "$CLAUDE_BIN" ...
# Empty root
--tmpfs / # Start with empty tmpfs root
# Nix store (read-only, required for nix/comma)
--ro-bind /nix/store /nix/store # All Nix packages
--ro-bind /nix/var /nix/var # Nix DB (needed for nix commands)
# System essentials
--ro-bind /etc/resolv.conf /etc/resolv.conf # DNS resolution
--ro-bind /etc/ssl /etc/ssl # TLS certificates
--ro-bind /etc/nix /etc/nix # Nix config (substituters, etc.)
--proc /proc # /proc filesystem
--dev /dev # Device nodes
# Working directory (read-write)
--bind "$PWD" "$PWD" # CWD mounted read-write
# Claude config (remapped)
--bind "$HOME/.claudebox" "$HOME/.claude" # Persistent config
# Home directory structure
--tmpfs "$HOME" # Empty home
--bind "$PWD" "$PWD" # Re-bind CWD after home tmpfs
# Temp
--tmpfs /tmp # Writable tmp
--tmpfs /run # Writable run
``` ```
**Confidence:** HIGH for the pattern, MEDIUM for exact mount ordering (test on NixOS to confirm). No slirp4netns. bwrap creates an isolated network namespace with only loopback (127.0.0.1) configured. `exec bwrap` still works here.
### Mount Ordering Matters ### Full Tier: No Change
bwrap processes mounts in order. Later mounts can overlay earlier ones. The correct order is: Current behavior. No `--unshare-net`. `exec bwrap` still works.
1. `--tmpfs /` (empty root) ### Network Tier Implication: `exec` vs `wait`
2. `--ro-bind /nix/store` (packages)
3. `--tmpfs $HOME` (empty home)
4. `--bind $HOME/.claudebox $HOME/.claude` (config into home)
5. `--bind $PWD $PWD` (working directory -- after home tmpfs if CWD is under HOME)
**Confidence:** HIGH -- this is well-documented bwrap behavior. For `full` and `none` tiers, `exec bwrap` is clean and efficient. For the `internet` tier, you cannot use `exec` because you need the PID. The wrapper script must branch:
### Environment Handling
```bash ```bash
bwrap \ if [[ "$NETWORK_TIER" == "internet" ]]; then
--clearenv # Start with empty environment # background + slirp4netns + wait pattern
--setenv HOME "$HOME" # Explicit home else
--setenv PATH "$SANDBOX_PATH" # Controlled PATH exec bwrap ... -- "$SANDBOX_CMD"
--setenv TERM "$TERM" # Terminal type fi
--setenv LANG "$LANG" # Locale
--setenv EDITOR "nano" # Safe editor (no vim with plugin configs)
--setenv USER "$USER" # Username
--setenv SHELL "/bin/bash" # Shell
--setenv NIX_PATH "$NIX_PATH" # For nix-env/comma
--setenv XDG_CACHE_HOME "$HOME/.cache"
--setenv TMPDIR /tmp
``` ```
**`--clearenv` is the critical flag.** This implements the allowlist model: start empty, add explicitly. Without it, you're doing denylist (trying to `--unsetenv` secrets you know about -- guaranteed to miss some). ---
**Confidence:** HIGH ## Auth Passthrough: Host ~/.claude Files
### Process Execution ### What Claude Code Stores in ~/.claude
From official docs (code.claude.com/docs/en/authentication):
- `~/.claude/.credentials.json` — OAuth tokens for Claude.ai subscription (Linux, mode 0600)
- `~/.claude/settings.json` — User settings (model, editor preferences, etc.)
- `$CLAUDE_CONFIG_DIR/.credentials.json` — If env var is set
**Auth passthrough means:** Mount host `~/.claude/.credentials.json` read-only into the sandbox's `~/.claude/` directory so Claude Code inside can authenticate with the user's existing subscription without re-logging in.
### Current Situation vs Target
Currently, `~/.claudebox` is bind-mounted as `~/.claude` inside the sandbox. This correctly isolates conversation history per the existing design. The problem: `~/.claudebox` does not have credentials — those live in host's `~/.claude`.
### Recommended Pattern
Selective file mounts, not directory replacement:
```bash ```bash
bwrap \ # Mount instance dir as ~/.claude (for history isolation)
--die-with-parent # Kill sandbox if parent dies --bind "$INSTANCE_DIR" "$HOME/.claude" \
--new-session # New session (prevents tty hijacking)
-- \ # Then overlay specific auth files from host ~/.claude (read-only)
claude --dangerously-skip-permissions "$@" --ro-bind "$HOME/.claude/.credentials.json" "$HOME/.claude/.credentials.json" \
``` ```
**Confidence:** HIGH Mount ordering in bwrap: the `--ro-bind` overlay after the `--bind` for the directory works because bwrap processes mounts sequentially. The credentials file mount overlays the one that would exist in the instance dir.
## Comma and nix-index **Guard the mount:** Only add `--ro-bind` if the credentials file exists on the host:
### How Comma Works
`comma` (the `,` command) is a wrapper that:
1. Takes a command name (e.g., `, python3`)
2. Looks up which nixpkgs package provides that command using `nix-index` database
3. Runs `nix shell nixpkgs#<package> -c <command> [args...]`
### Packaging in nixpkgs
- `pkgs.comma` -- the comma binary itself
- `pkgs.nix-index` -- the indexer that builds/queries the database
- Database: comma needs a pre-built index. Two options:
1. **`nix-index-database`** (flake from nix-community) -- pre-built weekly index, no local indexing needed. This is what you want.
2. `nix-index --update` -- builds index locally, takes 30+ minutes. Don't do this.
### For claudebox
The nix-index database needs to be available inside the sandbox. Options:
- Mount `~/.cache/nix-index` read-only into the sandbox (if using nix-index-database on the host)
- Or use the `nix-index-database` flake's `comma-with-db` package which bundles the database
**Recommendation:** Use `comma-with-db` from `nix-index-database` flake if available, otherwise mount the host's nix-index database read-only.
**Confidence:** MEDIUM -- comma-with-db packaging may have changed. Verify against current `nix-community/nix-index-database` flake.
## Flake Structure
```nix
{
description = "claudebox - sandboxed Claude Code wrapper";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
# Optional: pre-built nix-index database
nix-index-database = {
url = "github:nix-community/nix-index-database";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, nix-index-database, ... }:
let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
in {
packages.${system}.default = pkgs.writeShellApplication {
name = "claudebox";
runtimeInputs = with pkgs; [
bubblewrap coreutils git curl jq ripgrep fd
nix bash nodejs
# comma with bundled database from nix-index-database flake
];
text = builtins.readFile ./claudebox.sh;
};
# Or as an overlay for integration with host NixOS config
overlays.default = final: prev: {
claudebox = self.packages.${final.system}.default;
};
};
}
```
**Confidence:** HIGH for the pattern, MEDIUM for nix-index-database integration specifics.
### Why `builtins.readFile` for the Script Body
Keep the shell script in a separate `claudebox.sh` file rather than inline in Nix. Reasons:
- Shell syntax highlighting in editors
- Shellcheck can run independently
- Easier to iterate on the script without touching Nix expressions
- `writeShellApplication` still runs shellcheck on it at build time
**Confidence:** HIGH
## PATH Construction Inside Sandbox
The sandbox PATH should only contain Nix store paths. `writeShellApplication` handles this for the wrapper script itself, but the PATH *inside* the bwrap sandbox needs to be constructed explicitly:
```bash ```bash
SANDBOX_PATH="" AUTH_MOUNTS=()
for pkg in ${coreutils} ${git} ${curl} ${jq} ${ripgrep} ${fd} ${nix} ${comma} ${bash} ${nodejs}; do if [[ -f "$HOME/.claude/.credentials.json" ]]; then
SANDBOX_PATH="$SANDBOX_PATH:$pkg/bin" AUTH_MOUNTS+=(--ro-bind "$HOME/.claude/.credentials.json" "$HOME/.claude/.credentials.json")
done fi
SANDBOX_PATH="${SANDBOX_PATH#:}" # Remove leading colon
``` ```
Or more idiomatically in Nix, construct the PATH in the Nix expression and interpolate it into the script: **Confidence:** HIGH for the pattern. The file path is confirmed from official docs.
```nix ---
let
sandboxPath = lib.makeBinPath [ ## Per-Project Instance Isolation
pkgs.coreutils pkgs.git pkgs.curl pkgs.jq
pkgs.ripgrep pkgs.fd pkgs.nix pkgs.comma ### Instance Directory Pattern
pkgs.bash pkgs.nodejs
]; ```
in ~/.claudebox/instances/<hash>/.claude/
pkgs.writeShellApplication {
text = ''
SANDBOX_PATH="${sandboxPath}"
# ... bwrap --setenv PATH "$SANDBOX_PATH" ...
'';
}
``` ```
**Use `lib.makeBinPath`** -- it joins `/nix/store/.../bin` paths with colons. This is the standard nixpkgs function for constructing PATH. Where `<hash>` is a stable identifier for the project. Options:
**Confidence:** HIGH | Hash Input | Pros | Cons |
|------------|------|------|
| `md5sum "$CWD"` | Stable as long as project doesn't move | Breaks if project relocated |
| `sha256sum "$CWD"` | Same | Same |
| `basename "$CWD"` | Human readable | Collides across different projects with same dir name |
| `basename "$CWD"`-`md5sum "$CWD" \| head -c8` | Human readable + unique | Slightly longer |
## Nix Store Access Inside Sandbox **Recommendation:** `$(basename "$CWD")-$(echo "$CWD" | md5sum | head -c8)` — readable in `ls` output, collision-resistant.
For `nix shell` and comma to work inside the sandbox, you need:
1. `/nix/store` mounted read-only (packages)
2. `/nix/var/nix` mounted -- needed for `nix` commands to find the database
3. Nix daemon socket: `/nix/var/nix/daemon-socket/socket` -- needed for `nix shell` to trigger builds/downloads
4. `NIX_PATH` or registry config for `nixpkgs` resolution
The daemon socket is critical and easy to miss. Without it, `nix shell` fails because it can't talk to the Nix daemon.
```bash ```bash
--ro-bind /nix/store /nix/store INSTANCE_HASH="$(basename "$CWD")-$(echo "$CWD" | md5sum | cut -c1-8)"
--ro-bind /nix/var/nix/db /nix/var/nix/db INSTANCE_DIR="$HOME/.claudebox/instances/$INSTANCE_HASH"
--ro-bind /nix/var/nix/daemon-socket /nix/var/nix/daemon-socket mkdir -p "$INSTANCE_DIR"
# In bwrap call:
--bind "$INSTANCE_DIR" "$HOME/.claude" \
``` ```
Wait -- the daemon socket needs to be bind-mounted (not ro-bind) because it's a Unix socket: **No new packages needed** — `md5sum` is in coreutils (already in runtimeInputs).
**Confidence:** HIGH for the pattern.
---
## Named Profiles: Config Format
### Recommendation: Bash-sourced config files (`.sh`)
**Format:** Simple `KEY=value` bash files, sourced by the wrapper script.
```bash ```bash
--bind /nix/var/nix/daemon-socket /nix/var/nix/daemon-socket # ~/.claudebox/profiles/myproject.sh
CLAUDEBOX_NETWORK=internet
CLAUDEBOX_EXTRA_ENV=MY_API_KEY,ANOTHER_VAR
CLAUDEBOX_EXTRA_MOUNTS="/data/shared:/data/shared:ro"
CLAUDEBOX_EXTRA_PACKAGES="python3 postgresql"
``` ```
**Confidence:** MEDIUM -- socket bind-mount behavior should be tested. The daemon socket may need `--bind` not `--ro-bind`. **Why bash-sourced, not TOML/JSON/YAML:**
## Testing Strategy 1. Zero new dependencies — no parser needed, no `jq` gymnastics for complex formats
2. Fits the existing stack (`bash`, `coreutils` already in PATH)
3. Consistent with how the project already handles `CLAUDEBOX_EXTRA_ENV` (existing escape hatch)
4. `writeShellApplication` + shellcheck already validates the wrapper; sourcing a profile file is idiomatic bash
5. Security: the profile file runs as the user who owns it (`~/.claudebox/profiles/` is user-owned), same attack surface as `.bashrc`
Since this is a shell script wrapped in Nix: **Why not TOML:**
- Requires a TOML parser. `jq` cannot parse TOML. Options would be `dasel`, `taplo`, or `python3 -c "import tomllib"` — all add dependencies or require `nix shell` at startup (unacceptable latency).
- OpenAI Codex uses TOML but they have a full Rust binary with TOML parsing built in. claudebox is a shell script.
1. **Build test:** `nix build` succeeds (shellcheck passes) **Why not JSON:**
2. **Smoke test:** `claudebox` launches, `env` inside shows only allowlisted vars - `jq` is already in PATH and could parse JSON. But JSON does not support comments, which makes profiles harder to self-document. Multi-value fields (extra mounts list) are awkward as JSON arrays being bash-parsed.
3. **Secret test:** Verify `ls ~/.ssh` fails, `cat /etc/shadow` fails, `env | grep -i key` returns nothing
4. **Comma test:** `, python3 --version` works inside sandbox
5. **Mount test:** Can write to CWD, cannot write outside CWD
No test framework needed. A simple `test.sh` with assertions suffices. **Profile loading:**
**Confidence:** HIGH ```bash
# Resolve profile path
if [[ -n "${CLAUDEBOX_PROFILE:-}" ]]; then
PROFILE_FILE="$HOME/.claudebox/profiles/${CLAUDEBOX_PROFILE}.sh"
if [[ -f "$PROFILE_FILE" ]]; then
# shellcheck source=/dev/null
source "$PROFILE_FILE"
else
echo "Error: profile '$CLAUDEBOX_PROFILE' not found at $PROFILE_FILE" >&2
exit 1
fi
fi
```
**CLI flag:** `--profile foo` sets `CLAUDEBOX_PROFILE=foo` before the source call.
**Confidence:** HIGH for the bash-sourced approach given the project constraints.
---
## Nix Devshell Injection Per Profile
### The Goal
A profile can declare extra Nix packages to make available inside the sandbox. Example: a Python project profile adds `python3`, `poetry` to the sandbox PATH without hardcoding them into the global claudebox derivation.
### Approach: `nix shell` to Resolve Store Paths at Runtime
The derivation cannot know profile packages at build time. Resolution happens at wrapper script execution time.
**Pattern:**
```bash
# In profile file:
CLAUDEBOX_EXTRA_PACKAGES="python3 poetry"
# In wrapper script, if CLAUDEBOX_EXTRA_PACKAGES is set:
if [[ -n "${CLAUDEBOX_EXTRA_PACKAGES:-}" ]]; then
# Build a space-separated list of nixpkgs#pkg references
PKG_ARGS=()
read -ra pkgs <<< "$CLAUDEBOX_EXTRA_PACKAGES"
for pkg in "${pkgs[@]}"; do
PKG_ARGS+=("nixpkgs#$pkg")
done
# Resolve store paths using nix path-info
EXTRA_PATHS=""
for pkg in "${pkgs[@]}"; do
store_path=$(nix eval --raw "nixpkgs#${pkg}.outPath" 2>/dev/null)
if [[ -n "$store_path" ]]; then
EXTRA_PATHS="${EXTRA_PATHS}:${store_path}/bin"
fi
done
# Prepend to SANDBOX_PATH
SANDBOX_PATH="${EXTRA_PATHS#:}:${SANDBOX_PATH}"
fi
```
**Alternative: `nix build --no-link --print-out-paths`**
```bash
store_path=$(nix build --no-link --print-out-paths "nixpkgs#$pkg" 2>/dev/null)
```
This forces the package to be built/fetched if not in store. `nix eval --raw` does not build — it only resolves the path, which may not exist if the package isn't already in the store. For a wrapper script, `nix build` is safer but adds latency on first use.
**Recommendation:** Use `nix build --no-link --print-out-paths "nixpkgs#${pkg}"` per package. Cache the result is implicit (Nix store is content-addressed; rebuild is a no-op if already present).
**No new Nix flake inputs needed** — `nix` is already in runtimeInputs.
**Confidence:** MEDIUM — `nix eval --raw` vs `nix build` behavior distinction should be verified. The pattern is sound; exact subcommand flags may need adjustment.
---
## Extra Mount Injection Per Profile
Profile-declared extra mounts follow the same additive pattern:
```bash
# In profile:
CLAUDEBOX_EXTRA_MOUNTS="/data/shared:/data/shared:ro /home/user/keys:/keys:ro"
# In wrapper:
EXTRA_MOUNT_ARGS=()
if [[ -n "${CLAUDEBOX_EXTRA_MOUNTS:-}" ]]; then
read -ra mount_specs <<< "$CLAUDEBOX_EXTRA_MOUNTS"
for spec in "${mount_specs[@]}"; do
IFS=':' read -r src dst mode <<< "$spec"
if [[ "$mode" == "ro" ]]; then
EXTRA_MOUNT_ARGS+=(--ro-bind "$src" "$dst")
else
EXTRA_MOUNT_ARGS+=(--bind "$src" "$dst")
fi
done
fi
```
**Confidence:** HIGH — straightforward bwrap flag construction.
---
## Alternatives Considered
| Feature | Recommended | Alternative | Why Not |
|---------|-------------|-------------|---------|
| Network isolation | slirp4netns + `--unshare-net` | pasta (passt) | pasta is newer and used by podman but less mature than slirp4netns for bwrap integration; slirp4netns has `--ready-fd` for synchronization |
| Network isolation | slirp4netns + `--unshare-net` | nftables/iptables rules | Requires root; incompatible with unprivileged bwrap model |
| Profile format | bash-sourced `.sh` | TOML | TOML requires a parser binary not in the current stack |
| Profile format | bash-sourced `.sh` | JSON + jq | JSON lacks comments; multi-value fields are awkward in bash; `.sh` is simpler |
| Devshell injection | `nix build --no-link --print-out-paths` | Include packages in derivation | Derivation is built once; profiles are dynamic; runtime resolution is the only option |
| Instance hash | `basename`-`md5sum` | git hash | Not all projects are git repos; CWD is always available |
---
## What NOT to Add
| Avoid | Why | Use Instead |
|-------|-----|-------------|
| `pasta` / `passt` | More complex setup than slirp4netns; slirp4netns is the established tool in nixpkgs with `--ready-fd` sync support | `slirp4netns` |
| `iptables` / `nftables` for network filtering | Requires root; incompatible with unprivileged bwrap model | `--unshare-net` + slirp4netns |
| `dasel` / `taplo` for TOML parsing | Adds dependencies not in current stack | Bash-sourced profile files |
| `docker` / `podman` for extra isolation | Project constraint: bwrap only, no Docker | Existing bwrap model |
| Separate Nix flake per profile | Each profile would need a separate derivation build; defeats the purpose of dynamic profiles | Runtime `nix build` resolution |
| `CLAUDE_CONFIG_DIR` env var redirection | Complicates the `~/.claude` → instance dir mapping; better to mount directly | Direct `--bind "$INSTANCE_DIR" "$HOME/.claude"` |
---
## Version Verification Checklist
Before implementation:
- [ ] Verify `pkgs.slirp4netns` version in current nixpkgs-unstable (expected: 1.3.x)
- [ ] Confirm `bwrap --info-fd` flag is available in installed bubblewrap version (added in 0.4.0+)
- [ ] Confirm `slirp4netns --ready-fd` flag is available (added in 0.4.0+)
- [ ] Test `nix build --no-link --print-out-paths` works inside the sandbox (nix daemon socket is already mounted)
- [ ] Confirm `~/.claude/.credentials.json` is the correct path on this host (check `ls ~/.claude/`)
- [ ] Verify bwrap overlay mount order works: `--bind DIR` then `--ro-bind FILE_INSIDE_DIR` for auth passthrough
---
## Sources ## Sources
- Training data knowledge of nixpkgs `writeShellApplication` (stable API since 2022) - https://github.com/rootless-containers/slirp4netns — v1.3.3 release confirmed June 2025, `--ready-fd`, `--configure`, `--disable-host-loopback` flags documented (HIGH confidence)
- Training data knowledge of bubblewrap (stable API, project is mature) - https://code.claude.com/docs/en/authentication — `~/.claude/.credentials.json` path on Linux confirmed from official docs (HIGH confidence)
- Training data knowledge of comma/nix-index-database (nix-community project) - https://manpages.debian.org/unstable/bubblewrap/bwrap.1.en.html — `--unshare-net`, `--share-net`, `--info-fd` flags documented (HIGH confidence)
- **All versions should be verified against current nixpkgs before implementation** - https://repology.org/project/slirp4netns/versions — Version tracking across distributions
- Training data: bash-sourced config file pattern, `nix build --no-link`, `lib.makeBinPath` (HIGH confidence for patterns, verify exact flags)
## Verification Checklist (for implementation phase) ---
*Stack research for: claudebox v2.0 network isolation, profiles, auth passthrough*
- [ ] Confirm `bubblewrap` version in current nixpkgs channel *Researched: 2026-04-10*
- [ ] Confirm `comma` and `nix-index-database` flake are current and compatible
- [ ] Test Nix daemon socket access through bwrap bind mount
- [ ] Test mount ordering with CWD under $HOME
- [ ] Confirm `--clearenv` + `--setenv` pattern works with Claude Code (it may need vars we haven't listed)
- [ ] Check if Claude Code needs `~/.local` or `~/.config` beyond `~/.claude`

View file

@ -1,172 +1,156 @@
# Project Research Summary # Project Research Summary
**Project:** claudebox **Project:** claudebox v2.0 — Network Isolation & Profiles
**Domain:** Nix bubblewrap sandbox wrapper for AI coding agents **Domain:** Nix/bubblewrap sandbox wrapper for AI coding agents (Claude Code)
**Researched:** 2026-04-09 **Researched:** 2026-04-10
**Confidence:** MEDIUM-HIGH **Confidence:** HIGH (auth passthrough, instance isolation, network none/full tiers), MEDIUM-HIGH (internet-only tier, profiles)
## Executive Summary ## Executive Summary
claudebox is a single-purpose Nix derivation that wraps Claude Code in a bubblewrap sandbox, hiding secrets (SSH keys, GPG, AWS creds, age keys, Tailscale state) while preserving full coding capability. The expert approach is straightforward: `writeShellApplication` produces a shellcheck-validated bash script that assembles bwrap flags and `exec`s into the sandboxed Claude process. The entire security model rests on two primitives -- `--clearenv` (environment allowlist) and selective filesystem bind-mounts (default-deny). This is a well-trodden pattern in the Nix ecosystem (nixpak, bubblejail, nix-bubblewrap all do variations of it). claudebox v2.0 extends the validated v1.0 foundation (writeShellApplication, bubblewrap, comma-with-db) with four new capability areas: tiered network isolation, per-project instance isolation, named profiles, and host auth passthrough. The only new runtime dependency is a userspace networking sidecar (`pkgs.slirp4netns` or `pkgs.passt`) for the internet-only network tier. All other features are pure shell extensions with no new deps.
The recommended approach is a five-stage shell script (arg parsing, env building, env audit, bwrap invocation, exec claude) packaged as a Nix flake. The key differentiator over generic sandbox wrappers is tool self-provisioning via comma/nix-index-database -- Claude can install any Nix package on demand inside the sandbox because the Nix daemon socket is bind-mounted, and new store paths appear through the live bind mount. This eliminates the "frozen toolset" problem that makes most sandboxes painful for development work. The recommended build order is strictly dependency-driven. Auth passthrough must come first because every downstream feature assumes Claude Code can authenticate inside the sandbox. Instance isolation depends on the auth mount path being stable. The `none` network tier validates the exec-to-wait refactoring before the high-complexity `inet` tier is added. Named profiles tie together all prior subsystems. Nix package injection is independently testable and should be last.
The primary risks are all in the "looks like it works but doesn't" category. The sandbox can appear functional after a basic test while leaking environment variables, lacking DNS resolution, missing SSL certificates, or having broken git. Research identified 15 specific pitfalls, 6 of which are critical and all manifest in Phase 1. The mitigation is a strict 7-point integration test (env check, curl HTTPS, nix shell, git operations, Node.js, full Claude session, tool installation via comma) that must pass before declaring any phase complete. The highest-risk feature is the internet-only network tier. It requires process coordination between bwrap and a sidecar: bwrap must be backgrounded (not exec'd), its sandbox PID captured via `--info-fd` or `--pidfile`, the sidecar started and waited on for readiness, and a custom `/etc/resolv.conf` injected because the host's resolv.conf points to a loopback DNS unreachable from the new network namespace.
## Key Findings ## Key Findings
### Recommended Stack ### Recommended Stack
The stack is minimal by design -- a shell script and two Nix packages. No frameworks, no languages beyond bash, no build systems beyond Nix. The existing stack carries forward unchanged. New additions:
**Core technologies:** **Core technologies:**
- **`writeShellApplication`**: Nix function to produce the wrapper -- provides shellcheck at build time, `set -euo pipefail`, and `runtimeInputs` PATH wiring - `pkgs.slirp4netns` (v1.3.3): internet-only network tier sidecar — well-documented `--ready-fd`/`--exit-fd` sync primitives for bash coordination
- **`bubblewrap` (bwrap)**: Unprivileged user-namespace sandbox -- no setuid needed on NixOS, mature and stable API - `pkgs.passt` (pasta binary): alternative sidecar — Podman 5 default, NAT-free, cleaner DNS; consider as primary if bash integration proves clean
- **`lib.makeBinPath`**: Constructs the sandbox-internal PATH from explicit Nix store paths -- guarantees only declared tools are available - Bash-sourced `.sh` profile files OR flat JSON + jq: named profile config
- **`comma` + `nix-index-database`**: On-demand package installation inside sandbox -- use `comma-with-db` or bind-mount host's nix-index DB - `sha256sum` (coreutils, already present): instance directory hashing
**Runtime deps for sandbox PATH:** coreutils, git, curl, jq, ripgrep, fd, nix, comma, bash, nodejs
**Explicitly excluded from sandbox:** gnupg, openssh, age/agenix, tailscale (secret material / infrastructure access)
### Expected Features ### Expected Features
**Must have (table stakes):** **Must have (table stakes):**
- Filesystem isolation with default-deny (bwrap `--tmpfs /` base) - Host auth passthrough — rw mount of `~/.claude/.credentials.json` (rw required for OAuth token refresh)
- Environment allowlist via `--clearenv` + `--setenv` - Per-project instance isolation — `~/.claudebox/instances/<hash>/.claude/` with git worktree awareness
- Secret path hiding (~/.ssh, ~/.gnupg, ~/.aws, age keys -- simply never mounted) - Named profiles (`--profile foo` / `CLAUDEBOX_PROFILE=foo`) — env vars, mounts, packages, network tier
- Minimal PATH from Nix store paths only - Tiered network isolation: `none` (offline) and `inet` (internet, no LAN/Tailscale)
- Nix store read-only mount + daemon socket for tool provisioning
- Persistent config directory (~/.claudebox mapped to ~/.claude inside sandbox)
- Pre-launch env audit with `--yes`/`-y` skip flag
- Working /tmp, /dev, /proc
- Exit code passthrough and signal forwarding via `exec`
**Should have (differentiators for v1):** **Should have (differentiators):**
- Tool self-provisioning via comma (already planned, low complexity) - Profile `extends` / inheritance
- Injected system prompt (CLAUDE.md in ~/.claudebox telling Claude about sandbox capabilities) - Network tier and active profile shown in pre-launch env audit
- Dry-run mode (`--dry-run` prints bwrap command without executing) - Profile `--list` and `--show` commands
- Sandbox health check (`claudebox --check`) - Instance dir GC (`--gc`)
**Defer (v2+):** **Defer to v2.1+:**
- Env var leak detection (regex scanning for secret-like patterns) - Full `nix develop .#devShell` integration — profile `packages` field covers 80% case
- Project-local tool declarations (.claudebox.toml) - Domain-level network allowlists
- Git credential isolation (sandbox-specific .gitconfig)
- Multiple working directories (--mount-ro/--mount-rw flags)
- Configurable security profiles (one hardcoded posture is correct for v1)
**Anti-features (never build):** **Anti-features (explicitly avoid):**
- Network isolation (Claude Code handles domain allowlisting; bwrap netns is fragile) - Mounting `.credentials.json` read-only — breaks OAuth token refresh
- GUI/audio/DBus passthrough (CLI tool, no desktop integration) - Auto-detecting and injecting devShell on every launch — breaks "no surprises" principle
- Seccomp/capability dropping (threat model is data exfiltration, not privilege escalation) - Storing secret values in profile files — profiles reference env var names, not values
- Docker/OCI wrapping (Nix+bwrap is lighter and daemonless)
### Architecture Approach ### Architecture Approach
The architecture is a single shell script with five sequential stages, packaged as one Nix derivation. There are no services, no config files to parse (in v1), no persistent state beyond ~/.claudebox. The critical architectural insight is the two-PATH distinction: `runtimeInputs` sets the wrapper script's PATH (needs bwrap), while `lib.makeBinPath` constructs the sandbox-internal PATH (needs git, curl, etc.). Mount ordering is the primary complexity -- bwrap processes mounts sequentially, later mounts overlay earlier ones, so the order must be: tmpfs root, read-only system mounts, tmpfs home, specific bind-mounts into home, CWD bind-mount. claudebox.sh grows four new functions plus modifications to arg parse, env builder, mount builder, and the exec block. The exec block must branch on network tier: `full` and `none` use `exec bwrap`; `inet` uses `bwrap ... &` + sidecar + `wait`.
**Major components:** **Major components:**
1. **Nix derivation** (flake.nix) -- pins all deps, builds wrapper via `writeShellApplication`, interpolates sandbox PATH via `lib.makeBinPath` 1. **Arg parse** — adds `--profile NAME` and `--network full|inet|none` flags
2. **Argument parser** -- handles `--yes`, `--dry-run`, `--check`, collects passthrough args for claude 2. **Profile loader** — reads `~/.claudebox/profiles/<name>.json` via jq; yields network, packages, env, mounts, passthrough settings
3. **Env builder** -- reads host vars, filters through allowlist array, builds `--setenv` flag list 3. **Instance resolver** — resolves git worktree common dir, hashes canonical project root, creates instance dir
4. **Env auditor** -- displays filtered env on stderr, prompts for confirmation (skippable with `--yes`) 4. **Auth mount**`--bind "$HOME/.claude/.credentials.json"` (read-write, not read-only)
5. **bwrap invocation** -- assembles namespace flags, mount table, env flags, execs into `claude --dangerously-skip-permissions` 5. **Package injector**`nix build --no-link --print-out-paths nixpkgs#<pkg>` loop; prepends to SANDBOX_PATH
6. **Network setup**`--unshare-net` for none/inet; sidecar coordination for inet; temp resolv.conf for inet
7. **Exec block** — three-branch: full → `exec bwrap`; none → `exec bwrap --unshare-net`; inet → `bwrap --pidfile &` + sidecar + `wait`
8. **Pre-launch audit** — extended to show active profile, network tier, extra mounts
### Critical Pitfalls ### Critical Pitfalls
1. **Environment variable leaks** -- bwrap inherits parent env by default; `--clearenv` is mandatory from day one. Without it, `SSH_AUTH_SOCK`, `AWS_PROFILE`, `KUBECONFIG` all pass through and the sandbox is theater. Test with `env` inside sandbox. 1. **Auth mount must be read-write, not read-only** — Claude Code's OAuth flow writes refreshed tokens back to `.credentials.json`. A `--ro-bind` causes silent EACCES; users get locked out. *Phase 1.*
2. **Nix daemon socket missing** -- mounting `/nix/store` read-only but forgetting `/nix/var/nix/daemon-socket` kills all comma/nix-shell functionality. Must bind-mount the socket (not ro-bind, it's a Unix socket). 2. **Sidecar requires process coordination** — bwrap must be backgrounded to capture sandbox PID; `--ready-fd` awaited before proceeding; `--exit-fd` used to prevent process leaks on abnormal exit. *Phase 3.*
3. **DNS/SSL resolution failure** -- on NixOS, `/etc/resolv.conf` is often a symlink; must resolve with `readlink -f` before mounting. Must also mount `/etc/ssl`, `/etc/nsswitch.conf`, and pass `NIX_SSL_CERT_FILE`/`SSL_CERT_FILE` in the env allowlist. Without this, nothing network-dependent works. 3. **DNS breaks in isolated namespace** — Host `/etc/resolv.conf` points to `127.0.0.53` (loopback, unreachable in new namespace). Must generate temp resolv.conf with sidecar DNS gateway. *Phase 3.*
4. **Git broken inside sandbox** -- missing ~/.gitconfig (no user identity), potential safe.directory rejection from UID mismatch with `--unshare-user`, credential helpers referencing binaries not in sandbox. Mount gitconfig read-only or generate minimal one. 4. **Git worktree hash collision** — Hashing CWD gives different hashes for worktrees of same repo. Use `git rev-parse --git-common-dir` to normalize. *Phase 2.*
5. **Missing /dev nodes** -- `--dev /dev` provides basics but may lack `/dev/shm` (Node.js V8), `/dev/pts` (PTY allocation), `/dev/tty` (git prompts). Test with actual Claude Code session, not just `echo hello`. 5. **Concurrent sessions race on instance directory** — Two claudebox invocations in same project write to same files. Add flock lockfile. *Phase 2.*
6. **Profile sourcing requires permission validation** — Shell-sourcing without checking ownership/permissions is code injection. Validate file ownership. *Phase 4.*
## Implications for Roadmap ## Implications for Roadmap
Based on research, suggested phase structure: ### Phase 4: Auth Passthrough
Auth must come first — every downstream feature needs Claude to authenticate inside the sandbox.
- Mount `~/.claude/.credentials.json` read-write into instance dir
- Validate token refresh works (not just initial auth)
### Phase 1: Minimal Viable Sandbox ### Phase 5: Per-Project Instance Isolation
**Rationale:** All critical pitfalls (6 of 6) and all table stakes features converge here. Nothing else can be built or tested without a working sandbox. The architecture research provides an explicit 6-stage build order within this phase. Depends on Phase 4 (auth mount path must be stable).
**Delivers:** A working `claudebox` command that launches Claude Code in a bwrap sandbox with env isolation, filesystem isolation, and basic tool access. - `~/.claudebox/instances/<sha256(canonical_root)[0:16]>/` as `~/.claude`
**Addresses:** All table stakes features (filesystem isolation, env allowlist, secret hiding, minimal PATH, persistent config, /tmp, /dev, /proc, exit code passthrough, signal forwarding) - Git worktree-aware hashing via `git rev-parse --git-common-dir`
**Avoids:** Env leaks (#1), missing /dev (#2), daemon socket (#3), DNS (#4), /tmp (#5), git (#6), symlinks (#10), SSL (#11), locale (#12), home dir (#13), mount ordering (#14), hardcoded paths (#15) - flock-based concurrent session guard
**Build sub-order within phase:**
1. Bare bwrap invocation (get a shell, validate mounts)
2. Run Claude inside bwrap (add config mount, env setup, API key)
3. Add Nix daemon socket + comma support
4. Fix git (gitconfig mount, safe.directory)
5. Env audit + argument parsing (--yes flag)
6. Nix packaging (writeShellApplication, flake, lib.makeBinPath)
### Phase 2: System Prompt and UX Polish ### Phase 6: Tiered Network Isolation
**Rationale:** Once the sandbox works, Claude needs to know it's sandboxed and how to use comma. This is low-effort, high-impact. Highest complexity. Two sub-phases: `none` first (trivial), `inet` second (sidecar coordination).
**Delivers:** Default CLAUDE.md in ~/.claudebox with sandbox-aware instructions, `--dry-run` mode, `--check` health check, error messages for missing prerequisites. - `none`: add `--unshare-net`, keep `exec bwrap`
**Addresses:** Injected system prompt, dry-run mode, sandbox health check - `inet`: `bwrap &` + slirp4netns/pasta sidecar + `--ready-fd`/`--exit-fd` + temp resolv.conf
**Avoids:** TTY/PTY issues (#8), XDG/cache directory issues (#9) - `--network` flag and `CLAUDEBOX_NETWORK` env var
### Phase 3: Hardening and Testing ### Phase 7: Named Profiles
**Rationale:** After functionality is proven, lock down remaining attack surface and formalize the test suite. Ties together all prior subsystems.
**Delivers:** PID namespace isolation (`--unshare-pid`), formalized 7-point integration test script, documentation. - `--profile foo` / `CLAUDEBOX_PROFILE=foo` (flag wins)
**Addresses:** /proc info leak (#7), the meta-pitfall of happy-path-only testing - Profile schema: network, env, extra_env_passthrough, mounts, packages
**Avoids:** Regression on any earlier pitfall via automated tests - `~/.claudebox/profiles/<name>.json` parsed with jq
- Permission validation before loading
- Pre-launch audit extended with profile info
### Phase 8: Nix Package Injection
Last because it has startup latency risk and is independently testable.
- Profile `packages` field resolved via `nix build --no-link --print-out-paths`
- Store paths prepended to SANDBOX_PATH
- Result caching to avoid re-resolving
### Phase Ordering Rationale ### Phase Ordering Rationale
- Phase 1 must come first because every other phase depends on a working sandbox. The internal build order (shell first, then Claude, then Nix, then git, then UX, then packaging) follows the dependency chain identified in architecture research. - Auth before isolation: credential mount path must be established first
- Phase 2 is separated from Phase 1 because it adds no security value -- it's UX. But it dramatically improves the actual Claude experience and is low complexity. - Isolation before profiles: per-project history makes profile defaults meaningful
- Phase 3 is last because hardening and testing are polish on a working tool. PID namespace isolation is not blocking functionality. - Network `none` before `inet`: validates exec→wait refactor cheaply
- All three phases are small. This is a shell script, not a platform. Total implementation is likely under 200 lines of bash + 50 lines of Nix. - Network before profiles: profiles set the network tier; implementation must exist first
- Profiles before package injection: package injection consumes profile packages field
### Research Flags ### Research Flags
Phases likely needing deeper research during planning: - **Phase 6 (inet tier):** pasta/slirp4netns exact CLI flags need live verification
- **Phase 1 (Nix/comma sub-step):** Verify `comma-with-db` packaging in current `nix-community/nix-index-database` flake. Verify `--clearenv` availability in nixpkgs bwrap version. Test daemon socket bind-mount vs ro-bind behavior. - **Phases 4, 5, 7, 8:** Standard patterns, skip research-phase
Phases with standard patterns (skip research-phase):
- **Phase 1 (all other sub-steps):** bwrap flags, writeShellApplication, mount ordering -- all well-documented, stable APIs.
- **Phase 2:** Entirely standard (writing a markdown file, adding CLI flags to a bash script).
- **Phase 3:** Standard bwrap flag (`--unshare-pid`), standard shell test assertions.
## Confidence Assessment ## Confidence Assessment
| Area | Confidence | Notes | | Area | Confidence | Notes |
|------|------------|-------| |------|------------|-------|
| Stack | HIGH | writeShellApplication and bwrap are stable, well-documented Nix/Linux primitives | | Stack | HIGH | Existing stack unchanged; slirp4netns/passt verified in nixpkgs |
| Features | MEDIUM | Feature landscape derived from training data on firejail/nixpak/bubblejail; core features are certain, differentiator priority is judgment | | Features | HIGH | Auth file path confirmed from official Claude Code docs |
| Architecture | HIGH | Single shell script architecture is obvious for this scope; mount ordering and PATH construction are well-established patterns | | Architecture | MEDIUM-HIGH | Existing codebase read directly; sidecar integration flags are MEDIUM |
| Pitfalls | MEDIUM-HIGH | Pitfalls are real and well-known in the bwrap community, but some NixOS-specific behaviors (symlink resolution, daemon socket permissions) need live testing | | Pitfalls | HIGH | Sourced from official docs, upstream issue trackers |
**Overall confidence:** MEDIUM-HIGH **Overall confidence:** HIGH for Phases 4-5, 7-8. MEDIUM-HIGH for Phase 6 inet tier.
### Gaps to Address ### Gaps to Address
- **`--clearenv` version requirement:** Verify bwrap 0.8.0+ in current nixpkgs. If older, fall back to `env -i` prefix. - **pasta vs slirp4netns final decision:** Attempt pasta first in Phase 6; fall back to slirp4netns if integration proves difficult
- **comma-with-db packaging:** Verify current nix-index-database flake API. May need to bind-mount host DB instead. - **Profile format: JSON vs bash-sourced:** JSON is safer (no code injection); bash-sourced is simpler. Decide in Phase 7 planning.
- **Claude Code env var requirements:** Research identified the obvious vars (HOME, PATH, TERM, API key) but Claude Code may need additional vars not documented. Requires live testing. - **Auth mount rw semantics:** Must verify token refresh works after Phase 4, not just initial auth
- **`--unshare-user` and git safe.directory interaction:** Research flags potential UID mismatch. Needs empirical verification -- may need to skip `--unshare-user` or add git safe.directory config.
- **`/dev/shm` and `/dev/pts` availability:** `--dev /dev` may or may not provide these. Requires testing on NixOS with current bwrap version.
## Sources ## Sources
### Primary (HIGH confidence) ### Primary (HIGH confidence)
- bubblewrap documentation and manpage -- mount semantics, namespace flags, --clearenv - Claude Code official docs — `~/.claude/.credentials.json` path; credential precedence
- nixpkgs `writeShellApplication` API -- stable since 2022, standard Nix pattern - slirp4netns GitHub (v1.3.3) — `--ready-fd`, `--exit-fd`, `--configure`, `--disable-host-loopback`
- Linux bind mount semantics -- kernel behavior, well-established - bubblewrap manpage — `--unshare-net`, `--info-fd`, `--pidfile`
- Git safe.directory behavior -- Git 2.35.2+, well-documented - Existing codebase (claudebox.sh, flake.nix) — direct read
### Secondary (MEDIUM confidence) ### Secondary (MEDIUM confidence)
- firejail feature documentation -- used for feature landscape comparison - passt.top — pasta architecture, `--config-net`, LAN isolation
- nixpak/bubblejail GitHub repositories -- used for architectural pattern comparison - Claude Code GitHub issues #24317, #27933 (OAuth refresh), #34437 (worktrees)
- comma/nix-index-database mechanics -- community project, verify current API
- Node.js /dev requirements -- inferred from V8 runtime behavior
### Tertiary (LOW confidence)
- Specific bwrap version in current nixpkgs -- needs verification
- comma-with-db package availability -- needs verification against current flake
--- ---
*Research completed: 2026-04-09* *Research completed: 2026-04-10*
*Ready for roadmap: yes* *Ready for roadmap: yes*