# Technology Stack **Project:** claudebox v2.0 — Network Isolation & Profiles **Researched:** 2026-04-10 **Confidence:** HIGH (network isolation), HIGH (auth passthrough), MEDIUM (profile config format), MEDIUM (devshell injection) **Scope:** NEW additions only. Existing validated stack (writeShellApplication, bubblewrap, comma-with-db, coreutils/git/curl/jq/ripgrep/fd/nix/nodejs) is carried forward unchanged. --- ## New Runtime Dependencies ### Add to `runtimeInputs` / `runtimeDeps` | Package | Nixpkgs Name | Version | Purpose | Why | Confidence | |---------|-------------|---------|---------|-----|------------| | `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 | | `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 | **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. **No new Nix flake inputs needed** — `pkgs.slirp4netns` is in nixpkgs unstable. --- ## Network Isolation: Tiered Architecture ### Three Tiers | 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). | ### Internet Tier: The Shell Pattern 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: ```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 \ --unshare-net \ --info-fd 4 \ # bwrap writes JSON with child PID to this FD ... \ -- "$CLAUDE_BIN" ... & BWRAP_PID=$! # 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" ``` **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. **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 bwrap --unshare-net ... -- "$CLAUDE_BIN" ... ``` No slirp4netns. bwrap creates an isolated network namespace with only loopback (127.0.0.1) configured. `exec bwrap` still works here. ### Full Tier: No Change Current behavior. No `--unshare-net`. `exec bwrap` still works. ### Network Tier Implication: `exec` vs `wait` 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: ```bash if [[ "$NETWORK_TIER" == "internet" ]]; then # background + slirp4netns + wait pattern else exec bwrap ... -- "$SANDBOX_CMD" fi ``` --- ## Auth Passthrough: Host ~/.claude Files ### 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 # Mount instance dir as ~/.claude (for history isolation) --bind "$INSTANCE_DIR" "$HOME/.claude" \ # Then overlay specific auth files from host ~/.claude (read-only) --ro-bind "$HOME/.claude/.credentials.json" "$HOME/.claude/.credentials.json" \ ``` 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. **Guard the mount:** Only add `--ro-bind` if the credentials file exists on the host: ```bash AUTH_MOUNTS=() if [[ -f "$HOME/.claude/.credentials.json" ]]; then AUTH_MOUNTS+=(--ro-bind "$HOME/.claude/.credentials.json" "$HOME/.claude/.credentials.json") fi ``` **Confidence:** HIGH for the pattern. The file path is confirmed from official docs. --- ## Per-Project Instance Isolation ### Instance Directory Pattern ``` ~/.claudebox/instances//.claude/ ``` Where `` is a stable identifier for the project. Options: | 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 | **Recommendation:** `$(basename "$CWD")-$(echo "$CWD" | md5sum | head -c8)` — readable in `ls` output, collision-resistant. ```bash INSTANCE_HASH="$(basename "$CWD")-$(echo "$CWD" | md5sum | cut -c1-8)" INSTANCE_DIR="$HOME/.claudebox/instances/$INSTANCE_HASH" mkdir -p "$INSTANCE_DIR" # In bwrap call: --bind "$INSTANCE_DIR" "$HOME/.claude" \ ``` **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 # ~/.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" ``` **Why bash-sourced, not TOML/JSON/YAML:** 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` **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. **Why not JSON:** - `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. **Profile loading:** ```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 - https://github.com/rootless-containers/slirp4netns — v1.3.3 release confirmed June 2025, `--ready-fd`, `--configure`, `--disable-host-loopback` flags documented (HIGH confidence) - https://code.claude.com/docs/en/authentication — `~/.claude/.credentials.json` path on Linux confirmed from official docs (HIGH confidence) - https://manpages.debian.org/unstable/bubblewrap/bwrap.1.en.html — `--unshare-net`, `--share-net`, `--info-fd` flags documented (HIGH confidence) - 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) --- *Stack research for: claudebox v2.0 network isolation, profiles, auth passthrough* *Researched: 2026-04-10*