claudebox/.planning/research/STACK.md

373 lines
16 KiB
Markdown

# 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/<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.
```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*