diff --git a/.planning/research/ARCHITECTURE.md b/.planning/research/ARCHITECTURE.md new file mode 100644 index 0000000..046e29e --- /dev/null +++ b/.planning/research/ARCHITECTURE.md @@ -0,0 +1,384 @@ +# 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) diff --git a/.planning/research/FEATURES.md b/.planning/research/FEATURES.md new file mode 100644 index 0000000..25fd915 --- /dev/null +++ b/.planning/research/FEATURES.md @@ -0,0 +1,141 @@ +# Feature Landscape + +**Domain:** CLI sandbox wrapper (Nix/bubblewrap) for AI coding agents +**Researched:** 2026-04-09 +**Confidence:** MEDIUM (based on training data for firejail, bubblejail, nixpak, bwrap; web verification unavailable) + +## Reference Projects Surveyed + +| Project | Approach | Relevance to claudebox | +|---------|----------|----------------------| +| **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 + +Features users expect. Missing = the wrapper is broken or useless for its stated purpose (secrets isolation for AI agents). + +| 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 | +| **Environment allowlist** | Denylist misses unknown vars; allowlist is secure-by-default | Low | `--clearenv` + explicit `--setenv` per var. All sandbox tools do this | +| **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 | +| **Minimal PATH** | Prevent access to host tools that might leak info or have side effects | Low | Construct PATH from explicit Nix store paths only | +| **Nix store read-only mount** | Required for `nix shell` and comma to work inside sandbox | Low | `--ro-bind /nix/store /nix/store` | +| **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 + +Features that set claudebox apart from generic sandbox wrappers. Not expected in a basic bwrap wrapper, but valuable for the AI agent use case. + +| Feature | Value Proposition | Complexity | Notes | +|---------|-------------------|------------|-------| +| **Tool self-provisioning via comma** | Claude can `nix shell` or `, ` to get any dev tool on demand without pre-declaration | Low | Already planned. Unique to Nix-based sandboxes. No other sandbox tool has this | +| **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` | +| **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` | +| **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) | +| **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 | +| **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 + +Features to deliberately NOT build. Either out of scope, security risks, or wrong layer. + +| 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 | +| **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 | +| **Audio / PulseAudio / PipeWire** | No audio needed for a coding agent | Don't mount audio sockets | +| **DBus access** | No desktop integration needed. DBus is a common sandbox escape vector | Don't mount DBus sockets | +| **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 | +| **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 | +| **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 | +| **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 + +``` +Filesystem isolation ─┐ +Environment allowlist ─┤ +Secret path hiding ────┤ +Minimal PATH ──────────┼── Core sandbox (all required together) +Nix store mount ───────┤ +Working /tmp,/dev,/proc┘ + +Nix store mount ──────── Tool self-provisioning (comma) + └── Project-local tool declarations (future) + +Pre-launch env audit ─── Env var leak detection (enhancement of audit) + +Persistent config dir ── Injected system prompt (lives in config dir) + +Core sandbox ─────────── Dry-run mode (needs bwrap command assembled first) +Core sandbox ─────────── Mount audit log (needs mount list) +Core sandbox ─────────── Sandbox health check (validates core works) + +Multiple working dirs ── Independent (optional mount flag) +Git credential isolation ── Independent (optional .gitconfig mount) +``` + +## MVP Recommendation + +Prioritize for v1: + +1. **Core sandbox** (all table stakes) -- this is the entire point +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: +- **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 +- **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 +- **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 +- **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. +- **FHS compatibility layer** is available but not needed -- Claude Code runs from Nix store. + +### 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. + +## Sources + +- firejail documentation and features page (training data, HIGH confidence for feature list -- firejail is well-documented and stable) +- bubblejail GitHub repository (training data, MEDIUM confidence -- less popular project) +- nixpak GitHub repository and NixOS discourse discussions (training data, MEDIUM confidence) +- bubblewrap man page and documentation (training data, HIGH confidence -- stable API) +- flatpak documentation on sandboxing and portals (training data, HIGH confidence) + +**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. diff --git a/.planning/research/PITFALLS.md b/.planning/research/PITFALLS.md new file mode 100644 index 0000000..42cbff2 --- /dev/null +++ b/.planning/research/PITFALLS.md @@ -0,0 +1,401 @@ +# Domain Pitfalls + +**Domain:** Bubblewrap sandbox wrappers for CLI tools on NixOS +**Researched:** 2026-04-09 +**Confidence:** MEDIUM (training data only, no live verification available) + +## Critical Pitfalls + +Mistakes that cause sandbox escapes, broken tools, or full rewrites. + +### Pitfall 1: Environment Variable Leaks via Inherited Env + +**What goes wrong:** Using `--unshare-all` or selectively binding vars but forgetting that bwrap inherits the parent environment by default. Every env var the shell has leaks into the sandbox unless you explicitly clear it. Variables like `DBUS_SESSION_BUS_ADDRESS`, `SSH_AUTH_SOCK`, `XDG_RUNTIME_DIR`, `GNOME_KEYRING_CONTROL`, `GPG_AGENT_INFO`, `AWS_PROFILE`, `KUBECONFIG`, and `DOCKER_HOST` silently pass through. + +**Why it happens:** bwrap does NOT start with an empty environment. People assume `--unshare-*` flags affect env vars -- they do not. Namespace unsharing and environment are completely orthogonal. + +**Consequences:** The entire security model of claudebox collapses. Claude Code can read `SSH_AUTH_SOCK`, connect to the agent, and sign things. AWS/GCP tokens pass through. The sandbox is theater. + +**Prevention:** Use `env -i` before the bwrap call, then explicitly set only allowlisted variables: + +```bash +exec env -i \ + HOME="$HOME" \ + TERM="$TERM" \ + PATH="$SANDBOX_PATH" \ + LANG="$LANG" \ + bwrap [flags] ... +``` + +Alternatively, `--clearenv` is available in bwrap 0.8.0+. Verify the version in nixpkgs before relying on it. + +**Detection:** Run `env` inside the sandbox. If it shows more than your allowlist, you have a leak. Build this as an automated test. + +**Phase:** Must be correct from Phase 1. This is the core security invariant. + +--- + +### Pitfall 2: Missing /dev Nodes Causing Silent Failures + +**What goes wrong:** A minimal `--dev /dev` mount is used, but tools need specific device nodes. Claude Code (Node.js) needs `/dev/null`, `/dev/zero`, `/dev/urandom`, and crucially `/dev/fd`, `/dev/stdin`, `/dev/stdout`, `/dev/stderr`. Git needs `/dev/tty` for credential prompts. Node.js needs `/dev/shm` for V8's shared memory segments. Missing `/dev/ptmx` and `/dev/pts` means no PTY allocation, breaking interactive features. + +**Why it happens:** `--dev /dev` in bwrap creates a minimal devtmpfs, but the exact set of nodes depends on bwrap version. People test simple commands and miss that complex runtimes need more. + +**Consequences:** Node.js crashes or hangs. Git operations that prompt for input hang forever. `nix shell` commands fail because nix-daemon communication breaks. The sandbox "works" for trivial tests but fails under real use. + +**Prevention:** Use `--dev /dev` (not `--dev-bind /dev /dev` which exposes host devices) and then verify these exist inside: + +- `/dev/null`, `/dev/zero`, `/dev/full` +- `/dev/random`, `/dev/urandom` +- `/dev/fd` (symlink to `/proc/self/fd`) +- `/dev/stdin`, `/dev/stdout`, `/dev/stderr` +- `/dev/shm` (tmpfs mount) +- `/dev/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`. + +**Detection:** Run `ls -la /dev/` inside the sandbox. Run `node -e "process.stdout.write('test')"`. Run `git status` in a repo. If any hang or error, missing dev nodes. + +**Phase:** Phase 1. Without correct /dev, nothing works. + +--- + +### Pitfall 3: Nix Store and Nix Daemon Socket Access + +**What goes wrong:** Mounting `/nix/store` read-only is remembered, but the nix-daemon socket at `/nix/var/nix/daemon-socket/socket` is forgotten. Without it, `nix shell`, `nix build`, and comma (`,`) all fail because they need to talk to the daemon for store operations. Additionally, `/nix/var/nix/db` may be needed for local queries. + +**Why it happens:** People think of Nix store as just the read-only `/nix/store` path. But `nix shell nixpkgs#foo` needs the daemon to fetch and realize store paths. The daemon socket is in a completely different path. + +**Consequences:** The entire "Claude can self-install tools via comma" feature is dead. Claude is stuck with whatever is in the pre-declared PATH. This defeats a core design goal. + +**Prevention:** + +```bash +--ro-bind /nix/store /nix/store \ +--ro-bind /nix/var/nix/db /nix/var/nix/db \ +--ro-bind /nix/var/nix/daemon-socket /nix/var/nix/daemon-socket \ +``` + +The socket itself needs to be accessible (not just the directory). Ensure the user inside the sandbox is in the `nixbld` group or that the daemon socket permissions allow access (they usually do for all users on NixOS). + +Also mount `--ro-bind /etc/nix /etc/nix` for nix.conf (channels, substituters, trusted keys). + +**Detection:** Run `nix shell nixpkgs#hello -c hello` inside the sandbox. If it hangs or errors with "cannot connect to daemon", the socket is not mounted. + +**Phase:** Phase 1. This is a core requirement per PROJECT.md. + +--- + +### Pitfall 4: DNS Resolution Fails Inside Sandbox + +**What goes wrong:** DNS resolution breaks because `/etc/resolv.conf`, `/etc/nsswitch.conf`, and the NSS libraries are not available inside the sandbox. On NixOS specifically, `/etc/resolv.conf` is often a symlink into the Nix store or managed by systemd-resolved (pointing to a stub resolver at 127.0.0.53), and the NSS shared libraries live in the Nix store at paths determined at build time. + +**Why it happens:** People bind-mount `/etc/resolv.conf` but forget that glibc's NSS needs `nsswitch.conf` plus the actual `.so` files for `nss_dns`, `nss_files`, `nss_resolve`. On NixOS these are in the Nix store, not in `/lib`. Without them, `getaddrinfo()` returns `EAI_NONAME` even though resolv.conf is present. + +**Consequences:** `curl`, `git clone`, `nix shell` (which fetches from cache.nixos.org), and Claude Code's own API calls all fail. The sandbox is offline despite having network access. + +**Prevention:** + +```bash +--ro-bind /etc/resolv.conf /etc/resolv.conf \ +--ro-bind /etc/nsswitch.conf /etc/nsswitch.conf \ +--ro-bind /etc/ssl /etc/ssl \ +--ro-bind /etc/hosts /etc/hosts \ +``` + +Since `/nix/store` is already mounted and your tools come from there, their RUNPATH/LD_LIBRARY_PATH should resolve the NSS `.so` files from the store. If you use `--symlink` for `/etc/resolv.conf` because it is itself a symlink, make sure the symlink target is also mounted. + +On NixOS with systemd-resolved, the stub resolver at 127.0.0.53 works fine inside the sandbox since network namespaces are not used (per PROJECT.md, no network isolation). + +**Detection:** Run `curl -s https://cache.nixos.org` or `getent hosts github.com` inside the sandbox. If they fail, DNS is broken. + +**Phase:** Phase 1. Without DNS, nothing network-dependent works. + +--- + +### Pitfall 5: /tmp Handling Breaks Build Tools and Nix + +**What goes wrong:** Using `--tmpfs /tmp` (correct instinct -- don't share host /tmp) but then Nix builds fail because the nix-daemon expects to be able to create build directories in `/tmp` that are accessible to both the daemon and the client. Also, some tools use `/tmp` for Unix domain sockets (X11, Wayland, PulseAudio) whose paths are referenced by env vars that were cleared. + +**Why it happens:** `/tmp` is both a security-sensitive directory (shared temp files = symlink attacks, info leaks) and a critical coordination point for IPC. + +**Consequences:** `nix build` and `nix shell` fail with permission errors or "cannot create temp directory". Node.js `os.tmpdir()` works but may fill a small tmpfs. Build operations that need significant temp space fail silently. + +**Prevention:** Use `--tmpfs /tmp` but size it generously: + +```bash +--tmpfs /tmp # defaults to 50% of RAM, which is usually fine +``` + +For Nix specifically: the daemon runs outside the sandbox, so it uses its own `/tmp`. The client inside the sandbox only needs `/tmp` for its own temp files, not for build directories. This means `--tmpfs /tmp` works fine for `nix shell` -- the potential issue is only if you try to run `nix-build` in single-user mode inside the sandbox (don't). + +Set `TMPDIR=/tmp` explicitly in the env allowlist so tools don't inherit a host-specific `TMPDIR` pointing to a non-existent path. + +**Detection:** Run `mktemp` and `nix shell nixpkgs#hello -c hello` inside the sandbox. + +**Phase:** Phase 1. + +--- + +### Pitfall 6: Git Broken Inside Sandbox + +**What goes wrong:** Git fails in multiple ways inside a bwrap sandbox: + +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 + +### Pitfall 7: /proc Mount Leaks Host Information + +**What goes wrong:** Using `--proc /proc` (correct) but not realizing that `/proc` exposes the host's process list, network config, and system info unless PID namespace is unshared. `/proc/net/tcp` reveals all host connections. `/proc/*/environ` of other processes may leak secrets. + +**Why it happens:** `--proc /proc` mounts a procfs, but its contents depend on whether `--unshare-pid` is used. + +**Consequences:** Claude Code can read host process info. Not a direct secret leak, but violates the principle of minimal exposure. + +**Prevention:** Use `--unshare-pid --proc /proc`. This gives the sandbox its own PID namespace, so `/proc` only shows sandbox processes. Note: `--unshare-pid` requires a new mount namespace (`--unshare-all` or `--unshare-pid` standalone). Verify the bwrap invocation order. + +**Detection:** Run `ls /proc/` inside the sandbox. If you see hundreds of PIDs, PID namespace is not isolated. + +**Phase:** Phase 1, but not blocking. Enhancement after basic functionality works. + +--- + +### Pitfall 8: TTY/PTY Not Properly Forwarded + +**What goes wrong:** Claude Code is an interactive CLI tool that needs a proper terminal. Inside bwrap, if the controlling TTY is not forwarded, `isatty()` returns false, disabling color output, interactive prompts, and progress indicators. Worse, if Claude Code tries to allocate a PTY (for running subcommands), it fails without `/dev/pts`. + +**Why it happens:** bwrap by default inherits the parent's stdio file descriptors, so basic TTY works. But PTY allocation for subprocesses needs `/dev/pts` and `/dev/ptmx` properly mounted. + +**Consequences:** Claude Code "works" but looks broken -- no colors, no interactive prompts. Subprocess execution may hang. + +**Prevention:** Ensure these are available: + +```bash +--dev /dev \ # provides basic TTY +--dev-bind /dev/pts /dev/pts \ # PTY allocation +``` + +Verify `TERM` is in the env allowlist. Verify the stdin/stdout/stderr fds are inherited (they are by default in bwrap, no special flag needed). + +**Detection:** Run `tput colors` and `tty` inside the sandbox. Check that Claude Code shows colored output. + +**Phase:** Phase 1. Claude Code is a terminal app. + +--- + +### Pitfall 9: XDG and Cache Directories Missing + +**What goes wrong:** Tools inside the sandbox expect `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`, `XDG_DATA_HOME`, and `XDG_RUNTIME_DIR` to exist and be writable. Node.js writes to `~/.npm`, `~/.node_repl_history`. Nix wants `~/.cache/nix`. Without writable cache dirs, tools fail with `EACCES` or `ENOENT` or silently degrade. + +**Why it happens:** The allowlist env model correctly strips these vars, but the underlying directories also need to exist and be writable. `XDG_RUNTIME_DIR` is particularly tricky -- it is per-user, often `/run/user/1000`, and some tools refuse to work without it. + +**Consequences:** `nix shell` may fail to cache anything. Node.js writes warnings to stderr on every invocation. Tools that need a config dir crash on first run. + +**Prevention:** + +```bash +# Map a persistent cache dir +--bind "$HOME/.claudebox/cache" "$HOME/.cache" \ +--bind "$HOME/.claudebox/local" "$HOME/.local" \ +# Create a tmpfs for XDG_RUNTIME_DIR +--tmpfs /run/user/$(id -u) \ +# Set the env vars +XDG_CACHE_HOME="$HOME/.cache" +XDG_RUNTIME_DIR="/run/user/$(id -u)" +``` + +Create `$HOME/.claudebox/cache` and `$HOME/.claudebox/local` directories in the wrapper script before launching bwrap. + +**Detection:** Run `echo $XDG_RUNTIME_DIR` and `ls -la ~/.cache` inside the sandbox. + +**Phase:** Phase 1. Required for Nix and Node.js operation. + +--- + +### Pitfall 10: Symlink Resolution Across Mount Boundaries + +**What goes wrong:** On NixOS, many paths under `/etc` and `/usr` are symlinks into the Nix store. Bind-mounting the symlink does not follow it -- you get the symlink, but the target is only accessible if `/nix/store` is also mounted. People bind-mount `/etc/resolv.conf` not realizing it is a symlink to `/run/systemd/resolve/stub-resolv.conf` or a Nix store path. + +**Why it happens:** NixOS is symlink-heavy by design. The entire `/etc` is largely managed through symlinks. Bwrap `--ro-bind` mounts the file/symlink literally, so the target must be reachable. + +**Consequences:** Bind mounts silently succeed but the file is empty or inaccessible. DNS breaks, SSL certs are missing, etc. + +**Prevention:** In the wrapper script, resolve symlinks before mounting: + +```bash +resolve_path() { + readlink -f "$1" +} +--ro-bind "$(resolve_path /etc/resolv.conf)" /etc/resolv.conf \ +``` + +Or mount entire directory trees that are known to be symlink farms (`/etc/ssl`, `/etc/static`). + +Since `/nix/store` is already mounted read-only, most NixOS symlinks will resolve correctly. The issue is paths like `/run/systemd/resolve/` which are outside the store. + +**Detection:** Run `cat /etc/resolv.conf` and `ls -la /etc/ssl/certs/ca-certificates.crt` inside the sandbox. + +**Phase:** Phase 1. NixOS-specific but critical. + +--- + +### Pitfall 11: SSL/TLS Certificate Chain Missing + +**What goes wrong:** HTTPS requests fail with certificate validation errors because the CA certificate bundle is not mounted. On NixOS, the cert bundle is at a Nix store path symlinked from `/etc/ssl/certs/ca-certificates.crt` and/or `/etc/pki/tls/certs/ca-bundle.crt`. Some tools also check `SSL_CERT_FILE` and `NIX_SSL_CERT_FILE` environment variables. + +**Why it happens:** The env allowlist strips `SSL_CERT_FILE` and `NIX_SSL_CERT_FILE`. The filesystem mounts don't include `/etc/ssl`. Both must be present for HTTPS to work. + +**Consequences:** `curl https://...` fails. `nix shell` cannot download from cache.nixos.org. Claude Code cannot reach the Anthropic API. Game over. + +**Prevention:** + +```bash +--ro-bind /etc/ssl /etc/ssl \ +--ro-bind /etc/pki /etc/pki \ # if it exists +``` + +Add `NIX_SSL_CERT_FILE` and `SSL_CERT_FILE` to the env allowlist, pointing to the cert bundle path. On NixOS: + +```bash +NIX_SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt +SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt +``` + +**Detection:** Run `curl -v https://cache.nixos.org` inside the sandbox. Check for certificate errors. + +**Phase:** Phase 1. Without this, nothing network-related works. + +--- + +## Minor Pitfalls + +### Pitfall 12: Locale Data Missing + +**What goes wrong:** Passing `LANG=en_US.UTF-8` in the allowlist but not mounting the locale data. On NixOS, locale data lives in the Nix store (referenced by `LOCALE_ARCHIVE`). + +**Prevention:** Add `LOCALE_ARCHIVE` to the env allowlist, keeping its original value (which points into `/nix/store`, already mounted). + +**Phase:** Phase 1. Easy to include. + +--- + +### Pitfall 13: Home Directory Confusion + +**What goes wrong:** `HOME` inside the sandbox points to the real home directory path, but most of it is not mounted. Tools try to read `~/.bashrc`, `~/.profile`, `~/.config/*` and fail. Worse, if `HOME` is bind-mounted entirely, all secrets are exposed. + +**Prevention:** Mount only the specific home subdirectories needed: + +```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. + +**Phase:** Phase 1. + +--- + +### Pitfall 14: bwrap Argument Order Matters + +**What goes wrong:** bwrap processes mount arguments in order. A later `--tmpfs /foo` overwrites an earlier `--bind /bar /foo`. People declare mounts in logical groups but don't realize that order determines the final mount table. A `--tmpfs $HOME` after `--bind ... $HOME/.claude` wipes the bind mount. + +**Prevention:** Order mounts from general to specific. tmpfs base directories first, then bind mounts on top: + +```bash +--tmpfs "$HOME" \ # 1. empty home base +--bind ... "$HOME/.claude" \ # 2. specific dirs on top +``` + +**Detection:** `mount` or `cat /proc/mounts` inside the sandbox to verify the mount table. + +**Phase:** Phase 1. + +--- + +### Pitfall 15: Hardcoded Paths in Nix-Built Binaries + +**What goes wrong:** Nix-built binaries have hardcoded RUNPATH and interpreter paths pointing into `/nix/store`. This is fine as long as `/nix/store` is mounted. But if you try to use non-Nix binaries or if you accidentally mount a partial `/nix/store`, binaries fail with "no such file or directory" (the dynamic linker is missing). + +**Prevention:** Always mount ALL of `/nix/store` read-only, not just specific paths: + +```bash +--ro-bind /nix/store /nix/store # the whole thing, not individual derivations +``` + +**Phase:** Phase 1. + +--- + +## Phase-Specific Warnings + +| Phase Topic | Likely Pitfall | Mitigation | +|-------------|---------------|------------| +| Basic bwrap invocation | Env leak (#1), /dev (#2), DNS (#4), SSL (#11) | Test with `env`, `curl https://`, `node -e` inside sandbox | +| Nix/comma integration | Daemon socket (#3), /tmp (#5), symlinks (#10) | Test `nix shell nixpkgs#hello -c hello` and `, cowsay` | +| Git operations | Git config/ownership (#6) | Test `git log`, `git diff`, `git commit` in a real repo | +| Interactive use | TTY (#8), XDG dirs (#9) | Test full Claude Code session, check colors and prompts | +| Pre-launch env audit | Env allowlist completeness (#1) | Print env before and after, diff against allowlist | +| Hardening | /proc isolation (#7) | Add `--unshare-pid` after basic functionality works | + +## The Meta-Pitfall: Testing Only the Happy Path + +The single biggest risk with sandbox wrappers is declaring success after `echo hello` works inside bwrap. Every pitfall above manifests only under real workloads. The test plan must include: + +1. `env` -- verify env is clean +2. `curl https://api.anthropic.com` -- verify DNS + SSL +3. `nix shell nixpkgs#hello -c hello` -- verify nix-daemon +4. `git log && git diff` -- verify git works +5. `node -e "console.log('test')"` -- verify Node.js runtime +6. Actually run `claude --dangerously-skip-permissions` and have a conversation +7. Have Claude run a build command, install a tool with comma, edit a file + +If all seven pass, the sandbox is solid. + +## Sources + +- bubblewrap documentation and man page (training data, MEDIUM confidence) +- NixOS filesystem layout knowledge (training data, HIGH confidence -- well-established, unlikely to have changed) +- Git safe.directory behavior from Git 2.35.2+ (training data, HIGH confidence) +- Node.js /dev requirements (training data, MEDIUM confidence) +- General Linux namespace/mount/sandbox knowledge (training data, HIGH confidence) + +Note: Web search and fetch tools were unavailable during this research. All findings are from training data. The bwrap-specific mount behaviors and NixOS symlink patterns are well-established and unlikely to have changed, but the `--clearenv` flag availability should be verified against the current nixpkgs bwrap version. diff --git a/.planning/research/STACK.md b/.planning/research/STACK.md new file mode 100644 index 0000000..e77a5c6 --- /dev/null +++ b/.planning/research/STACK.md @@ -0,0 +1,335 @@ +# Technology Stack + +**Project:** claudebox +**Researched:** 2026-04-09 +**Note:** Research conducted from training data only (web/shell tools unavailable). Versions should be verified against current nixpkgs before implementation. + +## Recommended Stack + +### Core: Nix Derivation via `writeShellApplication` + +| Technology | Version | Purpose | Why | Confidence | +|------------|---------|---------|-----|------------| +| `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) + +| Package | Purpose | Why This One | Confidence | +|---------|---------|--------------|------------| +| `bubblewrap` | Sandbox | The whole point | HIGH | +| `coreutils` | Basic shell utils | `env`, `cat`, `echo`, `mkdir`, etc. | HIGH | +| `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# -c ` via `, ` 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) + +| Package | Why Excluded | +|---------|-------------| +| `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 + +```nix +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. + +### Why NOT These Alternatives + +| Alternative | Why Not | +|-------------|---------| +| `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 +bwrap \ + --unshare-user # New user namespace (maps to root inside) + --unshare-pid # New PID namespace (can't see host PIDs) + --unshare-ipc # New IPC namespace + --unshare-uts # New UTS namespace (hostname isolation) + # NOTE: --unshare-net is OUT OF SCOPE per project constraints + # NOTE: --unshare-cgroup usually unnecessary for this use case +``` + +**Confidence:** HIGH -- these are standard bwrap namespace flags. + +### Filesystem Mounts + +```bash +bwrap \ + # 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). + +### Mount Ordering Matters + +bwrap processes mounts in order. Later mounts can overlay earlier ones. The correct order is: + +1. `--tmpfs /` (empty root) +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. + +### Environment Handling + +```bash +bwrap \ + --clearenv # Start with empty environment + --setenv HOME "$HOME" # Explicit home + --setenv PATH "$SANDBOX_PATH" # Controlled PATH + --setenv TERM "$TERM" # Terminal type + --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 + +### Process Execution + +```bash +bwrap \ + --die-with-parent # Kill sandbox if parent dies + --new-session # New session (prevents tty hijacking) + -- \ + claude --dangerously-skip-permissions "$@" +``` + +**Confidence:** HIGH + +## Comma and nix-index + +### 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# -c [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 +SANDBOX_PATH="" +for pkg in ${coreutils} ${git} ${curl} ${jq} ${ripgrep} ${fd} ${nix} ${comma} ${bash} ${nodejs}; do + SANDBOX_PATH="$SANDBOX_PATH:$pkg/bin" +done +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: + +```nix +let + sandboxPath = lib.makeBinPath [ + pkgs.coreutils pkgs.git pkgs.curl pkgs.jq + pkgs.ripgrep pkgs.fd pkgs.nix pkgs.comma + pkgs.bash pkgs.nodejs + ]; +in +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. + +**Confidence:** HIGH + +## Nix Store Access Inside Sandbox + +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 +--ro-bind /nix/store /nix/store +--ro-bind /nix/var/nix/db /nix/var/nix/db +--ro-bind /nix/var/nix/daemon-socket /nix/var/nix/daemon-socket +``` + +Wait -- the daemon socket needs to be bind-mounted (not ro-bind) because it's a Unix socket: + +```bash +--bind /nix/var/nix/daemon-socket /nix/var/nix/daemon-socket +``` + +**Confidence:** MEDIUM -- socket bind-mount behavior should be tested. The daemon socket may need `--bind` not `--ro-bind`. + +## Testing Strategy + +Since this is a shell script wrapped in Nix: + +1. **Build test:** `nix build` succeeds (shellcheck passes) +2. **Smoke test:** `claudebox` launches, `env` inside shows only allowlisted vars +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. + +**Confidence:** HIGH + +## Sources + +- Training data knowledge of nixpkgs `writeShellApplication` (stable API since 2022) +- Training data knowledge of bubblewrap (stable API, project is mature) +- Training data knowledge of comma/nix-index-database (nix-community project) +- **All versions should be verified against current nixpkgs before implementation** + +## Verification Checklist (for implementation phase) + +- [ ] Confirm `bubblewrap` version in current nixpkgs channel +- [ ] 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` diff --git a/.planning/research/SUMMARY.md b/.planning/research/SUMMARY.md new file mode 100644 index 0000000..6a08535 --- /dev/null +++ b/.planning/research/SUMMARY.md @@ -0,0 +1,172 @@ +# Project Research Summary + +**Project:** claudebox +**Domain:** Nix bubblewrap sandbox wrapper for AI coding agents +**Researched:** 2026-04-09 +**Confidence:** MEDIUM-HIGH + +## 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). + +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 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. + +## Key Findings + +### 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. + +**Core technologies:** +- **`writeShellApplication`**: Nix function to produce the wrapper -- provides shellcheck at build time, `set -euo pipefail`, and `runtimeInputs` PATH wiring +- **`bubblewrap` (bwrap)**: Unprivileged user-namespace sandbox -- no setuid needed on NixOS, mature and stable API +- **`lib.makeBinPath`**: Constructs the sandbox-internal PATH from explicit Nix store paths -- guarantees only declared tools are available +- **`comma` + `nix-index-database`**: On-demand package installation inside sandbox -- use `comma-with-db` or bind-mount host's nix-index DB + +**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 + +**Must have (table stakes):** +- Filesystem isolation with default-deny (bwrap `--tmpfs /` base) +- Environment allowlist via `--clearenv` + `--setenv` +- Secret path hiding (~/.ssh, ~/.gnupg, ~/.aws, age keys -- simply never mounted) +- Minimal PATH from Nix store paths only +- 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):** +- Tool self-provisioning via comma (already planned, low complexity) +- Injected system prompt (CLAUDE.md in ~/.claudebox telling Claude about sandbox capabilities) +- Dry-run mode (`--dry-run` prints bwrap command without executing) +- Sandbox health check (`claudebox --check`) + +**Defer (v2+):** +- Env var leak detection (regex scanning for secret-like patterns) +- Project-local tool declarations (.claudebox.toml) +- 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):** +- Network isolation (Claude Code handles domain allowlisting; bwrap netns is fragile) +- GUI/audio/DBus passthrough (CLI tool, no desktop integration) +- Seccomp/capability dropping (threat model is data exfiltration, not privilege escalation) +- Docker/OCI wrapping (Nix+bwrap is lighter and daemonless) + +### 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. + +**Major components:** +1. **Nix derivation** (flake.nix) -- pins all deps, builds wrapper via `writeShellApplication`, interpolates sandbox PATH via `lib.makeBinPath` +2. **Argument parser** -- handles `--yes`, `--dry-run`, `--check`, collects passthrough args for claude +3. **Env builder** -- reads host vars, filters through allowlist array, builds `--setenv` flag list +4. **Env auditor** -- displays filtered env on stderr, prompts for confirmation (skippable with `--yes`) +5. **bwrap invocation** -- assembles namespace flags, mount table, env flags, execs into `claude --dangerously-skip-permissions` + +### 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. + +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). + +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. + +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. + +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`. + +## Implications for Roadmap + +Based on research, suggested phase structure: + +### Phase 1: Minimal Viable Sandbox +**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. +**Delivers:** A working `claudebox` command that launches Claude Code in a bwrap sandbox with env isolation, filesystem isolation, and basic tool access. +**Addresses:** All table stakes features (filesystem isolation, env allowlist, secret hiding, minimal PATH, persistent config, /tmp, /dev, /proc, exit code passthrough, signal forwarding) +**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) +**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 +**Rationale:** Once the sandbox works, Claude needs to know it's sandboxed and how to use comma. This is low-effort, high-impact. +**Delivers:** Default CLAUDE.md in ~/.claudebox with sandbox-aware instructions, `--dry-run` mode, `--check` health check, error messages for missing prerequisites. +**Addresses:** Injected system prompt, dry-run mode, sandbox health check +**Avoids:** TTY/PTY issues (#8), XDG/cache directory issues (#9) + +### Phase 3: Hardening and Testing +**Rationale:** After functionality is proven, lock down remaining attack surface and formalize the test suite. +**Delivers:** PID namespace isolation (`--unshare-pid`), formalized 7-point integration test script, documentation. +**Addresses:** /proc info leak (#7), the meta-pitfall of happy-path-only testing +**Avoids:** Regression on any earlier pitfall via automated tests + +### 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. +- 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. +- Phase 3 is last because hardening and testing are polish on a working tool. PID namespace isolation is not blocking functionality. +- 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. + +### Research Flags + +Phases likely needing deeper research during planning: +- **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 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 + +| Area | Confidence | Notes | +|------|------------|-------| +| Stack | HIGH | writeShellApplication and bwrap are stable, well-documented Nix/Linux primitives | +| Features | MEDIUM | Feature landscape derived from training data on firejail/nixpak/bubblejail; core features are certain, differentiator priority is judgment | +| Architecture | HIGH | Single shell script architecture is obvious for this scope; mount ordering and PATH construction are well-established patterns | +| 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 | + +**Overall confidence:** MEDIUM-HIGH + +### Gaps to Address + +- **`--clearenv` version requirement:** Verify bwrap 0.8.0+ in current nixpkgs. If older, fall back to `env -i` prefix. +- **comma-with-db packaging:** Verify current nix-index-database flake API. May need to bind-mount host DB instead. +- **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. +- **`--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 + +### Primary (HIGH confidence) +- bubblewrap documentation and manpage -- mount semantics, namespace flags, --clearenv +- nixpkgs `writeShellApplication` API -- stable since 2022, standard Nix pattern +- Linux bind mount semantics -- kernel behavior, well-established +- Git safe.directory behavior -- Git 2.35.2+, well-documented + +### Secondary (MEDIUM confidence) +- firejail feature documentation -- used for feature landscape comparison +- nixpak/bubblejail GitHub repositories -- used for architectural pattern comparison +- 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* +*Ready for roadmap: yes*