claudebox/.planning/research/STACK.md

16 KiB

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 neededpkgs.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:

# 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

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:

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.

Selective file mounts, not directory replacement:

# 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:

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/<hash>/.claude/

Where <hash> 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.

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 neededmd5sum 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.

# ~/.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:

# 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:

# 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

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 needednix 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:

# 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


Stack research for: claudebox v2.0 network isolation, profiles, auth passthrough Researched: 2026-04-10