- Add CREDS_FILE/CREDS_MOUNT detection after mkdir ~/.claudebox - Conditional --bind in exec bwrap via BWRAP_ARGS array - Mirror conditional bind in --dry-run display block - Read-write mount (not ro-bind) for OAuth token refresh - Silent skip when credentials file absent (no error/warning) - Refactor exec bwrap to BWRAP_ARGS array for conditional mount support
384 lines
15 KiB
Markdown
384 lines
15 KiB
Markdown
# Architecture Patterns
|
|
|
|
**Domain:** Nix bubblewrap sandbox wrapper
|
|
**Researched:** 2026-04-09
|
|
|
|
## Recommended Architecture
|
|
|
|
claudebox is a single Nix derivation producing a shell script. The script has five logical stages that execute sequentially before `exec`ing into the sandboxed Claude process.
|
|
|
|
```
|
|
claudebox (entry)
|
|
|
|
|
v
|
|
[1. Argument Parsing] --yes/-y flag, passthrough args for claude
|
|
|
|
|
v
|
|
[2. Environment Build] Start empty, allowlist safe vars from host
|
|
|
|
|
v
|
|
[3. Env Audit Display] Show what's entering the sandbox, prompt user
|
|
|
|
|
v
|
|
[4. bwrap Invocation] Namespace + mount table + env + exec chain
|
|
| |
|
|
| +-- Mount table (ro: /nix/store, /etc/resolv.conf, ...)
|
|
| +-- Mount table (rw: CWD, ~/.claudebox -> ~/.claude)
|
|
| +-- Mount table (tmpfs: /tmp, /home)
|
|
| +-- Namespace config (unshare user, pid, ipc)
|
|
| +-- Env vars (--clearenv + explicit --setenv per var)
|
|
|
|
|
v
|
|
[5. exec claude] --dangerously-skip-permissions + user args
|
|
```
|
|
|
|
### Component Boundaries
|
|
|
|
| Component | Responsibility | Notes |
|
|
|-----------|---------------|-------|
|
|
| **Nix derivation** (`default.nix` / `flake.nix`) | Pins all runtime deps, builds wrapper via `writeShellApplication` | Closure includes coreutils, git, curl, jq, rg, fd, nix, comma, claude-code |
|
|
| **Argument parser** | Handles `--yes`/`-y`, collects passthrough args | Simple `case`/`shift` loop, no getopt needed |
|
|
| **Env builder** | Constructs the `--setenv` flag list from allowlist | Reads host vars, filters through allowlist, builds array |
|
|
| **Env auditor** | Displays env to user, prompts for confirmation | Skipped with `--yes`; uses stderr for display |
|
|
| **Mount table** | Defines all filesystem bindings for bwrap | Static mounts + dynamic CWD mount |
|
|
| **bwrap exec** | Assembles and execs the bwrap command | Final `exec bwrap ... -- claude ...` |
|
|
|
|
### The bwrap Invocation Structure
|
|
|
|
bubblewrap flags are order-sensitive for mounts (later mounts overlay earlier ones) but not for namespace flags. The canonical structure:
|
|
|
|
```bash
|
|
exec bwrap \
|
|
# --- Namespace isolation ---
|
|
--unshare-user \
|
|
--unshare-pid \
|
|
--unshare-ipc \
|
|
--unshare-cgroup \
|
|
--die-with-parent \
|
|
\
|
|
# --- Environment (start clean) ---
|
|
--clearenv \
|
|
--setenv HOME "$sandbox_home" \
|
|
--setenv PATH "$sandbox_path" \
|
|
--setenv TERM "$TERM" \
|
|
# ... more --setenv flags from allowlist ...
|
|
\
|
|
# --- Base filesystem (read-only) ---
|
|
--ro-bind /nix/store /nix/store \
|
|
--ro-bind /etc/resolv.conf /etc/resolv.conf \
|
|
--ro-bind /etc/ssl /etc/ssl \
|
|
--ro-bind /etc/nix /etc/nix \
|
|
--ro-bind /etc/passwd /etc/passwd \
|
|
--ro-bind /etc/group /etc/group \
|
|
--ro-bind /etc/nsswitch.conf /etc/nsswitch.conf \
|
|
\
|
|
# --- Nix daemon socket (required for nix commands) ---
|
|
--bind /nix/var/nix/daemon-socket /nix/var/nix/daemon-socket \
|
|
--ro-bind /nix/var/nix/db /nix/var/nix/db \
|
|
--ro-bind /nix/var/nix/profiles /nix/var/nix/profiles \
|
|
\
|
|
# --- tmpfs layers ---
|
|
--tmpfs /tmp \
|
|
--tmpfs /run \
|
|
\
|
|
# --- Proc/dev (needed for process management) ---
|
|
--proc /proc \
|
|
--dev /dev \
|
|
\
|
|
# --- User home (isolated) ---
|
|
--tmpfs "$sandbox_home" \
|
|
\
|
|
# --- Persistent Claude config ---
|
|
--bind "$HOME/.claudebox" "$sandbox_home/.claude" \
|
|
\
|
|
# --- Working directory (read-write) ---
|
|
--bind "$(pwd)" "$(pwd)" \
|
|
--chdir "$(pwd)" \
|
|
\
|
|
# --- XDG cache for nix/comma ---
|
|
--bind "$HOME/.claudebox/cache" "$sandbox_home/.cache" \
|
|
\
|
|
-- \
|
|
claude --dangerously-skip-permissions "$@"
|
|
```
|
|
|
|
**Flag ordering rationale:**
|
|
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
|
|
|
|
The Nix store is the critical piece. Here is how each layer works:
|
|
|
|
**Read-only store access (`--ro-bind /nix/store /nix/store`):**
|
|
- All store paths (the closure of the wrapper script) are immediately available
|
|
- Programs in PATH resolve because PATH points to `/nix/store/...-coreutils/bin` etc.
|
|
- This is a bind mount, not a copy -- zero overhead
|
|
|
|
**Nix daemon socket (`--bind /nix/var/nix/daemon-socket`):**
|
|
- `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`):**
|
|
- 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
|
|
allowed_vars=(
|
|
HOME PATH TERM EDITOR VISUAL
|
|
LANG LC_ALL LC_CTYPE
|
|
COLORTERM FORCE_COLOR
|
|
NO_COLOR
|
|
XDG_RUNTIME_DIR
|
|
CLAUDE_CODE_API_KEY
|
|
ANTHROPIC_API_KEY
|
|
)
|
|
|
|
env_args=()
|
|
for var in "${allowed_vars[@]}"; do
|
|
if [[ -n "${!var:-}" ]]; then
|
|
env_args+=(--setenv "$var" "${!var}")
|
|
fi
|
|
done
|
|
```
|
|
|
|
HOME and PATH get overridden with sandbox-specific values after this loop.
|
|
|
|
### Pattern 4: Pre-launch Audit on stderr
|
|
|
|
**What:** Print the env vars that will enter the sandbox, prompt on stderr
|
|
|
|
```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
|
|
```
|
|
|
|
## Anti-Patterns to Avoid
|
|
|
|
### Anti-Pattern 1: Using --dev-bind Instead of --ro-bind for /nix/store
|
|
**What:** Mounting /nix/store read-write inside the sandbox
|
|
**Why bad:** The sandbox process could write to the store, bypassing the Nix daemon. No security benefit and potential store corruption.
|
|
**Instead:** `--ro-bind /nix/store /nix/store` -- the daemon handles writes from outside.
|
|
|
|
### Anti-Pattern 2: Env Denylist
|
|
**What:** Starting with the full host env and removing known-bad vars
|
|
**Why bad:** New secrets (e.g., `VAULT_TOKEN`, `OPENAI_API_KEY`) leak automatically. You must know every possible secret name.
|
|
**Instead:** `--clearenv` + explicit `--setenv` for each allowed var.
|
|
|
|
### Anti-Pattern 3: Bind-Mounting All of /home
|
|
**What:** `--bind /home /home` for convenience
|
|
**Why bad:** Exposes `~/.ssh`, `~/.gnupg`, `~/.aws`, `~/.config/gcloud`, age keys, everything
|
|
**Instead:** `--tmpfs $HOME` then selectively bind specific directories.
|
|
|
|
### Anti-Pattern 4: Forgetting --die-with-parent
|
|
**What:** Omitting `--die-with-parent` from bwrap flags
|
|
**Why bad:** If the wrapper script is killed, the sandbox process becomes orphaned and keeps running
|
|
**Instead:** Always include `--die-with-parent`.
|
|
|
|
### Anti-Pattern 5: Bind-Mounting /nix/store But Not the Daemon Socket
|
|
**What:** Read-only store mount without daemon access
|
|
**Why bad:** `nix shell`, `nix build`, and comma all fail because they cannot talk to the daemon. Tools are frozen to what's in PATH.
|
|
**Instead:** Also bind the daemon socket and /nix/var/nix/db.
|
|
|
|
## Component Build Order
|
|
|
|
Build and test each component incrementally:
|
|
|
|
### Stage 1: Minimal bwrap exec (get a shell)
|
|
- Hardcode everything, no env audit, no argument parsing
|
|
- Goal: `bwrap --ro-bind /nix/store /nix/store --bind $(pwd) $(pwd) ... -- /bin/sh`
|
|
- Validates: mount table works, namespace config doesn't crash
|
|
- Test: Can you run `ls` inside the sandbox? Can you see `/nix/store`?
|
|
|
|
### Stage 2: Run Claude inside bwrap
|
|
- 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
|
|
- Add daemon socket mount
|
|
- Add nix db/profiles mounts
|
|
- Add nix-index database mount for comma
|
|
- Test: Can Claude run `, python3` and get a working Python?
|
|
|
|
### Stage 4: Env audit + argument parsing
|
|
- 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
|
|
- `writeShellApplication` wrapper
|
|
- Construct sandbox PATH via `lib.makeBinPath`
|
|
- Wire into flake
|
|
- Test: `nix run .#claudebox` works end-to-end
|
|
|
|
### Stage 6: Polish
|
|
- Default CLAUDE.md with sandbox instructions
|
|
- Error messages for missing `~/.claudebox`
|
|
- XDG_RUNTIME_DIR handling
|
|
|
|
## Scalability Considerations
|
|
|
|
Not applicable -- this is a single-user local tool. The architecture is a shell script wrapping a single process.
|
|
|
|
## Key Technical Notes
|
|
|
|
### /nix/store Bind Mount Reflects Live Changes
|
|
When bwrap does `--ro-bind /nix/store /nix/store`, it creates a bind mount. Bind mounts in Linux reflect the live state of the source. So when the Nix daemon (running outside) adds new paths to `/nix/store`, they immediately appear inside the sandbox through the existing mount. This is why `nix shell` works: the daemon builds, writes the result to `/nix/store`, and the sandbox sees it instantly.
|
|
|
|
### --unshare-net Is Intentionally Omitted
|
|
The project explicitly keeps network access (Claude needs API access, git needs remotes, curl needs endpoints). Network isolation is out of scope per PROJECT.md -- Claude Code's own proxy handles domain allowlisting.
|
|
|
|
### User Namespace Requirement
|
|
`--unshare-user` requires user namespaces to be enabled in the kernel (`sysctl kernel.unprivileged_userns_clone=1`). NixOS has this enabled by default. Without user namespaces, bwrap needs setuid -- but on NixOS this is handled by the `bubblewrap` package and `security.allowUserNamespaces` (defaults to true).
|
|
|
|
### XDG_RUNTIME_DIR
|
|
Some tools (including potentially Claude Code) expect `XDG_RUNTIME_DIR` to exist. Options:
|
|
- `--tmpfs /run/user/$(id -u)` and `--setenv XDG_RUNTIME_DIR /run/user/$(id -u)`
|
|
- Or simply don't pass it and let tools fall back to `/tmp`
|
|
|
|
Recommend the tmpfs approach for maximum compatibility.
|
|
|
|
## Sources
|
|
|
|
- bubblewrap documentation and manpage (training data, HIGH confidence -- bwrap is stable and rarely changes API)
|
|
- Nix daemon architecture (training data, HIGH confidence -- fundamental Nix design)
|
|
- nixpkgs `writeShellApplication` patterns (training data, HIGH confidence)
|
|
- Linux bind mount semantics (training data, HIGH confidence -- kernel behavior)
|
|
- comma/nix-index mechanics (training data, MEDIUM confidence -- verify comma's current invocation style)
|