373 lines
16 KiB
Markdown
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*
|