feat(04-01): add credential file mount for OAuth passthrough

- Add CREDS_FILE/CREDS_MOUNT detection after mkdir ~/.claudebox
- Conditional --bind in exec bwrap via BWRAP_ARGS array
- Mirror conditional bind in --dry-run display block
- Read-write mount (not ro-bind) for OAuth token refresh
- Silent skip when credentials file absent (no error/warning)
- Refactor exec bwrap to BWRAP_ARGS array for conditional mount support
This commit is contained in:
Christopher Mühl 2026-04-10 09:20:18 +00:00
parent 40e40e3f30
commit 6465da8583
18 changed files with 1335 additions and 2175 deletions

View file

@ -1,15 +0,0 @@
# Milestones
## v1.0 MVP (Shipped: 2026-04-10)
**Phases completed:** 3 phases, 5 plans, 6 tasks
**Key accomplishments:**
- Nix flake with writeShellApplication producing claudebox wrapper in bwrap with clearenv, env allowlist, tmpfs root, secret hiding, and comma/nix tool access
- Fixed NixOS symlink resolution — readlink -f for profile paths to real nix store paths
- CLI with --check, --dry-run modes, multi-flag parsing, and CLAUDE_ARGS accumulator
- Pre-launch env audit with grouped display, sensitive value masking, and interactive Y/n confirmation
- SANDBOX.md generation and CLAUDE.md import management for sandbox-aware prompting
---

View file

@ -2,7 +2,7 @@
## What This Is
A Nix derivation that produces a `claudebox` wrapper script for Claude Code. It runs Claude inside a bubblewrap sandbox with an allowlisted environment, explicit filesystem mounts, and a minimal PATH — keeping SSH keys, GPG/age secrets, cloud tokens, and Tailscale state completely invisible to the AI agent. Includes pre-launch env audit, diagnostic modes, and sandbox-aware prompting.
A Nix derivation that produces a `claudebox` wrapper script for Claude Code. It runs Claude inside a bubblewrap sandbox with an allowlisted environment, explicit filesystem mounts, and a minimal PATH — keeping SSH keys, GPG/age secrets, cloud tokens, and Tailscale state completely invisible to the AI agent.
## Core Value
@ -12,40 +12,36 @@ Secrets never enter the Claude Code environment. If a secret is accessible insid
### Validated
- ✓ Wrapper script that execs `claude --dangerously-skip-permissions` inside a bwrap sandbox — v1.0
- ✓ Environment allowlist: start with empty env, explicitly pass only known-safe vars — v1.0
- ✓ Pre-launch env audit: list all env vars being passed in for user review — v1.0
- ✓ `--yes` / `-y` flag to skip the env audit — v1.0
- ✓ Filesystem isolation: only CWD mounted read-write, plus `~/.claudebox` mapped to `~/.claude` — v1.0
- ✓ Secret paths hidden: `~/.ssh`, `~/.gnupg`, `~/.config/gcloud`, `~/.aws`, Tailscale state, age keys — v1.0
- ✓ Minimal PATH: Nix store paths only — coreutils, git, curl, jq, ripgrep, fd, nix, comma — v1.0
- ✓ Claude can self-install tools via `nix shell` or `, <tool>` (comma) — v1.0
- ✓ Default SANDBOX.md injected so Claude knows its capabilities and constraints — v1.0
- ✓ Works on endurance (NixOS desktop) — v1.0
- ✓ `--check` flag for environment diagnostics — v1.0
- ✓ `--dry-run` flag to print bwrap command without executing — v1.0
- [x] Default prompt/instructions injected so Claude knows how to use nix/comma to get dev tools — Validated in Phase 3: Sandbox-Aware Prompting
### Active
- [ ] Host `~/.claude` auth files mounted read-only for subscription passthrough
- [ ] Per-project instance directories (`~/.claudebox/instances/<hash>/.claude/`) — conversation history scoped per project
- [ ] Named profiles (`--profile foo` / `CLAUDEBOX_PROFILE=foo`) defining env vars, mounts, packages, network tier
- [ ] Profile storage at `~/.claudebox/profiles/`
- [ ] Nix devshell/package injection per profile
- [ ] Tiered network isolation: full, internet-only (unshare-net + slirp4netns), none (offline)
- [ ] Wrapper script that execs `claude --dangerously-skip-permissions` inside a bwrap sandbox
- [ ] Environment allowlist: start with empty env, explicitly pass only known-safe vars (HOME, PATH, TERM, EDITOR, LANG, etc.)
- [ ] Pre-launch env audit: before running, list all env vars being passed in so the user can review for secrets. Proceed on confirmation, abort on rejection
- [ ] `--yes` / `-y` flag to skip the env audit and launch immediately
- [ ] Filesystem isolation: only CWD mounted read-write, plus `~/.claudebox` mapped to `~/.claude` inside the sandbox
- [ ] Secret paths hidden: `~/.ssh`, `~/.gnupg`, `~/.config/gcloud`, `~/.aws`, Tailscale state, age keys — none of these visible inside the sandbox
- [ ] Minimal PATH: Nix store paths only — coreutils, git, curl, jq, ripgrep, fd, nix, comma
- [ ] Claude can self-install tools via `nix shell` or `, <tool>` (comma) inside the sandbox
- [x] Default prompt/instructions injected so Claude knows how to use nix/comma to get dev tools — Validated in Phase 3
- [ ] Works on endurance (NixOS desktop)
### Out of Scope
- Network isolation — trusting Claude Code's built-in proxy for domain allowlisting
- NixOS module form — this is a wrapper script derivation, not a services/programs module
- Configurable per-project profiles — v1 is one tool set, profiles come later
- Shareability — personal tool first, not designed for others yet
- Domain-level network allowlists — tiered isolation (full/internet-only/none) is sufficient for now
## Context
Shipped v1.0 with 399 LOC (350 shell + 49 Nix).
Tech stack: Nix flake (`writeShellApplication`) + bubblewrap + comma-with-db.
Runs on NixOS (endurance) with readlink -f workaround for symlink chain resolution.
Non-NixOS support added via conditional `/etc/static` mount.
- Target machine: endurance (NixOS desktop)
- Claude Code already has bubblewrap sandboxing (`--sandbox`) but it doesn't control env vars or secret file visibility — that's claudebox's job
- bwrap is in nixpkgs, so the derivation uses `writeShellApplication` wrapping a bwrap invocation
- `~/.claudebox/` is the persistent config directory that gets bind-mounted as `~/.claude` inside the sandbox, keeping real `~/.claude` outside
- comma (`,`) is a tool that runs `nix shell nixpkgs#<pkg> -c <pkg>` — lets Claude pull in any tool on demand without pre-declaring it
- The Nix store needs to be mounted read-only inside the sandbox for nix/comma to work
## Constraints
@ -57,25 +53,11 @@ Non-NixOS support added via conditional `/etc/static` mount.
| Decision | Rationale | Outcome |
|----------|-----------|---------|
| Own bwrap over Claude's --sandbox | Full control over mounts, env, namespaces | ✓ Good |
| Env allowlist over denylist | Denylist misses unknown vars; allowlist is secure by default | ✓ Good |
| comma for tool access | Claude can pull any tool on demand without pre-declaring PATH entries | ✓ Good |
| --dangerously-skip-permissions always | The bwrap sandbox IS the permission layer — Claude's prompts are redundant | ✓ Good |
| Pre-launch env audit | User reviews exactly what enters the sandbox, catches leaks before they happen | ✓ Good |
| readlink -f for binary resolution | NixOS profile symlinks aren't visible inside bwrap; resolve to real store paths | ✓ Good |
| Claude Code via nix-claude-code flake | ryoppippi/nix-claude-code, not host PATH | ✓ Good |
| SANDBOX.md as separate file with @import | Keeps user CLAUDE.md clean, sandbox instructions always fresh | ✓ Good |
## Current Milestone: v2.0 Network Isolation & Profiles
**Goal:** Add tiered network isolation, per-project instance isolation, named profiles, and host auth passthrough.
**Target features:**
- Host auth passthrough (read-only mount of auth files from `~/.claude`)
- Per-project instance isolation (conversation history scoped per project automatically)
- Named profiles with env vars, mounts, packages, and network tier
- Nix devshell injection per profile
- Tiered network: full, internet-only (no LAN/Tailscale), none (offline)
| Own bwrap over Claude's --sandbox | Full control over mounts, env, namespaces | — Pending |
| Env allowlist over denylist | Denylist misses unknown vars; allowlist is secure by default | — Pending |
| comma for tool access | Claude can pull any tool on demand without pre-declaring PATH entries | — Pending |
| --dangerously-skip-permissions always | The bwrap sandbox IS the permission layer — Claude's prompts are redundant | — Pending |
| Pre-launch env audit | User reviews exactly what enters the sandbox, catches leaks before they happen | — Pending |
## Evolution
@ -95,4 +77,4 @@ This document evolves at phase transitions and milestone boundaries.
4. Update Context with current state
---
*Last updated: 2026-04-10 after v2.0 milestone started*
*Last updated: 2026-04-09 after Phase 3 completion*

View file

@ -1,96 +1,131 @@
# Requirements: claudebox
**Defined:** 2026-04-10
**Core Value:** Secrets never enter the Claude Code environment. If a secret is accessible inside the sandbox, it's a bug.
**Defined:** 2026-04-09
**Core Value:** Secrets never enter the Claude Code environment
## v2.0 Requirements
## v1 Requirements
Requirements for v2.0 release. Each maps to roadmap phases.
### Sandbox Core
### Authentication
- [x] **SAND-01**: Wrapper script produces a `claudebox` binary via Nix `writeShellApplication`
- [x] **SAND-02**: bwrap sandbox starts with `--clearenv` — empty environment, only explicitly allowed vars pass through
- [x] **SAND-03**: Environment allowlist includes only: HOME, PATH, TERM, EDITOR, LANG, LC_ALL, NIX_SSL_CERT_FILE, SSL_CERT_FILE, ANTHROPIC_API_KEY, USER, SHELL, XDG_RUNTIME_DIR
- [x] **SAND-04**: Filesystem starts as tmpfs root — nothing from host is visible unless explicitly mounted
- [x] **SAND-05**: CWD is bind-mounted read-write inside the sandbox
- [x] **SAND-06**: `/nix/store` is mounted read-only inside the sandbox
- [x] **SAND-07**: Nix daemon socket (`/nix/var/nix/daemon-socket`) is bind-mounted for `nix shell` / comma to work
- [x] **SAND-08**: `~/.claudebox` on host is bind-mounted as `~/.claude` inside the sandbox
- [x] **SAND-09**: Secret paths are never mounted: `~/.ssh`, `~/.gnupg`, `~/.aws`, `~/.config/gcloud`, age key paths, `/var/lib/tailscale`
- [x] **SAND-10**: PATH inside sandbox contains only Nix store paths: coreutils, git, curl, jq, ripgrep, fd, nix, comma, bash
- [x] **SAND-11**: Working `/tmp` (tmpfs), `/dev` (bwrap `--dev`), `/proc` (bwrap `--proc`)
- [x] **SAND-12**: DNS resolution works inside sandbox (`/etc/resolv.conf` and its symlink targets mounted)
- [x] **SAND-13**: SSL/TLS works inside sandbox (cert bundle mounted, `NIX_SSL_CERT_FILE` set)
- [x] **SAND-14**: Exit code from Claude Code passes through to the wrapper's caller
- [x] **SAND-15**: Signals (Ctrl+C) reach Claude Code via `exec` — no intermediate shell
- [ ] **AUTH-01**: User's existing Claude subscription works inside sandbox via host `~/.claude/.credentials.json` mounted read-write
- [ ] **AUTH-02**: `ANTHROPIC_API_KEY` env var passed through when set on host (takes precedence over OAuth)
### Tool Provisioning
### Instance Isolation
- [x] **TOOL-01**: comma (`,`) is available in sandbox PATH for on-demand tool installation
- [x] **TOOL-02**: `nix shell` works inside the sandbox for installing arbitrary packages
- [x] **TOOL-03**: Newly installed Nix store paths are visible inside sandbox (live bind mount)
- [ ] **INST-01**: Each project directory gets its own isolated `~/.claude` state (conversations, todos, history)
- [ ] **INST-02**: Git worktrees of the same repo share the same instance directory
- [ ] **INST-03**: Concurrent claudebox sessions in the same project are protected by flock
- [ ] **INST-04**: `--gc` command cleans up instance directories for projects that no longer exist
### User Experience
- [ ] **UX-01**: Pre-launch env audit displays all env vars being passed into the sandbox on stderr
- [ ] **UX-02**: Pre-launch env audit prompts for confirmation before proceeding
- [ ] **UX-03**: `--yes` / `-y` flag skips the env audit confirmation
- [ ] **UX-04**: `--dry-run` flag prints the full bwrap command without executing
- [ ] **UX-05**: `--check` flag verifies bwrap exists, required Nix packages are available, and `~/.claudebox` exists
- [x] **UX-06**: `claude --dangerously-skip-permissions` is always passed — the sandbox is the permission layer
### Claude Awareness
- [ ] **AWARE-01**: Default `CLAUDE.md` is created in `~/.claudebox/` on first run if not present
- [ ] **AWARE-02**: Injected CLAUDE.md tells Claude it's in a sandbox, how to use comma/nix for tools, and what's not available
### Git Support
- [x] **GIT-01**: Git works inside the sandbox with a minimal `.gitconfig` (user name/email)
- [x] **GIT-02**: `safe.directory` is configured to trust the mounted CWD
### Nix Packaging
- [x] **NIX-01**: Project is a Nix flake with `claudebox` as default package
- [x] **NIX-02**: All runtime dependencies are pinned via flake inputs
- [x] **NIX-03**: `nix run` or `nix profile install` produces a working `claudebox` command
## v2 Requirements
### Network Isolation
- [ ] **NET-01**: `--network none` fully isolates network (offline mode, Nix daemon still works via socket)
- [ ] **NET-02**: `--network inet` allows internet access but blocks LAN and Tailscale traffic
- [ ] **NET-03**: `--network full` preserves current behavior (host network, default)
- [ ] **NET-04**: `--network` CLI flag selects tier at launch
- [ ] **NET-05**: `CLAUDEBOX_NETWORK` env var sets tier (CLI flag wins if both set)
- **NET-01**: Block LAN/Tailscale access (RFC1918 + 100.64.0.0/10) while allowing internet egress
- **NET-02**: Network namespace with controlled outbound via slirp4netns or veth pair
### Profiles
### Enhanced Security
- [ ] **PROF-01**: Named profiles loadable via `--profile foo` or `CLAUDEBOX_PROFILE=foo` (flag wins)
- [ ] **PROF-02**: Profile defines env vars to pass through, extra mounts, and network tier
- [ ] **PROF-03**: Profiles stored as JSON at `~/.claudebox/profiles/<name>.json`
- [ ] **PROF-04**: `--list-profiles` shows available profiles
- [ ] **PROF-05**: `--show-profile <name>` displays profile contents
- [ ] **PROF-06**: Pre-launch audit extended to show active profile, network tier, and extra mounts
- **SEC-01**: Env var leak detection — regex scan for patterns like `*KEY*`, `*TOKEN*`, `*SECRET*`
- **SEC-02**: PID namespace isolation (`--unshare-pid`)
- **SEC-03**: Git credential isolation — sandbox-specific `.gitconfig` with HTTPS-only credential helpers
## Future Requirements
### Extensibility
### Nix Package Injection
- **PKG-01**: Profile `packages` field resolved via `nix build` and added to sandbox PATH
- **PKG-02**: Package resolution cached to avoid startup latency on repeated launches
### Profile Inheritance
- **PROF-07**: Profile `extends` field to inherit from another profile
### Instance Management
- **INST-05**: Instance dir GC with dry-run mode
- **EXT-01**: Project-local tool declarations via `.claudebox.toml` or `.claudebox/tools.txt`
- **EXT-02**: Additional mount paths via `--mount-ro` / `--mount-rw` flags
- **EXT-03**: Configurable security profiles (different postures for different projects)
## Out of Scope
| Feature | Reason |
|---------|--------|
| Full nix develop devShell integration | Profile `packages` field covers 80% case; full devShell adds complexity |
| Domain-level network allowlists | Three tiers (full/inet/none) cover actual use cases |
| NixOS module form | Wrapper script derivation, not a services/programs module |
| Shareability | Personal tool first, not designed for others yet |
| Per-profile SANDBOX.md overrides | Breaks the security invariant — one SANDBOX.md for all |
| Storing secret values in profile files | Profiles reference env var names, not values |
| GUI/X11/Wayland passthrough | CLI tool, no desktop integration needed |
| Audio/PulseAudio/PipeWire | No audio needed for coding agent |
| DBus access | Common sandbox escape vector, not needed |
| Seccomp syscall filtering | Threat model is data exfiltration, not privilege escalation |
| Docker/OCI wrapping | Nix+bwrap is lighter and daemonless |
| NixOS module (services/programs) | Wrapper script derivation is sufficient |
| Multi-user / shareability | Personal tool for endurance |
## Traceability
Which phases cover which requirements. Updated during roadmap creation.
| Requirement | Phase | Status |
|-------------|-------|--------|
| AUTH-01 | Phase 4 | Pending |
| AUTH-02 | Phase 4 | Pending |
| INST-01 | Phase 5 | Pending |
| INST-02 | Phase 5 | Pending |
| INST-03 | Phase 5 | Pending |
| INST-04 | Phase 5 | Pending |
| NET-01 | Phase 6 | Pending |
| NET-02 | Phase 6 | Pending |
| NET-03 | Phase 6 | Pending |
| NET-04 | Phase 6 | Pending |
| NET-05 | Phase 6 | Pending |
| PROF-01 | Phase 7 | Pending |
| PROF-02 | Phase 7 | Pending |
| PROF-03 | Phase 7 | Pending |
| PROF-04 | Phase 7 | Pending |
| PROF-05 | Phase 7 | Pending |
| PROF-06 | Phase 7 | Pending |
| SAND-01 | Phase 1 | Complete |
| SAND-02 | Phase 1 | Complete |
| SAND-03 | Phase 1 | Complete |
| SAND-04 | Phase 1 | Complete |
| SAND-05 | Phase 1 | Complete |
| SAND-06 | Phase 1 | Complete |
| SAND-07 | Phase 1 | Complete |
| SAND-08 | Phase 1 | Complete |
| SAND-09 | Phase 1 | Complete |
| SAND-10 | Phase 1 | Complete |
| SAND-11 | Phase 1 | Complete |
| SAND-12 | Phase 1 | Complete |
| SAND-13 | Phase 1 | Complete |
| SAND-14 | Phase 1 | Complete |
| SAND-15 | Phase 1 | Complete |
| TOOL-01 | Phase 1 | Complete |
| TOOL-02 | Phase 1 | Complete |
| TOOL-03 | Phase 1 | Complete |
| UX-01 | Phase 2 | Pending |
| UX-02 | Phase 2 | Pending |
| UX-03 | Phase 2 | Pending |
| UX-04 | Phase 2 | Pending |
| UX-05 | Phase 2 | Pending |
| UX-06 | Phase 1 | Complete |
| AWARE-01 | Phase 3 | Pending |
| AWARE-02 | Phase 3 | Pending |
| GIT-01 | Phase 1 | Complete |
| GIT-02 | Phase 1 | Complete |
| NIX-01 | Phase 1 | Complete |
| NIX-02 | Phase 1 | Complete |
| NIX-03 | Phase 1 | Complete |
**Coverage:**
- v2.0 requirements: 17 total
- Mapped to phases: 17
- v1 requirements: 31 total
- Mapped to phases: 31
- Unmapped: 0
---
*Requirements defined: 2026-04-10*
*Last updated: 2026-04-10 after initial definition*
*Requirements defined: 2026-04-09*
*Last updated: 2026-04-09 after roadmap creation*

View file

@ -1,52 +0,0 @@
# Project Retrospective
*A living document updated after each milestone. Lessons feed forward into future planning.*
## Milestone: v1.0 — MVP
**Shipped:** 2026-04-10
**Phases:** 3 | **Plans:** 5
### What Was Built
- Nix flake producing `claudebox` wrapper: bwrap sandbox with clearenv, env allowlist, tmpfs root, secret path hiding, git identity forwarding, comma/nix tool access
- CLI diagnostic modes: --check (environment validation), --dry-run (print bwrap command), --shell (debug shell)
- Pre-launch env audit with grouped sections, sensitive value masking, Y/n confirmation prompt
- SANDBOX.md generation and CLAUDE.md import management so Claude knows its sandbox constraints
### What Worked
- writeShellApplication with builtins.readFile pattern — shellcheck at build time, shell syntax highlighting in editors
- Rapid phase execution: Phase 1 in ~2 min, Phase 2 in ~4 min, Phase 3 in ~76 sec
- clearenv + allowlist approach caught all secret leakage by default
- readlink -f fix for NixOS symlinks was discovered and fixed in-phase without blocking
### What Was Inefficient
- REQUIREMENTS.md traceability table not updated during execution — 7 requirements showed "Pending" despite being complete
- Phase 3 context was gathered but not executed in the same session, requiring session continuity overhead
### Patterns Established
- readlink -f for all host-resolved binaries passed into bwrap (NixOS symlink chains)
- SANDBOX.md as separate file with @import in CLAUDE.md (keeps user content clean, sandbox instructions always fresh)
- export trick for shellcheck SC2034 when a variable is used in a later plan but not yet
### Key Lessons
1. On NixOS, every host binary path is a symlink chain through /etc/profiles/per-user/ — must resolve to real store paths before passing to bwrap
2. Conditional mounts needed for cross-distro support (/etc/static exists only on NixOS)
### Cost Observations
- Model mix: predominantly opus for execution
- Sessions: ~3 sessions across 2 days
- Notable: entire v1.0 MVP shipped in under 2 days of wall clock time
---
## Cross-Milestone Trends
### Process Evolution
| Milestone | Phases | Plans | Key Change |
|-----------|--------|-------|------------|
| v1.0 | 3 | 5 | Initial project — established sandbox patterns |
### Top Lessons (Verified Across Milestones)
1. (Will populate as more milestones complete)

View file

@ -1,90 +1,73 @@
# Roadmap: claudebox
## Milestones
## Overview
- ✅ **v1.0 MVP** — Phases 1-3 (shipped 2026-04-10)
- 🚧 **v2.0 Network Isolation & Profiles** — Phases 4-7 (in progress)
claudebox is a Nix-packaged bwrap sandbox wrapper for Claude Code. The roadmap moves from a working sandbox (Phase 1) through CLI polish (Phase 2) to sandbox-aware prompting (Phase 3). Phase 1 is the bulk of the work -- once Claude runs inside bwrap with env isolation, filesystem isolation, and tool provisioning, the remaining phases add UX and developer experience improvements.
## Phases
<details>
<summary>✅ v1.0 MVP (Phases 1-3) — SHIPPED 2026-04-10</summary>
**Phase Numbering:**
- Integer phases (1, 2, 3): Planned milestone work
- Decimal phases (2.1, 2.2): Urgent insertions (marked with INSERTED)
- [x] Phase 1: Minimal Viable Sandbox (2/2 plans) — bwrap sandbox with clearenv, env allowlist, filesystem isolation, secret hiding, tool provisioning
- [x] Phase 2: Env Audit and CLI Polish (2/2 plans) — --check, --dry-run, env audit display with masking, confirmation prompt
- [x] Phase 3: Sandbox-Aware Prompting (1/1 plan) — SANDBOX.md generation, CLAUDE.md import management
Decimal phases appear between their surrounding integers in numeric order.
Full details: [milestones/v1.0-ROADMAP.md](milestones/v1.0-ROADMAP.md)
</details>
### 🚧 v2.0 Network Isolation & Profiles (In Progress)
**Milestone Goal:** Add tiered network isolation, per-project instance isolation, named profiles, and host auth passthrough so Claude can authenticate, work in project-scoped history, operate at controlled network exposure, and run under reusable configuration profiles.
- [ ] **Phase 4: Auth Passthrough** — Mount host Claude credentials read-write so subscription and API key access work inside the sandbox
- [ ] **Phase 5: Per-Project Instance Isolation** — Scope conversation history and state to each project directory automatically
- [ ] **Phase 6: Tiered Network Isolation** — Add none/inet/full network tiers selectable at launch
- [ ] **Phase 7: Named Profiles** — Load named configuration profiles that set env vars, mounts, and network tier
- [ ] **Phase 1: Minimal Viable Sandbox** - Working claudebox command that launches Claude in bwrap with full isolation and tool provisioning
- [ ] **Phase 2: Env Audit and CLI Polish** - Pre-launch env review, --yes, --dry-run, and --check flags
- [ ] **Phase 3: Sandbox-Aware Prompting** - Injected CLAUDE.md so Claude knows its capabilities and constraints
## Phase Details
### Phase 4: Auth Passthrough
**Goal**: Claude Code inside the sandbox can authenticate using the host subscription or API key
**Depends on**: Phase 3
**Requirements**: AUTH-01, AUTH-02
### Phase 1: Minimal Viable Sandbox
**Goal**: User can run `claudebox` in any project directory and get a fully functional Claude Code session with secrets invisible
**Depends on**: Nothing (first phase)
**Requirements**: SAND-01, SAND-02, SAND-03, SAND-04, SAND-05, SAND-06, SAND-07, SAND-08, SAND-09, SAND-10, SAND-11, SAND-12, SAND-13, SAND-14, SAND-15, TOOL-01, TOOL-02, TOOL-03, GIT-01, GIT-02, NIX-01, NIX-02, NIX-03, UX-06
**Success Criteria** (what must be TRUE):
1. Running claudebox with an active Claude subscription succeeds without re-authentication
2. OAuth token refresh completes silently — credentials file is updated and the session continues
3. When `ANTHROPIC_API_KEY` is set on the host, it is passed into the sandbox and takes precedence over OAuth
**Plans**: 1 plan
1. Running `nix run` or `nix profile install` produces a working `claudebox` command
2. `claudebox` launches Claude Code inside bwrap; `env` inside the sandbox shows only allowlisted variables (no SSH_AUTH_SOCK, AWS_PROFILE, etc.)
3. Secret paths (~/.ssh, ~/.gnupg, ~/.aws, ~/.config/gcloud, age keys, /var/lib/tailscale) are not visible inside the sandbox
4. Claude can run `curl https://example.com`, `git status`, `, jq --help` (comma), and `nix shell nixpkgs#python3 -c python3 --version` inside the sandbox
5. Ctrl+C terminates the session cleanly; exit code from Claude passes through to the caller
**Plans:** 2 plans
Plans:
- [ ] 04-01-PLAN.md — Credential mount + audit redesign (unified env list, Mounts section, Network section)
- [x] 01-01-PLAN.md -- Create flake.nix and claudebox.sh with complete bwrap sandbox
- [x] 01-02-PLAN.md -- Build verification and manual sandbox smoke test
### Phase 5: Per-Project Instance Isolation
**Goal**: Each project directory has its own isolated Claude state so conversation history, todos, and settings do not bleed between projects
**Depends on**: Phase 4
**Requirements**: INST-01, INST-02, INST-03, INST-04
### Phase 2: Env Audit and CLI Polish
**Goal**: User can review exactly what enters the sandbox before launch, and has diagnostic tools for troubleshooting
**Depends on**: Phase 1
**Requirements**: UX-01, UX-02, UX-03, UX-04, UX-05
**Success Criteria** (what must be TRUE):
1. Launching claudebox in two different project directories produces two separate conversation histories with no cross-contamination
2. Launching claudebox from a git worktree shares instance state with the main worktree of the same repo
3. Two concurrent claudebox sessions in the same project do not corrupt each other's state
4. Running `claudebox --gc` removes instance directories for project roots that no longer exist on disk
**Plans**: TBD
1. Running `claudebox` without `--yes` prints all env vars being passed into the sandbox and prompts for confirmation before proceeding
2. Running `claudebox --yes` or `claudebox -y` skips the env audit and launches immediately
3. Running `claudebox --dry-run` prints the full bwrap command without executing it
4. Running `claudebox --check` reports whether bwrap exists, required Nix packages are available, and ~/.claudebox exists
**Plans:** 2 plans
### Phase 6: Tiered Network Isolation
**Goal**: Users can select a network access tier at launch to control whether Claude has no network, internet-only, or full host network access
**Depends on**: Phase 5
**Requirements**: NET-01, NET-02, NET-03, NET-04, NET-05
**Success Criteria** (what must be TRUE):
1. `--network none` (or `CLAUDEBOX_NETWORK=none`) starts a session with no network access; DNS and all TCP connections fail inside the sandbox while the Nix daemon socket remains usable
2. `--network inet` starts a session where internet hostnames resolve and connections succeed, but LAN addresses and Tailscale IPs are unreachable
3. `--network full` (the default) preserves existing behavior with full host network access
4. When both `CLAUDEBOX_NETWORK` and `--network` are set, the CLI flag wins
**Plans**: TBD
**UI hint**: no
Plans:
- [x] 02-01-PLAN.md -- Refactor flag parsing, add --check and --dry-run modes
- [x] 02-02-PLAN.md -- Env audit display with grouping, masking, and confirmation prompt
### Phase 7: Named Profiles
**Goal**: Users can define named profiles that package env var passthrough, extra mounts, and network tier into a reusable configuration loaded by name at launch
**Depends on**: Phase 6
**Requirements**: PROF-01, PROF-02, PROF-03, PROF-04, PROF-05, PROF-06
### Phase 3: Sandbox-Aware Prompting
**Goal**: Claude inside the sandbox knows it is sandboxed, how to install tools, and what is unavailable
**Depends on**: Phase 1
**Requirements**: AWARE-01, AWARE-02
**Success Criteria** (what must be TRUE):
1. `claudebox --profile foo` loads `~/.claudebox/profiles/foo.json` and applies its env vars, mounts, and network tier for the session
2. `CLAUDEBOX_PROFILE=foo` activates a profile when no `--profile` flag is given; `--profile` wins when both are set
3. `claudebox --list-profiles` prints all profiles found under `~/.claudebox/profiles/`
4. `claudebox --show-profile foo` prints the contents of the named profile
5. The pre-launch env audit displays the active profile name, resolved network tier, and any extra mounts added by the profile
**Plans**: TBD
1. First run of `claudebox` creates a default CLAUDE.md in ~/.claudebox/ if none exists
2. The injected CLAUDE.md tells Claude it is in a bwrap sandbox, how to use comma (`, <tool>`) and `nix shell` for tool installation, and that SSH/GPG/cloud credentials are unavailable
**Plans:** 1 plan
Plans:
- [x] 03-01-PLAN.md -- Add SANDBOX.md generation and CLAUDE.md import management
## Progress
| Phase | Milestone | Plans Complete | Status | Completed |
|-------|-----------|----------------|--------|-----------|
| 1. Minimal Viable Sandbox | v1.0 | 2/2 | Complete | 2026-04-09 |
| 2. Env Audit and CLI Polish | v1.0 | 2/2 | Complete | 2026-04-09 |
| 3. Sandbox-Aware Prompting | v1.0 | 1/1 | Complete | 2026-04-10 |
| 4. Auth Passthrough | v2.0 | 0/1 | Not started | - |
| 5. Per-Project Instance Isolation | v2.0 | 0/? | Not started | - |
| 6. Tiered Network Isolation | v2.0 | 0/? | Not started | - |
| 7. Named Profiles | v2.0 | 0/? | Not started | - |
**Execution Order:**
Phases execute in numeric order: 1 -> 2 -> 3
| Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------|
| 1. Minimal Viable Sandbox | 2/2 | Complete | - |
| 2. Env Audit and CLI Polish | 0/2 | Planned | - |
| 3. Sandbox-Aware Prompting | 0/1 | Not started | - |

View file

@ -1,36 +1,43 @@
---
gsd_state_version: 1.0
milestone: v2.0
milestone_name: Network Isolation & Profiles
status: active
stopped_at: null
last_updated: "2026-04-10"
last_activity: 2026-04-10 - v2.0 roadmap created; phases 4-7 defined
milestone: v1.0
milestone_name: milestone
status: executing
stopped_at: Phase 3 context gathered
last_updated: "2026-04-09T19:24:16.913Z"
last_activity: 2026-04-09
progress:
total_phases: 4
completed_phases: 0
total_plans: 0
completed_plans: 0
percent: 0
total_phases: 3
completed_phases: 3
total_plans: 5
completed_plans: 5
percent: 100
---
# Project State
## Project Reference
See: .planning/PROJECT.md (updated 2026-04-10)
See: .planning/PROJECT.md (updated 2026-04-09)
**Core value:** Secrets never enter the Claude Code environment. If a secret is accessible inside the sandbox, it's a bug.
**Current focus:** Phase 4 — Auth Passthrough
**Core value:** Secrets never enter the Claude Code environment
**Current focus:** Phase 2 (next)
## Current Position
Phase: 4 of 7 (Auth Passthrough)
Plan: 0 of ? in current phase
Status: Ready to plan
Last activity: 2026-04-10 — v2.0 roadmap created; phases 4-7 defined
Phase: 03 of 3 (sandbox aware prompting)
Plan: Not started
Status: Ready to execute
Last activity: 2026-04-10 - Completed quick task 260410-d4u: on non-nixos hosts, bwrap fails because /etc/static does not exist
Progress: [███░░░░░░░] 30% (v1.0 complete; v2.0 phases 4-7 not started)
Progress: [███░░░░░░░] 33%
## Performance Metrics
**Velocity:**
| Phase 01 P01 | 1min | 2 tasks | 3 files |
| Phase 01 P02 | 1min | 2 tasks | 1 file |
## Accumulated Context
@ -40,10 +47,8 @@ Progress: [███░░░░░░░] 30% (v1.0 complete; v2.0 phases 4-7 n
- [Phase 01]: readlink -f required to resolve NixOS profile symlinks to real nix store paths for bwrap visibility
- [Phase 01]: SANDBOX_PATH built via makeBinPath in flake.nix to prevent host PATH leakage
- [Phase 01]: SHELL set to nix store bash path, not /bin/bash (doesn't exist in tmpfs root)
- [Phase 01]: --shell flag added for manual sandbox debugging
- [Phase 01]: SSL cert verification failure is a host-level NixOS issue, not sandbox-specific
- [v2.0 planning]: Auth mount must be read-write — OAuth token refresh writes back to .credentials.json; ro-bind causes silent EACCES
- [v2.0 planning]: Profile format will be JSON (not bash-sourced) to prevent code injection
- [v2.0 planning]: Attempt pasta sidecar first for inet tier; fall back to slirp4netns if integration is difficult
### Pending Todos
@ -51,12 +56,16 @@ None.
### Blockers/Concerns
- [Phase 6]: pasta vs slirp4netns final decision deferred to Phase 6 planning — exact CLI flags need live verification
- [Phase 6]: inet tier requires exec-to-wait refactor (background bwrap, coordinate with sidecar via --ready-fd/--exit-fd)
- SSL cert verification fails system-wide (host + sandbox) — NixOS/OpenSSL issue, not claudebox
- SSL cert verification fails system-wide (host + sandbox) -- NixOS/OpenSSL issue, not claudebox
### Quick Tasks Completed
| # | Description | Date | Commit | Directory |
|---|-------------|------|--------|-----------|
| 260410-d4u | on non-nixos hosts, bwrap fails because /etc/static does not exist | 2026-04-10 | 97c10f8 | [260410-d4u-on-non-nixos-hosts-bwrap-fails-because-e](./quick/260410-d4u-on-non-nixos-hosts-bwrap-fails-because-e/) |
## Session Continuity
Last session: 2026-04-09T18:59:43.248Z
Stopped at: Phase 3 context gathered
Resume file: .planning/phases/03-sandbox-aware-prompting/03-CONTEXT.md

View file

@ -28,8 +28,7 @@
"skip_discuss": false,
"code_review": true,
"code_review_depth": "standard",
"use_worktrees": true,
"_auto_chain_active": false
"use_worktrees": true
},
"hooks": {
"context_warnings": true

View file

@ -1,140 +0,0 @@
# Requirements Archive: v1.0 MVP
**Archived:** 2026-04-10
**Status:** SHIPPED
For current requirements, see `.planning/REQUIREMENTS.md`.
---
# Requirements: claudebox
**Defined:** 2026-04-09
**Core Value:** Secrets never enter the Claude Code environment
## v1 Requirements
### Sandbox Core
- [x] **SAND-01**: Wrapper script produces a `claudebox` binary via Nix `writeShellApplication`
- [x] **SAND-02**: bwrap sandbox starts with `--clearenv` — empty environment, only explicitly allowed vars pass through
- [x] **SAND-03**: Environment allowlist includes only: HOME, PATH, TERM, EDITOR, LANG, LC_ALL, NIX_SSL_CERT_FILE, SSL_CERT_FILE, ANTHROPIC_API_KEY, USER, SHELL, XDG_RUNTIME_DIR
- [x] **SAND-04**: Filesystem starts as tmpfs root — nothing from host is visible unless explicitly mounted
- [x] **SAND-05**: CWD is bind-mounted read-write inside the sandbox
- [x] **SAND-06**: `/nix/store` is mounted read-only inside the sandbox
- [x] **SAND-07**: Nix daemon socket (`/nix/var/nix/daemon-socket`) is bind-mounted for `nix shell` / comma to work
- [x] **SAND-08**: `~/.claudebox` on host is bind-mounted as `~/.claude` inside the sandbox
- [x] **SAND-09**: Secret paths are never mounted: `~/.ssh`, `~/.gnupg`, `~/.aws`, `~/.config/gcloud`, age key paths, `/var/lib/tailscale`
- [x] **SAND-10**: PATH inside sandbox contains only Nix store paths: coreutils, git, curl, jq, ripgrep, fd, nix, comma, bash
- [x] **SAND-11**: Working `/tmp` (tmpfs), `/dev` (bwrap `--dev`), `/proc` (bwrap `--proc`)
- [x] **SAND-12**: DNS resolution works inside sandbox (`/etc/resolv.conf` and its symlink targets mounted)
- [x] **SAND-13**: SSL/TLS works inside sandbox (cert bundle mounted, `NIX_SSL_CERT_FILE` set)
- [x] **SAND-14**: Exit code from Claude Code passes through to the wrapper's caller
- [x] **SAND-15**: Signals (Ctrl+C) reach Claude Code via `exec` — no intermediate shell
### Tool Provisioning
- [x] **TOOL-01**: comma (`,`) is available in sandbox PATH for on-demand tool installation
- [x] **TOOL-02**: `nix shell` works inside the sandbox for installing arbitrary packages
- [x] **TOOL-03**: Newly installed Nix store paths are visible inside sandbox (live bind mount)
### User Experience
- [ ] **UX-01**: Pre-launch env audit displays all env vars being passed into the sandbox on stderr
- [ ] **UX-02**: Pre-launch env audit prompts for confirmation before proceeding
- [ ] **UX-03**: `--yes` / `-y` flag skips the env audit confirmation
- [ ] **UX-04**: `--dry-run` flag prints the full bwrap command without executing
- [ ] **UX-05**: `--check` flag verifies bwrap exists, required Nix packages are available, and `~/.claudebox` exists
- [x] **UX-06**: `claude --dangerously-skip-permissions` is always passed — the sandbox is the permission layer
### Claude Awareness
- [ ] **AWARE-01**: Default `CLAUDE.md` is created in `~/.claudebox/` on first run if not present
- [ ] **AWARE-02**: Injected CLAUDE.md tells Claude it's in a sandbox, how to use comma/nix for tools, and what's not available
### Git Support
- [x] **GIT-01**: Git works inside the sandbox with a minimal `.gitconfig` (user name/email)
- [x] **GIT-02**: `safe.directory` is configured to trust the mounted CWD
### Nix Packaging
- [x] **NIX-01**: Project is a Nix flake with `claudebox` as default package
- [x] **NIX-02**: All runtime dependencies are pinned via flake inputs
- [x] **NIX-03**: `nix run` or `nix profile install` produces a working `claudebox` command
## v2 Requirements
### Network Isolation
- **NET-01**: Block LAN/Tailscale access (RFC1918 + 100.64.0.0/10) while allowing internet egress
- **NET-02**: Network namespace with controlled outbound via slirp4netns or veth pair
### Enhanced Security
- **SEC-01**: Env var leak detection — regex scan for patterns like `*KEY*`, `*TOKEN*`, `*SECRET*`
- **SEC-02**: PID namespace isolation (`--unshare-pid`)
- **SEC-03**: Git credential isolation — sandbox-specific `.gitconfig` with HTTPS-only credential helpers
### Extensibility
- **EXT-01**: Project-local tool declarations via `.claudebox.toml` or `.claudebox/tools.txt`
- **EXT-02**: Additional mount paths via `--mount-ro` / `--mount-rw` flags
- **EXT-03**: Configurable security profiles (different postures for different projects)
## Out of Scope
| Feature | Reason |
|---------|--------|
| GUI/X11/Wayland passthrough | CLI tool, no desktop integration needed |
| Audio/PulseAudio/PipeWire | No audio needed for coding agent |
| DBus access | Common sandbox escape vector, not needed |
| Seccomp syscall filtering | Threat model is data exfiltration, not privilege escalation |
| Docker/OCI wrapping | Nix+bwrap is lighter and daemonless |
| NixOS module (services/programs) | Wrapper script derivation is sufficient |
| Multi-user / shareability | Personal tool for endurance |
## Traceability
| Requirement | Phase | Status |
|-------------|-------|--------|
| SAND-01 | Phase 1 | Complete |
| SAND-02 | Phase 1 | Complete |
| SAND-03 | Phase 1 | Complete |
| SAND-04 | Phase 1 | Complete |
| SAND-05 | Phase 1 | Complete |
| SAND-06 | Phase 1 | Complete |
| SAND-07 | Phase 1 | Complete |
| SAND-08 | Phase 1 | Complete |
| SAND-09 | Phase 1 | Complete |
| SAND-10 | Phase 1 | Complete |
| SAND-11 | Phase 1 | Complete |
| SAND-12 | Phase 1 | Complete |
| SAND-13 | Phase 1 | Complete |
| SAND-14 | Phase 1 | Complete |
| SAND-15 | Phase 1 | Complete |
| TOOL-01 | Phase 1 | Complete |
| TOOL-02 | Phase 1 | Complete |
| TOOL-03 | Phase 1 | Complete |
| UX-01 | Phase 2 | Pending |
| UX-02 | Phase 2 | Pending |
| UX-03 | Phase 2 | Pending |
| UX-04 | Phase 2 | Pending |
| UX-05 | Phase 2 | Pending |
| UX-06 | Phase 1 | Complete |
| AWARE-01 | Phase 3 | Pending |
| AWARE-02 | Phase 3 | Pending |
| GIT-01 | Phase 1 | Complete |
| GIT-02 | Phase 1 | Complete |
| NIX-01 | Phase 1 | Complete |
| NIX-02 | Phase 1 | Complete |
| NIX-03 | Phase 1 | Complete |
**Coverage:**
- v1 requirements: 31 total
- Mapped to phases: 31
- Unmapped: 0
---
*Requirements defined: 2026-04-09*
*Last updated: 2026-04-09 after roadmap creation*

View file

@ -1,73 +0,0 @@
# Roadmap: claudebox
## Overview
claudebox is a Nix-packaged bwrap sandbox wrapper for Claude Code. The roadmap moves from a working sandbox (Phase 1) through CLI polish (Phase 2) to sandbox-aware prompting (Phase 3). Phase 1 is the bulk of the work -- once Claude runs inside bwrap with env isolation, filesystem isolation, and tool provisioning, the remaining phases add UX and developer experience improvements.
## Phases
**Phase Numbering:**
- Integer phases (1, 2, 3): Planned milestone work
- Decimal phases (2.1, 2.2): Urgent insertions (marked with INSERTED)
Decimal phases appear between their surrounding integers in numeric order.
- [ ] **Phase 1: Minimal Viable Sandbox** - Working claudebox command that launches Claude in bwrap with full isolation and tool provisioning
- [ ] **Phase 2: Env Audit and CLI Polish** - Pre-launch env review, --yes, --dry-run, and --check flags
- [ ] **Phase 3: Sandbox-Aware Prompting** - Injected CLAUDE.md so Claude knows its capabilities and constraints
## Phase Details
### Phase 1: Minimal Viable Sandbox
**Goal**: User can run `claudebox` in any project directory and get a fully functional Claude Code session with secrets invisible
**Depends on**: Nothing (first phase)
**Requirements**: SAND-01, SAND-02, SAND-03, SAND-04, SAND-05, SAND-06, SAND-07, SAND-08, SAND-09, SAND-10, SAND-11, SAND-12, SAND-13, SAND-14, SAND-15, TOOL-01, TOOL-02, TOOL-03, GIT-01, GIT-02, NIX-01, NIX-02, NIX-03, UX-06
**Success Criteria** (what must be TRUE):
1. Running `nix run` or `nix profile install` produces a working `claudebox` command
2. `claudebox` launches Claude Code inside bwrap; `env` inside the sandbox shows only allowlisted variables (no SSH_AUTH_SOCK, AWS_PROFILE, etc.)
3. Secret paths (~/.ssh, ~/.gnupg, ~/.aws, ~/.config/gcloud, age keys, /var/lib/tailscale) are not visible inside the sandbox
4. Claude can run `curl https://example.com`, `git status`, `, jq --help` (comma), and `nix shell nixpkgs#python3 -c python3 --version` inside the sandbox
5. Ctrl+C terminates the session cleanly; exit code from Claude passes through to the caller
**Plans:** 2 plans
Plans:
- [x] 01-01-PLAN.md -- Create flake.nix and claudebox.sh with complete bwrap sandbox
- [x] 01-02-PLAN.md -- Build verification and manual sandbox smoke test
### Phase 2: Env Audit and CLI Polish
**Goal**: User can review exactly what enters the sandbox before launch, and has diagnostic tools for troubleshooting
**Depends on**: Phase 1
**Requirements**: UX-01, UX-02, UX-03, UX-04, UX-05
**Success Criteria** (what must be TRUE):
1. Running `claudebox` without `--yes` prints all env vars being passed into the sandbox and prompts for confirmation before proceeding
2. Running `claudebox --yes` or `claudebox -y` skips the env audit and launches immediately
3. Running `claudebox --dry-run` prints the full bwrap command without executing it
4. Running `claudebox --check` reports whether bwrap exists, required Nix packages are available, and ~/.claudebox exists
**Plans:** 2 plans
Plans:
- [x] 02-01-PLAN.md -- Refactor flag parsing, add --check and --dry-run modes
- [x] 02-02-PLAN.md -- Env audit display with grouping, masking, and confirmation prompt
### Phase 3: Sandbox-Aware Prompting
**Goal**: Claude inside the sandbox knows it is sandboxed, how to install tools, and what is unavailable
**Depends on**: Phase 1
**Requirements**: AWARE-01, AWARE-02
**Success Criteria** (what must be TRUE):
1. First run of `claudebox` creates a default CLAUDE.md in ~/.claudebox/ if none exists
2. The injected CLAUDE.md tells Claude it is in a bwrap sandbox, how to use comma (`, <tool>`) and `nix shell` for tool installation, and that SSH/GPG/cloud credentials are unavailable
**Plans:** 1 plan
Plans:
- [x] 03-01-PLAN.md -- Add SANDBOX.md generation and CLAUDE.md import management
## Progress
**Execution Order:**
Phases execute in numeric order: 1 -> 2 -> 3
| Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------|
| 1. Minimal Viable Sandbox | 2/2 | Complete | - |
| 2. Env Audit and CLI Polish | 0/2 | Planned | - |
| 3. Sandbox-Aware Prompting | 0/1 | Not started | - |

View file

@ -1,211 +0,0 @@
---
plan: 1
phase: 04
wave: 1
depends_on: []
files_modified:
- claudebox.sh
requirements: [AUTH-01, AUTH-02]
autonomous: true
must_haves:
truths:
- "claudebox launches successfully when ~/.claude/.credentials.json exists on the host"
- "OAuth token refresh can write back to the credentials file (read-write mount)"
- "claudebox launches without error when ~/.claude/.credentials.json does not exist"
- "ANTHROPIC_API_KEY is passed into the sandbox when set on the host"
- "The audit screen shows all env vars in a single unified list with [~]/[>]/[+] prefixes"
- "The audit screen shows a Mounts section and a Network section after the env list"
- "The --dry-run output mirrors the credential bind when the file exists"
artifacts:
- path: "claudebox.sh"
provides: "Credential mount logic, updated print_audit, updated --dry-run block"
contains: "credentials.json"
key_links:
- from: "claudebox.sh credential bind block"
to: "exec bwrap call (line ~327)"
via: "--bind $HOME/.claude/.credentials.json $HOME/.claude/.credentials.json"
- from: "claudebox.sh credential bind block"
to: "--dry-run display block (line ~292)"
via: "mirrored echo of credential bind"
- from: "print_audit"
to: "AUDIT_SANDBOX_KEYS / AUDIT_HOST_KEYS / AUDIT_EXTRA_KEYS arrays"
via: "[~]/[>]/[+] prefix loop"
---
# Plan 1: Credential Mount + Audit Redesign
## Goal
Mount `~/.claude/.credentials.json` read-write into the sandbox and rewrite the pre-launch audit to a unified env/mounts/network display.
## Context
Claude Code stores OAuth tokens in `~/.claude/.credentials.json`. Without mounting this file, users must re-authenticate every time. The mount must be read-write because OAuth refresh writes new tokens back to the file. When `ANTHROPIC_API_KEY` is set on the host, it is already passed through (line 204 of claudebox.sh) — no changes needed for that path.
The current audit shows three separate sections (Sandbox-generated / Host allowlisted / Extra). The redesign collapses these into one list with `[~]`/`[>]`/`[+]` prefixes (accessible without color), adds a Mounts section showing active filesystem binds, and adds a Network section (placeholder showing "full (host network)" until Phase 6).
<threat_model>
## Threat Model
**Assets:**
- `~/.claude/.credentials.json` — OAuth tokens granting access to the user's Claude subscription
- `ANTHROPIC_API_KEY` — API key granting billable Claude API access
**Threat actors:**
- Malicious code running inside the sandbox that could exfiltrate credentials via network or file writes
- A compromised Claude Code session writing a crafted `.credentials.json` back to the host
**Attack vectors:**
- Sandbox code reads `.credentials.json` and exfiltrates tokens over the network (full host network in Phase 4)
- Sandbox code overwrites `.credentials.json` with attacker-controlled content, poisoning the host credential store
- Path traversal: if the bind target were `$HOME/.claude/` (directory), sandbox code could write new files into `~/.claude` on the host
**Mitigations:**
- Bind is file-scoped (`--bind .credentials.json .credentials.json`), NOT directory-scoped — sandbox cannot create new files in the host `~/.claude` directory via this mount
- Read-write is required for legitimate OAuth refresh; this is an accepted trade-off documented in D-02
- `ANTHROPIC_API_KEY` is masked in audit output by `mask_value` (already implemented)
- Phase 6 network isolation will allow users to restrict exfiltration surface; Phase 4 accepts full-network risk consistent with existing behavior
**Residual risk:**
- A malicious prompt could cause Claude to read and exfiltrate the OAuth token over the network. Accepted: this is equivalent risk to the API key already in the sandbox. Phase 6 network tiers reduce this surface.
- OAuth token can be overwritten by sandbox code. Accepted: the file must be writable for refresh to work (D-02). The host file is the user's own credential file.
</threat_model>
## Tasks
<task id="4.1.1" name="Add credential file mount">
<read_first>
- claudebox.sh lines 284350 — the --dry-run display block and exec bwrap call; credential bind goes in both places
- claudebox.sh lines 98103 — CWD and ~/.claudebox setup pattern to follow
</read_first>
After the `mkdir -p "$HOME/.claudebox"` call and before the `print_audit` invocation, add a block that:
1. Sets `CREDS_FILE="$HOME/.claude/.credentials.json"`
2. Checks `if [[ -f "$CREDS_FILE" ]]; then` sets `CREDS_MOUNT=true`; else `CREDS_MOUNT=false`
3. Adds `--bind "$CREDS_FILE" "$HOME/.claude/.credentials.json"` to the exec bwrap call (after the `--symlink "$HOME/.claudebox" "$HOME/.claude"` line) when `CREDS_MOUNT=true`
4. Mirrors the same conditional bind in the `--dry-run` display block (after the symlink echo)
**Bwrap mount ordering note:** The credential file bind must come AFTER the `--symlink "$HOME/.claudebox" "$HOME/.claude"` line. bwrap processes mounts in order; the symlink creates `~/.claude → ~/.claudebox`, and the subsequent file bind layers `.credentials.json` on top of it inside the sandbox. This matches the established pattern from the code context.
Do NOT use `--ro-bind` — the mount must be read-write for OAuth token refresh (D-02).
Do NOT add any warning or error output when the file is absent — skip silently (D-03).
Always add the bind when the file exists, even if `ANTHROPIC_API_KEY` is also set (D-04).
<acceptance_criteria>
- [ ] `grep -n 'CREDS_FILE' claudebox.sh` shows the variable declared as `CREDS_FILE="$HOME/.claude/.credentials.json"`
- [ ] `grep -n 'CREDS_MOUNT' claudebox.sh` shows both the conditional set and usage in bwrap exec
- [ ] `grep -n 'credentials.json' claudebox.sh` shows the bind appears in BOTH the exec bwrap block AND the --dry-run block
- [ ] The bind uses `--bind` (not `--ro-bind`) — `grep 'ro-bind.*credentials' claudebox.sh` returns nothing
- [ ] `grep -A2 'CREDS_MOUNT' claudebox.sh` shows the condition is `[[ -f "$CREDS_FILE" ]]` — file existence check, not variable existence
- [ ] No `echo` or `>&2` output is produced when the credentials file is absent — the skip is completely silent
</acceptance_criteria>
</task>
<task id="4.1.2" name="Rewrite print_audit to unified list with Mounts and Network sections">
<read_first>
- claudebox.sh lines 171263 — the audit data structures (AUDIT_SANDBOX_KEYS, AUDIT_HOST_KEYS, AUDIT_EXTRA_KEYS arrays and associated maps) and the current print_audit function; the rewrite must consume the same data structures
- claudebox.sh lines 6576 — ANSI color variable definitions (BOLD, RESET, DIM, CYAN, YELLOW, GREEN, RED already defined)
- claudebox.sh lines 7891 — mask_value function signature and behavior; must be called for all env var values in the new display
</read_first>
Rewrite the `print_audit` function (lines 227263) to produce the new unified format per D-06, D-07, D-08, D-09, D-10.
**New print_audit structure:**
```
=== Sandbox Environment ===
[~] HOME=/home/user ← green [~] prefix, sandbox-generated
[~] USER=user
[~] PATH= ← PATH gets special multiline treatment (same as before)
/nix/store/.../bin
[>] TERM=xterm-256color ← yellow [>] prefix, host allowlisted (only if set)
[>] ANTHROPIC_API_KEY=abc123...xyz ← masked by mask_value
[+] MY_VAR=value ← cyan [+] prefix, extra via CLAUDEBOX_EXTRA_ENV
Mounts:
CWD /path/to/project (read-write)
~/.claude ~/.claudebox (read-write)
credentials ~/.claude/.credentials.json (read-write) ← only if CREDS_MOUNT=true
Network:
full (host network)
```
**Implementation rules:**
1. Single loop over all three key arrays in order: sandbox keys first ([~]), then host keys ([>]), then extra keys ([+])
2. Each line: ` {COLOR}{PREFIX}{RESET} {var}={masked_value}` — the prefix `[~]`, `[>]`, `[+]` is printed in color AND must be readable without color (accessibility per D-07)
3. PATH gets the same multiline indented treatment as the current implementation — show label then each path entry indented
4. After the env list, print a blank line then `${BOLD}Mounts:${RESET}` section:
- Always show: ` CWD $CWD (read-write)`
- Always show: ` ~/.claude $HOME/.claudebox (read-write)`
- Conditionally show (when `CREDS_MOUNT=true`): ` credentials $HOME/.claude/.credentials.json (read-write)`
- Use consistent column alignment — pad the label to a fixed width (e.g., 12 chars) so values align
5. After Mounts, print a blank line then `${BOLD}Network:${RESET}` section:
- Always show: ` full (host network)`
- This is a Phase 4 placeholder — no dynamic logic
6. All output to stderr (`>&2`)
7. Unset allowlisted host vars are silently omitted from the display (discretion decision from CONTEXT.md)
The existing `AUDIT_SANDBOX_KEYS`, `AUDIT_SANDBOX_VALS`, `AUDIT_HOST_KEYS`, `AUDIT_HOST_VALS`, `AUDIT_EXTRA_KEYS`, `AUDIT_EXTRA_VALS` arrays must NOT be restructured — only `print_audit` changes.
<acceptance_criteria>
- [ ] `grep '\[~\]' claudebox.sh` matches inside print_audit, used for sandbox-generated vars
- [ ] `grep '\[>\]' claudebox.sh` matches inside print_audit, used for host-allowlisted vars
- [ ] `grep '\[+\]' claudebox.sh` matches inside print_audit, used for extra vars
- [ ] `grep 'Mounts:' claudebox.sh` matches inside print_audit
- [ ] `grep 'Network:' claudebox.sh` matches inside print_audit
- [ ] `grep 'full (host network)' claudebox.sh` matches inside print_audit
- [ ] `grep 'CREDS_MOUNT' claudebox.sh` shows the Mounts section conditionally includes the credentials line
- [ ] `grep 'mask_value' claudebox.sh` shows mask_value is called for every env var value in the new loops (not just some vars)
- [ ] `grep '>&2' claudebox.sh` — all print_audit echo/printf calls redirect to stderr
- [ ] `bash -n claudebox.sh` exits 0 (no syntax errors)
</acceptance_criteria>
</task>
## Verification
```bash
# Syntax check
bash -n claudebox.sh
# Verify credential bind present in both exec and dry-run blocks
grep -n 'credentials.json' claudebox.sh
# Verify read-write (not ro-bind) for credentials
grep 'ro-bind.*credentials' claudebox.sh # must return nothing
# Verify prefix scheme present
grep -E '\[~\]|\[>\]|\[+\]' claudebox.sh
# Verify Mounts and Network sections
grep -E 'Mounts:|Network:|full .host network.' claudebox.sh
# Functional test: dry-run with credentials file present
# (Create a test file, run dry-run, confirm bind appears in output)
touch /tmp/test_credentials.json
HOME_ORIG="$HOME"
# If test harness available: claudebox --dry-run should show --bind ...credentials.json
```
## Must-Haves
- `~/.claude/.credentials.json` is mounted read-write into the sandbox when it exists on the host
- When the file does not exist, claudebox starts without any error or warning
- The exec bwrap call and the --dry-run display block are in sync (credential bind appears in both or neither)
- The audit screen shows `[~]`, `[>]`, and `[+]` prefixes that distinguish env var categories without relying on color alone
- The audit screen shows a Mounts section listing CWD, ~/.claude, and (conditionally) the credentials file
- The audit screen shows a Network section with "full (host network)" as a Phase 6 placeholder
- `bash -n claudebox.sh` passes (no syntax errors introduced)
## Output
After completion, create `.planning/phases/04-auth-passthrough/04-01-SUMMARY.md`

View file

@ -1,114 +0,0 @@
# Phase 4: Auth Passthrough - Context
**Gathered:** 2026-04-10
**Status:** Ready for planning
<domain>
## Phase Boundary
Mount host Claude credentials read-write into the sandbox so subscription and API key access work inside. Includes a redesign of the pre-launch env audit to show env vars, mounts, and network as three unified sections with color + prefix-based categorization.
This phase does NOT scope per-project instance isolation (Phase 5) or any other `~/.claude` state (history, todos, settings).
</domain>
<decisions>
## Implementation Decisions
### Credential mount
- **D-01:** Mount only `~/.claude/.credentials.json` from the host — no other auth files. Other `~/.claude` contents (history, todos, settings) belong to Phase 5.
- **D-02:** Mount read-write (already decided in v2.0 planning): OAuth token refresh writes back to `.credentials.json`; ro-bind causes silent EACCES.
- **D-03:** If `~/.claude/.credentials.json` does not exist on the host, skip the mount silently. No warning, no error — Claude will prompt the user to log in inside the sandbox if needed.
- **D-04:** Always mount credentials if the file exists, even when `ANTHROPIC_API_KEY` is also set. Claude Code handles precedence internally.
### ANTHROPIC_API_KEY
- **D-05:** `ANTHROPIC_API_KEY` is already in the host allowlist (line 204 of claudebox.sh). No additional changes needed — AUTH-02 is satisfied by existing code.
### Audit screen redesign
- **D-06:** Replace the current three-section audit (Sandbox-generated / Host allowlisted / Extra) with a single unified list of all env vars, each prefixed to indicate category. Add two new sections after the env list: **Mounts** and **Network**.
- **D-07:** Prefix scheme (accessible without color — works for colorblind/screen-reader users):
- `[~]` — Sandbox-generated (auto-set, never from host)
- `[>]` — Host allowlisted (passed from host if set)
- `[+]` — User-configured extras (via CLAUDEBOX_EXTRA_ENV or future profile)
- **D-08:** Color scheme mapped to prefixes:
- `[~]` green
- `[>]` yellow
- `[+]` cyan
- **D-09:** Mounts section lists the active filesystem binds visible to the user — at minimum: CWD (read-write), `~/.claudebox``~/.claude`, and credential file if mounted. Format TBD by planner.
- **D-10:** Network section: for now (Phase 4, before Phase 6 adds tiers), show the current default — "full (host network)". This section is a placeholder that Phase 6 will fill in.
### Claude's Discretion
- Exact formatting of the Mounts and Network sections (spacing, alignment, separators).
- Whether to show a header row for each section or just a bold label.
- Whether unset allowlisted vars are shown as `[>] VAR (not set)` or silently omitted (recommend omitting — less noise).
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Requirements
- `.planning/REQUIREMENTS.md` §Authentication (AUTH-01, AUTH-02) — credential mount spec and API key precedence rule
### Existing implementation
- `claudebox.sh` lines 180263 — current ENV_ARGS construction and audit display (`print_audit` function); this is the primary code that changes
- `claudebox.sh` lines 326350 — bwrap exec with current mount list; credential bind goes here
No external specs — requirements fully captured in decisions above and in REQUIREMENTS.md.
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- `ENV_ARGS` array (claudebox.sh:181) — bwrap `--setenv` args built up before exec; credential mount args follow the same pattern as a new `MOUNT_ARGS` array or inline additions to the exec call.
- `print_audit` function (claudebox.sh:227263) — current audit display; will be rewritten per D-06.
- `mask_value` function (claudebox.sh:8695) — masks sensitive var values; remains unchanged.
- ANSI color vars (claudebox.sh:6679) — `GREEN`, `YELLOW`, `CYAN`, `BOLD`, `RESET` already defined; `CYAN` is not yet used in the audit but is available.
### Established Patterns
- bwrap mount ordering matters: `~/.claudebox` bind first, then credential file bind on top of it — bwrap layers binds in order.
- Conditional passthrough pattern: `if [[ -v "$var" ]]; then ... fi` (line 205) — same guard pattern applies for the credential file existence check: `if [[ -f "$HOME/.claude/.credentials.json" ]]; then`.
- All user-visible output goes to stderr (`>&2`), never stdout.
### Integration Points
- Credential bind must be added to both the live `exec bwrap` call (line 327) AND the `--dry-run` display block (line 292) to keep them in sync.
- `print_audit` is called before the bwrap exec and is the only place the audit display is generated.
- `ANTHROPIC_API_KEY` already wired (line 204) — no changes needed there.
</code_context>
<specifics>
## Specific Ideas
- Audit format: single unified env var list with `[~]`/`[>]`/`[+]` prefixes, followed by a Mounts section and a Network section. The prefix alone (no color) must be sufficient to distinguish categories — for accessibility.
- Network section in Phase 4 is a placeholder showing "full (host network)" — Phase 6 will make it dynamic.
- The credential bind must be mirrored in `--dry-run` output.
</specifics>
<deferred>
## Deferred Ideas
- Per-project instance isolation (`~/.claude` state scoped per project) — Phase 5
- Dynamic network tier display in audit — Phase 6 (placeholder added in Phase 4)
- Profile-driven extra mounts shown in audit — Phase 7
</deferred>
---
*Phase: 04-auth-passthrough*
*Context gathered: 2026-04-10*

View file

@ -1,78 +0,0 @@
# Phase 4: Auth Passthrough - Discussion Log
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
> Decisions captured in CONTEXT.md — this log preserves the Q&A.
**Date:** 2026-04-10
**Phase:** 04-auth-passthrough
**Mode:** discuss
**Areas discussed:** Missing credentials fallback, Audit display, API key + OAuth coexistence, Which credential files
---
## Areas Discussed
### Missing credentials fallback
| Question | Options | Selected |
|----------|---------|----------|
| What should happen if ~/.claude/.credentials.json doesn't exist on the host? | Skip silently / Warn in audit / Hard error | Skip silently |
**Rationale:** No credentials = no subscription access inside sandbox. Claude will prompt to log in. No need to warn or error.
---
### Audit display
| Question | Options | Selected |
|----------|---------|----------|
| Should the credential mount appear in the pre-launch env audit? | Show in audit / Silent | Show in audit |
**User input:** Requested a broader audit redesign — three sections (env, mounts, network), unified env var list with color + prefix-based categorization. Not just a minimal mount line.
| Question | Options | Selected |
|----------|---------|----------|
| Fold audit redesign into Phase 4 or defer? | Fold into Phase 4 / Phase 4 shows mount only | Fold into Phase 4 |
**Rationale:** Phase 4 already touches the audit; doing the full redesign avoids a half-done audit screen.
| Question | Options | Selected |
|----------|---------|----------|
| Color scheme for unified env var list? | Green/Yellow/Cyan / Green/Yellow/Magenta / You decide | Green/Yellow/Cyan + accessibility prefixes |
**User input:** Requested accessibility prefixes alongside colors: `[~]` sandbox-generated, `[>]` host allowlisted, `[+]` user-configured.
| Question | Options | Selected |
|----------|---------|----------|
| Prefix characters? | [S]/[H]/[U] / [~]/[>]/[+] / You decide | [~] / [>] / [+] |
---
### API key + OAuth coexistence
| Question | Options | Selected |
|----------|---------|----------|
| When ANTHROPIC_API_KEY is set, still mount credentials? | Always mount if credentials exist / Skip mount when API key is set | Always mount if credentials exist |
**Rationale:** Simpler logic. Claude Code handles precedence internally.
---
### Which credential files
| Question | Options | Selected |
|----------|---------|----------|
| Which auth files to mount? | .credentials.json only / Entire ~/.claude / credentials.json + .oauth-token | .credentials.json only |
**Rationale:** Minimal surface. Other ~/.claude contents belong to Phase 5 (instance isolation).
---
## Corrections Made
None — all selections were user-confirmed choices.
## Scope Notes
- Audit redesign (three sections, color + prefix) folded into Phase 4 scope at user request.
- Network section in audit is a placeholder in Phase 4 — Phase 6 makes it dynamic.

View file

@ -1,443 +1,384 @@
# Architecture Research
# Architecture Patterns
**Domain:** bwrap sandbox wrapper — network isolation, profiles, auth passthrough
**Researched:** 2026-04-10
**Confidence:** HIGH (existing codebase read directly; new feature patterns verified against nixpkgs, official Claude Code docs, and upstream slirp4netns/pasta documentation)
**Domain:** Nix bubblewrap sandbox wrapper
**Researched:** 2026-04-09
## Standard Architecture
## Recommended Architecture
### System Overview — v2.0 with new features
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 invocation
|
v
┌───────────────────────────────────────────────────────────────────┐
│ claudebox.sh (writeShellApplication, Nix store) │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌──────────────────────────┐ │
│ │ Arg parse │ │Profile load │ │ Instance dir resolution │ │
│ │ --profile │ │ ~/.claudebox│ │ ~/.claudebox/instances/ │ │
│ │ --network │ │ /profiles/ │ │ <cwd-hash>/.claude/ │ │
│ └──────┬──────┘ └──────┬──────┘ └────────────┬─────────────┘ │
│ └────────────────┴──────────────┬─────────┘ │
│ v │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Env builder │ │
│ │ - sandbox-generated vars (HOME, USER, PATH, SHELL ...) │ │
│ │ - host allowlist (TERM, LANG, ANTHROPIC_API_KEY ...) │ │
│ │ - profile env vars injected here │ │
│ │ - CLAUDEBOX_EXTRA_ENV escape hatch │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ | │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Mount builder │ │
│ │ - core mounts (nix store, /etc/*, /proc, /dev, tmpfs) │ │
│ │ - auth passthrough (ro-bind ~/.claude/.credentials.json) │ │
│ │ - instance dir (bind ~/.claudebox/instances/<hash>) │ │
│ │ - profile extra mounts appended here │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ | │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Network tier decision │ │
│ │ full → no --unshare-net, share host network │ │
│ │ inet → --unshare-net + pasta sidecar (internet, no LAN) │ │
│ │ none → --unshare-net, no sidecar (offline) │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ | │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Pre-launch: env audit display + confirmation │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ | │
│ full/none: exec bwrap ... | inet: bwrap ... &
│ | pasta $BWRAP_PID │
│ | wait $BWRAP_PID │
└───────────────────────────────────────────────────────────────────┘
|
┌──────────────────────────┴───────────────────────┐
│ bwrap sandbox │
│ ~/.claudebox → ~/.claudebox/instances/<hash>
│ ~/.claude symlink → ~/.claudebox │
│ ~/.claude/.credentials.json (read-only) │
│ CWD (read-write) │
│ profile devshell PATH prepended to SANDBOX_PATH │
│ network: full | inet (via pasta TAP) | none │
└──────────────────────────────────────────────────┘
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 Responsibilities
### Component Boundaries
| Component | Responsibility | Status (v2.0) |
|-----------|---------------|----------------|
| `flake.nix` | Nix derivation, `runtimeInputs`, `SANDBOX_PATH` injection | Modified — add `pkgs.passt` |
| `claudebox.sh` — arg parse | CLI flags | Modified — add `--profile`, `--network` |
| `claudebox.sh` — profile loader | Read `~/.claudebox/profiles/<name>.json` | New function |
| `claudebox.sh` — instance resolver | Hash CWD → `~/.claudebox/instances/<hash>/`, create if missing | New function |
| `claudebox.sh` — env builder | Accumulate `--setenv` args | Modified — add profile env injection |
| `claudebox.sh` — mount builder | Accumulate bwrap mount args | Modified — auth ro-bind, instance bind, profile mounts |
| `claudebox.sh` — network setup | Decide `--unshare-net` + pasta sidecar | New function |
| `claudebox.sh` — package injector | Resolve profile packages via `nix build`, prepend to `SANDBOX_PATH` | New function |
| `claudebox.sh` — exec block | `exec bwrap` or `bwrap & wait` depending on tier | Modified — split by network tier |
| Profile store | `~/.claudebox/profiles/<name>.json` | New on-disk schema |
| Instance store | `~/.claudebox/instances/<cwd-hash>/` | New on-disk layout |
| Auth source | `~/.claude/.credentials.json` (host, read-only mount) | New mount only |
| 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 ...` |
## Recommended Project Structure
### The bwrap Invocation Structure
```
claudebox/
├── flake.nix # add pkgs.passt to runtimeDeps
├── claudebox.sh # main script — extended with new sections
└── profiles/ # optional bundled example profiles
└── example.json
~/.claudebox/ # runtime state on host
├── CLAUDE.md # existing
├── SANDBOX.md # existing, overwritten each launch
├── profiles/ # user-defined profiles
│ ├── default.json
│ ├── work.json
│ └── offline.json
└── instances/ # per-project conversation history
├── a3f7b2c1d9e1f430/ # sha256 of /home/user/projects/foo (16 hex chars)
│ └── .claude/ # conversations, settings scoped to this project
│ ├── settings.local.json
│ └── projects/
└── d9e1f430a3f7b2c1/
└── .claude/
```
### Structure Rationale
- **`profiles/`:** Flat JSON keeps profiles shell-readable with `jq` without a Nix dependency at profile-load time. A `.nix` variant is possible for devshell injection but adds complexity; JSON is preferred for v2.0.
- **`instances/<hash>/`:** Hashing the absolute CWD path gives stable, collision-resistant names without encoding slashes. `sha256sum` truncated to 16 hex chars is sufficient — 2^64 space, no practical collision risk.
- **`instances/<hash>/.claude/`:** Claude Code reads `~/.claude/` for all state (conversation history in `projects/`, settings in `settings.local.json`). Mapping this per-project gives isolated history automatically without any changes to Claude Code itself.
## Architectural Patterns
### Pattern 1: Instance Dir via CWD Hash
**What:** Derive a stable instance directory from `sha256sum` of the absolute CWD. Create on first use. Bind-mount it as `~/.claudebox` inside the sandbox (preserving the existing `~/.claudebox → ~/.claude` symlink that already exists in the script).
**When to use:** Every launch. Replaces the current single `~/.claudebox` bind-mount target.
**Trade-offs:** One `sha256sum` call and one `mkdir -p` per launch. Negligible latency. History is siloed per CWD, which is the desired behavior.
**Integration point in existing code (line 345-346 of claudebox.sh):**
bubblewrap flags are order-sensitive for mounts (later mounts overlay earlier ones) but not for namespace flags. The canonical structure:
```bash
# EXISTING (line ~345 in current claudebox.sh):
# --bind "$HOME/.claudebox" "$HOME/.claudebox" \
# --symlink "$HOME/.claudebox" "$HOME/.claude" \
# REPLACE WITH:
INSTANCE_HASH=$(printf '%s' "$CWD" | sha256sum | cut -c1-16)
INSTANCE_DIR="$HOME/.claudebox/instances/$INSTANCE_HASH"
mkdir -p "$INSTANCE_DIR/.claude"
# In bwrap call:
# --bind "$INSTANCE_DIR" "$HOME/.claudebox" \
# --symlink "$HOME/.claudebox" "$HOME/.claude" \ (unchanged)
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 "$@"
```
The `~/.claudebox → ~/.claude` symlink line is unchanged. The SANDBOX.md write and CLAUDE.md prepend logic (lines 107-154) currently targets `$HOME/.claudebox/` — these should continue targeting `$HOME/.claudebox/` on the host (the shared root), not the instance dir. SANDBOX.md is global to all instances; CLAUDE.md is too. Only conversation history (inside `.claude/`) is per-instance.
**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
### Pattern 2: Auth Passthrough as Read-Only Mount
### How /nix/store Works Inside bwrap
**What:** Mount `~/.claude/.credentials.json` from the host into the sandbox at the same path, read-only. No other files from the host `~/.claude/` are mounted.
The Nix store is the critical piece. Here is how each layer works:
**When to use:** Always (unconditional in v2.0).
**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
**Why only `.credentials.json`:** Verified against official Claude Code docs — on Linux, credentials are stored at `~/.claude/.credentials.json` (mode 0600). The host `~/.claude/` otherwise contains all historical conversation state. We do not want that leaking into the sandbox; only the auth token is needed.
**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
**Path resolution inside sandbox:** The sandbox's `~/.claude/` is the instance dir (via `~/.claudebox → ~/.claude` symlink). The credential file must be bound inside this path:
**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
AUTH_CREDS="$HOME/.claude/.credentials.json"
if [[ -f "$AUTH_CREDS" ]]; then
# In bwrap mount args (after the instance dir bind and symlink):
MOUNT_ARGS+=(--ro-bind "$AUTH_CREDS" "$HOME/.claudebox/.claude/.credentials.json")
# Which resolves to ~/.claude/.credentials.json inside sandbox
fi
--ro-bind "$HOME/.cache/nix-index" "$sandbox_home/.cache/nix-index"
```
Wait — the symlink chain inside sandbox is: `~/.claudebox` is the instance dir, `~/.claude` symlinks to `~/.claudebox`. So `~/.claude/.credentials.json``~/.claudebox/.credentials.json``$INSTANCE_DIR/.credentials.json`. The ro-bind should target `$HOME/.claudebox/.credentials.json` inside bwrap's view of the namespace (which is the instance dir bind target). Simpler alternative: bind directly to the resolved path `$INSTANCE_DIR/.credentials.json` on the host before launching bwrap.
Or if using `nix-index-database` flake (common on NixOS), bind-mount its store path.
```bash
# Simpler: copy (or bind) creds into instance dir before launch
# ro-bind from host ~/.claude/.credentials.json
# to instance dir path that becomes ~/.claude/.credentials.json inside sandbox
if [[ -f "$HOME/.claude/.credentials.json" ]]; then
MOUNT_ARGS+=(--ro-bind "$HOME/.claude/.credentials.json" \
"$INSTANCE_DIR/.credentials.json")
fi
# Then inside sandbox: ~/.claude/.credentials.json exists read-only
### 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
```
**Credential precedence note (HIGH confidence — official docs):** Claude Code selects credentials in this order: cloud provider env vars → `ANTHROPIC_AUTH_TOKEN``ANTHROPIC_API_KEY``apiKeyHelper``CLAUDE_CODE_OAUTH_TOKEN` → OAuth file at `~/.claude/.credentials.json`. The existing host-allowlist for `ANTHROPIC_API_KEY` therefore continues to take precedence over the passthrough file if both are present.
### ~/.claudebox to ~/.claude Mapping
### Pattern 3: Tiered Network via Sidecar Process
The bind mount `--bind "$HOME/.claudebox" "$sandbox_home/.claude"` means:
**What:** Three network tiers controlled by `--network` flag (default: `full`):
- **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
- `full` — no `--unshare-net`; sandbox shares host network. Current behavior.
- `inet``--unshare-net` + pasta sidecar; internet access via userspace NAT, no LAN, no Tailscale, no host loopback services.
- `none``--unshare-net` alone; loopback only, fully offline.
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
**When to use:** Expose via `--network inet|none|full` CLI flag. Profile `network` field sets the default for that profile (overridable by CLI flag).
## Patterns to Follow
**pasta vs slirp4netns decision — use pasta:**
- Both `pkgs.passt` (version `2025_09_19.623dbf6`) and `pkgs.slirp4netns` (version `1.3.3`) are in nixpkgs and verified available.
- pasta is the current default in Podman 5 and RHEL 9.5; actively maintained.
- pasta avoids NAT (it forwards at Layer-4 via native sockets), giving better performance and simpler DNS.
- pasta supports `--no-map-gw` to prevent host gateway access and `--no-tcp-ports --no-udp-ports` to block incoming connections.
- slirp4netns has `--disable-host-loopback` but still routes to LAN by default.
- **Add `pkgs.passt` to `runtimeDeps` in `flake.nix`.** Keep `slirp4netns` as a named alternative but do not add it by default.
### Pattern 1: writeShellApplication with runtimeInputs
**Mechanism for `inet` tier — critical exec change:**
**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
The current script ends with `exec bwrap ...` (line 327, claudebox.sh). `exec` replaces the shell process, leaving no parent to launch a sidecar. For `inet` mode, the script must fork instead:
```bash
if [[ "$NETWORK_TIER" == "inet" ]]; then
BWRAP_PIDFILE=$(mktemp)
trap 'rm -f "$BWRAP_PIDFILE"' EXIT
bwrap \
--unshare-net \
--pidfile "$BWRAP_PIDFILE" \
"${ENV_ARGS[@]}" \
... (all other mount args) ... \
-- "${SANDBOX_CMD[@]}" &
BWRAP_PID=$!
# Wait for bwrap to write its PID (namespace is ready when pidfile is non-empty)
until [[ -s "$BWRAP_PIDFILE" ]]; do sleep 0.05; done
SANDBOX_PID=$(cat "$BWRAP_PIDFILE")
# Attach pasta to the network namespace
pasta --config-net --no-tcp-ports --no-udp-ports "$SANDBOX_PID" &
PASTA_PID=$!
wait "$BWRAP_PID"
STATUS=$?
kill "$PASTA_PID" 2>/dev/null || true
exit "$STATUS"
elif [[ "$NETWORK_TIER" == "none" ]]; then
exec bwrap --unshare-net "${ENV_ARGS[@]}" ... -- "${SANDBOX_CMD[@]}"
else # full (default)
exec bwrap "${ENV_ARGS[@]}" ... -- "${SANDBOX_CMD[@]}"
fi
```
**DNS for `inet` mode:** pasta provides DNS forwarding automatically through its virtual gateway. The existing `/etc/resolv.conf` ro-bind must be REMOVED for `inet` mode (the sandbox resolv.conf would point to host DNS on the host network, unreachable in the new namespace). pasta configures the TAP device with `10.0.2.100` and routes DNS queries through its gateway. A synthetic `/etc/resolv.conf` inside the sandbox pointing to `10.0.2.3` (pasta's default gateway DNS) should be provided via `--ro-bind` from a tempfile.
**`--pidfile` flag availability:** bwrap has supported `--pidfile` since version 0.4.0. The nixpkgs version is 0.9.x, so this is available. (HIGH confidence — verified against bwrap manpage.)
### Pattern 4: Profile Schema (JSON)
**What:** A flat JSON file at `~/.claudebox/profiles/<name>.json` describing what a named profile adds to the baseline sandbox.
**Schema:**
```json
{
"name": "work",
"network": "inet",
"env": {
"AWS_PROFILE": "work"
},
"extra_env_passthrough": ["MY_ORG_TOKEN", "VAULT_TOKEN"],
"mounts": [
{ "host": "~/.aws/credentials", "sandbox": "~/.aws/credentials", "mode": "ro" }
],
"packages": ["awscli2", "kubectl"]
```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;
}
```
**Loading with `jq` (already in `runtimeDeps`):**
**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
PROFILE_NAME="${CLAUDEBOX_PROFILE:-${1#--profile=}}" # or parsed via arg loop
PROFILE_FILE="$HOME/.claudebox/profiles/${PROFILE_NAME}.json"
if [[ -f "$PROFILE_FILE" ]]; then
PROFILE_NETWORK=$(jq -r '.network // empty' "$PROFILE_FILE")
mapfile -t PROFILE_PACKAGES < <(jq -r '.packages[]? // empty' "$PROFILE_FILE")
mapfile -t PROFILE_PASSTHROUGH < <(jq -r '.extra_env_passthrough[]? // empty' "$PROFILE_FILE")
# ... etc
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
```
**Profile env injection:** Profile `env` entries are treated like `CLAUDEBOX_EXTRA_ENV` values — they are sandbox-side constants (not host passthrough). The `extra_env_passthrough` list extends `HOST_ALLOWLIST` dynamically, allowing host env vars not in the hardcoded allowlist to pass through when a profile explicitly permits them. This preserves the allowlist security model: the profile, not the shell environment, decides what's allowed.
## Anti-Patterns to Avoid
### Pattern 5: Nix Package Injection
### 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.
**What:** Profile `packages` field lists nixpkgs attribute names. Resolved via `nix build --no-link --print-out-paths` before launch. Results prepended to `SANDBOX_PATH`.
### 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.
**Why `nix build` not `nix shell`:** `nix shell nixpkgs#pkg` spawns a child shell and cannot inject into the *parent's* `SANDBOX_PATH` variable. `nix build --print-out-paths` returns the store path, which can be prepended to the PATH string before it's passed to `--setenv`.
### 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.
```bash
EXTRA_BIN_PATHS=""
for pkg in "${PROFILE_PACKAGES[@]}"; do
pkg_out=$(nix build --no-link --print-out-paths "nixpkgs#${pkg}" 2>/dev/null) || {
echo "Warning: could not resolve profile package: $pkg" >&2
continue
}
EXTRA_BIN_PATHS="${pkg_out}/bin:${EXTRA_BIN_PATHS}"
done
SANDBOX_PATH="${EXTRA_BIN_PATHS}${SANDBOX_PATH}"
```
### 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`.
**Latency:** Zero if the store path is already built/cached. First-time use triggers a download; thereafter cached. The existing `nix` daemon socket bind ensures builds work inside the script (the script itself runs with full host access, not inside bwrap).
### 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.
**devshell injection (deferred):** Full `nix develop .#devShell` integration — where a project's own `flake.nix` devShell is evaluated and its environment injected — is significantly more complex (requires evaluating the flake, capturing `buildEnv`, etc.). Defer to a later milestone. The `packages` field covers the practical 80% use case.
## Component Build Order
## Data Flow
Build and test each component incrementally:
### Launch Flow (inet tier, with profile)
### 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`?
```
claudebox --profile work --network inet
|
[Arg parse] → PROFILE_NAME=work, NETWORK_TIER=inet
|
[Profile load] ~/.claudebox/profiles/work.json
→ PROFILE_NETWORK=inet, PROFILE_PACKAGES=[awscli2],
PROFILE_ENV={AWS_PROFILE=work}, PROFILE_MOUNTS=[~/.aws/credentials]
|
[Instance resolve]
INSTANCE_HASH = sha256("$CWD")[0:16]
mkdir -p ~/.claudebox/instances/$INSTANCE_HASH/.claude
|
[Package resolve]
nix build nixpkgs#awscli2 → /nix/store/xxx-awscli2
SANDBOX_PATH = /nix/store/xxx-awscli2/bin:$SANDBOX_PATH
|
[Env build]
base vars (HOME, USER, PATH=SANDBOX_PATH, SHELL, ...)
+ profile env (AWS_PROFILE=work)
+ host allowlist (TERM, LANG, ANTHROPIC_API_KEY if set, ...)
+ profile extra_env_passthrough (MY_ORG_TOKEN if set on host)
|
[Mount build]
core mounts (nix store, /etc/*, proc, dev, tmpfs ...)
+ instance dir bind (→ ~/.claudebox inside sandbox)
+ ~/.claudebox → ~/.claude symlink (unchanged)
+ auth creds ro-bind (~/.claude/.credentials.json)
+ profile mounts (~/.aws/credentials ro)
|
[DNS tempfile for inet mode]
echo "nameserver 10.0.2.3" > /tmp/resolv-$$.conf
(replaces the /etc/resolv.conf ro-bind)
|
[Env audit display + confirmation]
|
[Network: inet]
bwrap --unshare-net --pidfile $PIDFILE ... &
BWRAP_PID=$!
until [[ -s $PIDFILE ]]; do sleep 0.05; done
pasta --config-net --no-tcp-ports --no-udp-ports $(cat $PIDFILE) &
wait $BWRAP_PID → exit with its status
|
[Inside sandbox]
~/.claude/ = instance dir for this CWD (isolated history)
~/.claude/.credentials.json (ro, host OAuth token)
awscli2 in PATH
AWS_PROFILE=work in env
~/.aws/credentials accessible (ro)
internet via pasta, no LAN, no Tailscale, no host loopback
```
### 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?
### State Locations (host)
### 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?
| Purpose | Host Path | Sandbox Path | Mode |
|---------|-----------|-------------|------|
| Shared claudebox config | `~/.claudebox/` | n/a (host-side only) | rw |
| SANDBOX.md / CLAUDE.md | `~/.claudebox/SANDBOX.md` | n/a | rw |
| Per-project history | `~/.claudebox/instances/<hash>/` | `~/.claudebox/` | rw (bind) |
| Per-project .claude | `~/.claudebox/instances/<hash>/.claude/` | `~/.claude/` (via symlink) | rw |
| Host auth token | `~/.claude/.credentials.json` | `~/.claude/.credentials.json` | ro |
| Profile definitions | `~/.claudebox/profiles/<name>.json` | not mounted | — |
### 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?
## Anti-Patterns
### Stage 5: Nix packaging
- `writeShellApplication` wrapper
- Construct sandbox PATH via `lib.makeBinPath`
- Wire into flake
- Test: `nix run .#claudebox` works end-to-end
### Anti-Pattern 1: Mounting entire `~/.claude` for auth passthrough
### Stage 6: Polish
- Default CLAUDE.md with sandbox instructions
- Error messages for missing `~/.claudebox`
- XDG_RUNTIME_DIR handling
**What people do:** `--bind ~/.claude ~/.claude` to get credentials working.
## Scalability Considerations
**Why it's wrong:** Exposes all conversation history, settings, and any file Claude Code stores in `~/.claude`. Also breaks per-project isolation because the host `.claude` overwrites the instance dir.
Not applicable -- this is a single-user local tool. The architecture is a shell script wrapping a single process.
**Do this instead:** Mount only `~/.claude/.credentials.json` read-only. Keep the instance dir as the effective `~/.claude` target inside the sandbox.
## Key Technical Notes
### Anti-Pattern 2: `exec bwrap` for all network tiers
### /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.
**What people do:** Keep `exec bwrap` unconditionally for simplicity even when adding `inet` mode.
### --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.
**Why it's wrong:** `exec` replaces the shell process. There is no parent process left to launch the pasta sidecar after the bwrap namespace is created.
### 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).
**Do this instead:** Branch on `NETWORK_TIER`: use `exec bwrap` for `full` and `none` (no sidecar needed); use `bwrap ... &` + `wait` for `inet`.
### 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`
### Anti-Pattern 3: Hardcoding secrets in profile JSON
**What people do:** `"env": { "AWS_SECRET_ACCESS_KEY": "hardcoded-value" }` in a profile file.
**Why it's wrong:** Profile files at `~/.claudebox/profiles/` are plaintext. Hardcoded secrets become plaintext files on disk.
**Do this instead:** Use `extra_env_passthrough` to declare which host env vars are allowed into the sandbox. The value must be set in the host shell environment before running claudebox. The profile says "permit this variable" not "store this value."
### Anti-Pattern 4: Per-profile `nix develop` evaluation at launch
**What people do:** `nix develop .#profileEnv` to inject a full devshell environment at launch time.
**Why it's wrong:** `nix develop` evaluates a flake, which takes hundreds of milliseconds to seconds even with caching. It also requires the profile to point to a valid flake with a devShell output.
**Do this instead:** Profile `packages` field lists nixpkgs attribute names. Resolve via `nix build --no-link --print-out-paths`. Fast (cached after first use), no flake required. Full devshell support is a later milestone.
### Anti-Pattern 5: Removing `/etc/resolv.conf` mount for all tiers when adding inet support
**What people do:** Remove the resolv.conf bind globally when adding network isolation, breaking `full` mode DNS.
**Why it's wrong:** `full` and `none` tiers still rely on the `/etc/resolv.conf` bind-mount. Only `inet` mode needs it replaced with a pasta-managed DNS address.
**Do this instead:** Branch the resolv.conf mount on `NETWORK_TIER`: `full`/`none` keep the ro-bind from host; `inet` provides a tempfile pointing to pasta's gateway DNS (`nameserver 10.0.2.3`).
## Integration Points
### New vs Modified Components
| Component | Change Type | What Changes |
|-----------|------------|--------------|
| `flake.nix` | Modified | Add `pkgs.passt` to `runtimeDeps` (one line) |
| `claudebox.sh` — arg parse | Modified | Add `--profile NAME`, `--network full\|inet\|none` flags |
| `claudebox.sh` — exec block | Modified | Split `exec bwrap` into three branches by `NETWORK_TIER` |
| `claudebox.sh` — mount builder | Modified | Replace fixed `~/.claudebox` bind with instance dir bind; add auth creds ro-bind; add profile mounts |
| `claudebox.sh` — env builder | Modified | Add profile env injection after `CLAUDEBOX_EXTRA_ENV` block |
| `claudebox.sh` — profile loader | New function | `jq` parse of `~/.claudebox/profiles/<name>.json` |
| `claudebox.sh` — instance resolver | New function | `sha256sum` of CWD, `mkdir -p`, set `INSTANCE_DIR` |
| `claudebox.sh` — network setup | New function | Set `NETWORK_TIER` var; build `--unshare-net` flag; build pasta invocation for `inet` |
| `claudebox.sh` — package injector | New function | `nix build` loop, build `EXTRA_BIN_PATHS` |
| `~/.claudebox/profiles/` | New on-disk | User-created JSON profile files |
| `~/.claudebox/instances/` | New on-disk | Auto-created per-project dirs |
### Build Order (dependency-ordered)
1. **Auth passthrough** — Smallest change: add one conditional ro-bind for `~/.claude/.credentials.json`. Validates that the instance dir and auth file coexist correctly before instance dir is fully wired up. No new Nix deps. Unblocks all downstream features that require Claude to authenticate inside the sandbox.
2. **Per-project instance dirs** — Replace the single `~/.claudebox` bind-mount target with `~/.claudebox/instances/<hash>`. Depends on auth passthrough (step 1) having the credential bind path logic stable. No new Nix deps. After this, each project has isolated conversation history.
3. **Tiered network — `none` tier** — Add `--network` flag and implement `none` with `--unshare-net` alone. No new deps; bwrap is already present. Validates the exec→wait refactor structural change independently of pasta. Low risk: if offline mode is broken, `full` mode is unaffected.
4. **Tiered network — `inet` tier (pasta)** — Add `pkgs.passt` to flake, implement pasta sidecar for `inet`. Depends on step 3 having the exec→wait refactor validated. DNS tempfile handling belongs here. This is the highest-risk step (new external process, timing dependency on `--pidfile`).
5. **Named profiles (env + mounts + network tier)** — Add `--profile` flag, implement JSON profile loader with `jq`, inject env/mounts/network-tier from profile. Depends on steps 2 and 4 being stable (profiles can control all three axes). Pure shell logic, no new deps.
6. **Nix package injection** — Add `packages` field to profile schema, implement `nix build` loop. Last because it has latency risk and is independently testable without touching any other subsystem.
Recommend the tmpfs approach for maximum compatibility.
## Sources
- Existing codebase (`claudebox.sh` lines 1351, `flake.nix`): direct read — HIGH confidence
- Claude Code credential storage path on Linux (`~/.claude/.credentials.json`): official docs at `https://code.claude.com/docs/en/authentication` — HIGH confidence
- Claude Code credential precedence order: same official docs — HIGH confidence
- pasta/passt PID-based namespace attachment and `--config-net` flag: `https://passt.top/passt/about/` — MEDIUM confidence (pasta standalone bwrap integration not documented with exact flag combinations; verify `--no-tcp-ports` and DNS gateway address during implementation)
- slirp4netns `--configure --disable-host-loopback` pattern: `https://github.com/rootless-containers/slirp4netns` manpage — MEDIUM confidence
- nixpkgs package availability: verified locally via `nix eval``pkgs.passt` version `2025_09_19.623dbf6`, `pkgs.slirp4netns` version `1.3.3` — HIGH confidence
- bwrap `--pidfile` flag: bwrap 0.4.0+ manpage; current nixpkgs has 0.9.x — HIGH confidence
- pasta as Podman 5 / RHEL 9.5 default: WebSearch corroborated — MEDIUM confidence
---
*Architecture research for: claudebox v2.0 — network isolation, profiles, auth passthrough*
*Researched: 2026-04-10*
- 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)

View file

@ -1,197 +1,141 @@
# Feature Landscape
**Domain:** CLI sandbox wrapper (Nix/bubblewrap) for AI coding agents
**Researched:** 2026-04-10 (v2.0 milestone update — network isolation, profiles, auth passthrough)
**Confidence:** MEDIUM-HIGH (web-verified for auth files, slirp4netns mechanics, Claude Code storage layout; MEDIUM for devshell injection patterns)
**Researched:** 2026-04-09
**Confidence:** MEDIUM (based on training data for firejail, bubblejail, nixpak, bwrap; web verification unavailable)
---
## Reference Projects Surveyed
## v1.0 Features (Already Built — Reference Only)
| 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 |
Core sandbox, env allowlist, secret path hiding, minimal PATH, pre-launch audit, comma/nix tool provisioning, SANDBOX.md injection, --check/--dry-run/--shell modes. Do not re-implement.
## Table Stakes
---
## v2.0 New Features Under Research
### Table Stakes for v2.0
Features required for the milestone to be considered complete. Without these, the milestone goals are unmet.
Features users expect. Missing = the wrapper is broken or useless for its stated purpose (secrets isolation for AI agents).
| Feature | Why Expected | Complexity | Notes |
|---------|--------------|------------|-------|
| **Host auth passthrough** | Without it, every sandbox launch requires re-authenticating Claude Code — subscriptions and API keys are unusable | LOW | Mount `~/.claude/.credentials.json` read-only into `~/.claudebox/.credentials.json`. On Linux, Claude stores auth in `~/.claude/.credentials.json` (mode 0600). A read-only bind mount passes the file into the sandbox; Claude Code reads it on startup. No write access needed — token refresh writes back to the same file, so the mount must permit writes or use a copy-on-launch approach. |
| **Per-project instance isolation** | Running claudebox in two different projects should not share conversation history, todos, or project settings. Currently both projects write to the same `~/.claudebox/projects/` keyed by encoded path — works but muddles state if `~/.claudebox` is wiped or shared | LOW-MEDIUM | Claude Code stores sessions at `~/.claude/projects/{path-encoded}/`. A per-project instance dir `~/.claudebox/instances/<hash>/` contains its own `.claude/`; bind-mount that instead of `~/.claudebox` directly. Hash = SHA1 or MD5 of CWD absolute path. Instance dir auto-created on first launch. |
| **Named profiles** | Users need repeatable configurations for different types of work (e.g., "web" profile with cloud creds, "offline" profile with no network) | MEDIUM | Profile = named config at `~/.claudebox/profiles/<name>.conf` (or `.toml`). Contains: extra env vars to pass, extra mount paths (ro or rw), extra Nix packages, network tier. Activated via `--profile foo` or `CLAUDEBOX_PROFILE=foo`. Merges with defaults; profile settings additive on top of base config. |
| **Tiered network isolation** | "full" (current, no restriction), "internet-only" (blocks LAN/Tailscale/localhost), "none" (fully offline) serve distinct use cases: internet-only prevents Claude from reaching internal services; none is for sensitive codebases | HIGH | Three tiers: `full` = current behavior (host network shared). `none` = `--unshare-net` in bwrap, no connectivity. `internet-only` = `--unshare-net` + slirp4netns as separate process providing NAT'd internet via TAP device (`--disable-host-loopback` to block LAN). This is the complex tier. |
| **Nix devshell injection** | Projects with `flake.nix` defining a devShell should be able to make those packages available to Claude inside the sandbox | MEDIUM-HIGH | Two viable approaches: (a) resolve devShell's `buildInputs` to store paths and add them to `PATH`/mounts before launch; (b) run `nix print-dev-env` to get env vars and merge them. Approach (b) is cleaner — `nix print-dev-env .#devShell` emits JSON of env vars including `PATH` modifications; parse and forward into sandbox ENV_ARGS. |
| **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 for v2.0
## Differentiators
Features that go beyond minimum requirements and add meaningful value.
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 |
|---------|-------------------|------------|-------|
| **Profile inheritance / `extends`** | A "work" profile that extends "default" instead of rewriting it avoids duplication and drift | LOW | Simple key in profile config: `extends = "default"`. Load base profile first, merge overrides. Two levels sufficient; no deep inheritance chains needed. |
| **Network tier shown in env audit** | User sees "Network: internet-only" in pre-launch audit alongside env vars | LOW | Extend existing audit display to include active profile name and network tier. Auditability is the core UX principle of claudebox. |
| **Instance dir auto-cleanup** | Old instance dirs from deleted projects waste disk space over time | LOW | Add `claudebox --gc` that removes instance dirs for paths that no longer exist on disk. |
| **Profile `--list` and `--show`** | Discoverability of what profiles exist and what they contain | LOW | `claudebox --list-profiles` and `claudebox --show-profile foo`. Text output, no interactive UX needed. |
| **`nix print-dev-env` opt-in flag** | Devshell injection requires `nix eval` which can be slow; should be explicit opt-in not auto | LOW | `--devshell` flag or `devshell = true` in profile. Never run `nix print-dev-env` implicitly. |
| **Tool self-provisioning via comma** | Claude can `nix shell` or `, <tool>` 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 for v2.0
## Anti-Features
Features that seem natural extensions but should be explicitly avoided.
Features to deliberately NOT build. Either out of scope, security risks, or wrong layer.
| Anti-Feature | Why Avoid | What to Do Instead |
|--------------|-----------|-------------------|
| **Domain-level network allowlists** | Already declared out of scope in PROJECT.md. slirp4netns can be combined with iptables for domain filtering, but complexity is extreme and maintenance burden is high | Three tiers (full/internet-only/none) cover the actual use cases |
| **Writable auth file mounts** | Mounting `.credentials.json` read-write means Claude could overwrite or corrupt tokens | Mount read-only; Claude Code reads tokens, does not need to write them during a session (token refresh path is rare and can be handled by relaunching) |
| **Auto-detect and inject all devShell vars** | Running `nix print-dev-env` silently on every launch for any project with a `flake.nix` breaks the "no surprises" principle and is slow | Explicit `--devshell` flag only |
| **Profile passwords / encryption** | Profiles contain env var names, mount paths, package names — not secret values. API keys still come from host env via allowlist | Keep profile files plaintext; they don't need to store secrets |
| **Per-profile CLAUDE.md injection** | Profiles should not override sandbox system prompt — that breaks the security invariant that Claude always knows it's sandboxed | SANDBOX.md injection is unconditional; profiles can only add mounts/env/packages/network |
| **Multi-profile activation** | "Merge profile A and profile B" creates order-dependent ambiguity | One active profile per launch. Use `extends` for composition. |
| **Automatic profile detection from project** | Detecting profile from `.claudebox.toml` in project dir requires trusting project-local config, which is a sandbox escape vector | Profiles live in `~/.claudebox/profiles/` only. User chooses profile at launch. |
| **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 (v2.0)
## Feature Dependencies
```
Per-project instance isolation
└──requires──> Auth passthrough works correctly
(auth must be in instance dir or globally available)
Filesystem isolation ─┐
Environment allowlist ─┤
Secret path hiding ────┤
Minimal PATH ──────────┼── Core sandbox (all required together)
Nix store mount ───────┤
Working /tmp,/dev,/proc┘
Named profiles
└──provides──> Network tier configuration
└──provides──> Extra env vars (additive to base allowlist)
└──provides──> Extra mount paths
└──provides──> Nix package list for PATH injection
Nix store mount ──────── Tool self-provisioning (comma)
└── Project-local tool declarations (future)
Tiered network isolation
└──requires──> "none" tier: just --unshare-net (trivial, no deps)
└──requires──> "internet-only" tier: slirp4netns binary + process management
└──requires──> slirp4netns in runtimeInputs
└──requires──> pid-file or fd-passing to connect slirp4netns to bwrap net ns
Pre-launch env audit ─── Env var leak detection (enhancement of audit)
Nix devshell injection
└──requires──> `nix print-dev-env` available inside pre-launch environment
└──requires──> CWD has a flake.nix (validated before attempting)
└──enhances──> Named profiles (devshell can be a profile option)
Persistent config dir ── Injected system prompt (lives in config dir)
Profile inheritance (extends)
└──requires──> Named profiles exist first
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)
```
### Dependency Notes
## MVP Recommendation
- **Auth passthrough and instance isolation interact:** If each instance dir is fully self-contained, auth files must either be in the instance dir (copied/linked from `~/.claude`) or mounted globally. The cleanest approach: mount `~/.claude/.credentials.json` read-only directly into `~/.claudebox/.credentials.json` unconditionally, keep it separate from instance isolation (which only scopes project history and todos).
Prioritize for v1:
- **internet-only tier is a process management problem:** slirp4netns must be started before bwrap, connected to bwrap's network namespace via the `--netns-type=path` approach or by passing the network namespace fd. bwrap's `--userns-block-fd` / `--info-fd` / `--json-status-fd` flags provide synchronization primitives for this. This is the highest-complexity feature in the milestone.
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
- **Nix devshell injection does not require profile system:** It can be an independent `--devshell` flag. Profile system can optionally set `devshell = true`. The two features are parallel, not sequential.
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
## Implementation Complexity Ranking
### 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.
Ordered from lowest to highest complexity, within the v2.0 scope:
### 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.
| Feature | Complexity | Key Challenge |
|---------|------------|---------------|
| Auth passthrough | LOW | Identify correct files; decide ro vs rw; one-time mount addition |
| Network tier: none | LOW | Add `--unshare-net` to bwrap call when tier=none |
| Profile --list/--show | LOW | File glob + pretty-print |
| Network tier in audit display | LOW | Extend existing audit code |
| Per-project instance isolation | LOW-MEDIUM | Hash CWD, mkdir, adjust bind mount target |
| Named profiles (parsing + merge) | MEDIUM | Config file format, merge logic, validation |
| Nix devshell injection | MEDIUM | `nix print-dev-env` output parsing, env merge, PATH extension |
| Network tier: internet-only | HIGH | slirp4netns process lifecycle, bwrap netns synchronization, resolv.conf injection |
### 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.
---
## Auth Passthrough: Key Findings
Claude Code on Linux stores auth at `~/.claude/.credentials.json` (mode 0600). This file contains OAuth tokens. It is the only file strictly required for subscription/API key auth to work. (Source: inventivehq.com Claude Code configuration guide, verified 2026-04-10.)
Mount strategy:
- `--ro-bind "$HOME/.claude/.credentials.json" "$HOME/.claudebox/.credentials.json"` — read-only passthrough
- If file does not exist (user not authenticated), skip the mount silently; Claude Code will prompt for auth at first launch and write to the instance dir
- `settings.json` and `settings.local.json` in `~/.claude/` contain user preferences — mounting these read-only is optional but reduces friction (user's saved settings carry over)
---
## Per-Project Instance Isolation: Key Findings
Claude Code stores per-project data at `~/.claude/projects/{encoded-path}/` where encoding replaces `/` and `.` with `-`. (Source: milvus.io Claude Code local storage deep-dive, verified 2026-04-10.) Per-project instance dirs in claudebox would contain their own `~/.claude/`-equivalent, keeping conversation history, todos, and shell snapshots scoped per project.
Instance dir path: `~/.claudebox/instances/$(echo -n "$CWD" | sha256sum | cut -c1-16)/`
The bind-mount that currently maps `~/.claudebox``~/.claude` inside the sandbox would instead map `~/.claudebox/instances/<hash>``~/.claude`. Auth files should NOT live inside instance dirs (they are cross-project); they should be mounted separately.
---
## Tiered Network Isolation: Key Findings
**Tier: none** — `--unshare-net` added to bwrap flags. No other changes. Claude Code cannot reach any network. DNS fails. Nix store is local so tool installation via comma still works.
**Tier: internet-only** — Requires slirp4netns (in nixpkgs as `pkgs.slirp4netns`). The pattern:
1. bwrap is invoked with `--unshare-net` plus `--info-fd 4` to emit the sandbox PID
2. A wrapper script reads the PID from the info fd
3. slirp4netns is started: `slirp4netns --configure --disable-host-loopback <pid> tap0`
4. `--disable-host-loopback` prevents access to host's 127.x.x.x, 10.x.x.x (LAN), and Tailscale addresses
5. A custom `resolv.conf` pointing to slirp4netns's built-in DNS (10.0.2.3) is bind-mounted
Important: bwrap's `--info-fd` is a synchronization mechanism — bwrap blocks until the info fd is closed, allowing the wrapper to set up the network namespace before Claude Code starts. (Source: bwrap man page, slirp4netns GitHub README, verified 2026-04-10.)
slirp4netns does not require root privileges. It is in nixpkgs and works on NixOS.
**Tier: full** — Current behavior, no changes.
---
## Named Profiles: Design Recommendation
Config format: plain shell-style or TOML. Given the project is pure shell script with no TOML parser available in runtimeInputs, **shell key=value format** (like `.env` files) is simplest:
```sh
# ~/.claudebox/profiles/web.conf
network=internet-only
devshell=false
env=MY_EXTRA_VAR,ANOTHER_VAR
mount_ro=/home/user/shared-docs
packages=awscli2 kubectl
extends=default
```
Parsing: `source`-able if shell syntax, or simple `grep`/`awk` for key=value. Shell `source` is dangerous (arbitrary code execution) — use `awk -F= '{print $1, $2}'` parser instead.
---
## Nix Devshell Injection: Design Recommendation
`nix print-dev-env --json .#devShell` (or just `.` for default devShell) outputs a JSON object with all environment variables the devShell would set, including a modified `PATH`. Parsing this with `jq` (already in runtimeInputs) and merging into `ENV_ARGS` is the right approach.
Caveats:
- `nix print-dev-env` runs Nix evaluation — can be slow (2-10s) for complex flakes
- Must be run from CWD before entering sandbox
- Not all devShell vars are safe to forward (some reference host paths outside /nix/store)
- Should filter to only PATH modifications and explicitly safe vars
---
### 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
- [Claude Code Authentication Docs](https://code.claude.com/docs/en/authentication) — auth file locations (HIGH confidence)
- [inventivehq.com Claude Code config guide](https://inventivehq.com/knowledge-base/claude/where-configuration-files-are-stored) — file list verification (MEDIUM confidence)
- [Claude Code .credentials.json bug issue #1414](https://github.com/anthropics/claude-code/issues/1414) — confirms Linux credential file path (HIGH confidence)
- [milvus.io Claude Code local storage](https://milvus.io/blog/why-claude-code-feels-so-stable-a-developers-deep-dive-into-its-local-storage-design.md) — project dir encoding, storage layout (MEDIUM confidence)
- [slirp4netns README](https://github.com/rootless-containers/slirp4netns) — network isolation mechanics, --disable-host-loopback (HIGH confidence)
- [slirp4netns man page](https://github.com/rootless-containers/slirp4netns/blob/master/slirp4netns.1.md) — --disable-host-loopback details (HIGH confidence)
- [bubblewrap issue #392](https://github.com/containers/bubblewrap/issues/392) — slirp4netns+bwrap integration status (not merged into bwrap; external process approach needed) (HIGH confidence)
- [bwrap man page](https://manpages.debian.org/unstable/bubblewrap/bwrap.1.en.html) — --info-fd synchronization mechanism (HIGH confidence)
- Training data: nix print-dev-env JSON output format, jq availability (MEDIUM confidence — verify exact flags)
- 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)
---
*Feature research updated for v2.0 milestone: network isolation, profiles, auth passthrough, instance isolation, devshell injection*
*Researched: 2026-04-10*
**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.

View file

@ -1,450 +1,401 @@
# Pitfalls Research
# Domain Pitfalls
**Domain:** Bubblewrap sandbox wrappers for CLI tools on NixOS — v2.0 additions
**Researched:** 2026-04-10
**Confidence:** MEDIUM-HIGH (combination of live web research + authoritative sources + training data)
This file supersedes the v1.0 pitfalls from 2026-04-09 and focuses on the four new feature areas of the v2.0 milestone: tiered network isolation (slirp4netns), per-project instance isolation, named profiles, and host auth passthrough.
---
**Domain:** Bubblewrap sandbox wrappers for CLI tools on NixOS
**Researched:** 2026-04-09
**Confidence:** MEDIUM (training data only, no live verification available)
## Critical Pitfalls
### Pitfall 1: Auth Passthrough Read-Only Mount Breaks OAuth Token Refresh
Mistakes that cause sandbox escapes, broken tools, or full rewrites.
**What goes wrong:**
Mounting `~/.claude/.credentials.json` as `--ro-bind` (read-only) into the sandbox to provide auth passthrough seems correct from a security standpoint. In practice, Claude Code's OAuth flow performs a read-refresh-write cycle on `.credentials.json` on every session startup and periodically when the access token approaches expiry. A read-only mount causes the write to fail silently or with a cryptic EACCES error, breaking authentication.
### Pitfall 1: Environment Variable Leaks via Inherited Env
**Why it happens:**
The mental model is "auth is a secret, secrets should be read-only." But Claude Code on Linux stores the entire credentials object — including the refreshToken — in `.credentials.json`, and OAuth access tokens expire. The refresh flow reads the current token, requests a new access+refresh token pair from Anthropic's auth server, and writes the new pair back. If the write fails, the next invocation has an expired access token and a still-valid refresh token it can't update, eventually causing 401 errors.
**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.
Additionally, OAuth refresh tokens are single-use server-side. If a concurrent claude session inside the sandbox refreshes the token and can't write back, the original on-disk token is now invalid too. The user gets locked out.
**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.
**How to avoid:**
Do not mount `.credentials.json` read-only. Instead, mount `~/.claude` (or the specific subset of auth files) with `--bind` (read-write). Alternatively, keep credentials on the read-write `~/.claudebox` instance directory and symlink or copy on launch, updating the host copy on exit — but this is more complex.
**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.
The simplest correct approach: mount `~/.claude` read-write for auth files only, and keep the per-project conversation history and settings on the instance-scoped `~/.claudebox/instances/<hash>/.claude/`. Use a two-directory structure:
```
~/.claudebox/
auth/ # writable bind-mount to host ~/.claude auth files
instances/<hash>/ # per-project instance (conversation history, settings)
```
Inside the sandbox:
```
~/.claude -> ~/.claudebox/instances/<hash>/ (conversation history)
~/.claude/.credentials.json -> (from ~/.claudebox/auth/.credentials.json via symlink or separate bind)
```
**Warning signs:**
- Authentication works on first launch but fails after a few days
- Claude Code asks to re-authenticate on every launch
- Error messages mentioning 401, expired token, or authentication failure appear without the user having changed anything
**Phase to address:** Auth passthrough phase (Phase 1 of v2.0). Must be correct before any other feature.
---
### Pitfall 2: slirp4netns Requires Background Process Coordination — Not a Simple bwrap Flag
**What goes wrong:**
Developers treating "internet-only network isolation" as a bwrap option, similar to `--unshare-net`. There is no single bwrap flag for "internet but no LAN." The correct approach requires:
1. Creating a new network namespace via `--unshare-net`
2. Launching `slirp4netns` as a separate background process (before or concurrent with bwrap)
3. Connecting slirp4netns to the network namespace via the sandbox process's PID
4. Configuring the TAP device (`ip link set tap0 up`, `ip addr add 10.0.2.100/24 dev tap0`, `ip route add default via 10.0.2.2`)
5. Writing an `/etc/resolv.conf` inside the sandbox pointing to slirp4netns's built-in DNS at `10.0.2.3`
This is a non-trivial process orchestration problem in bash: you must start bwrap, capture the sandbox PID before it execs, start slirp4netns targeting that PID, wait for slirp4netns to signal readiness (`--ready-fd`), configure the network interface inside the namespace, then let the sandbox proceed.
**Why it happens:**
The conceptual model of bwrap as a self-contained invocation (set flags, exec, done) breaks down for network namespacing. slirp4netns is a peer process, not a child, and must outlive the bwrap invocation. All existing production users (podman, rootless containers, Nix daemon for fixed-output derivations) implement this in C or Go, not in bash. There is no documented bash-only reference implementation.
**How to avoid:**
Implement the slirp4netns setup using bwrap's `--sync-fd` mechanism:
1. Open a pipe: `coproc ... { exec bwrap --unshare-net --sync-fd 4 ... }`
2. Read the PID from the sync fd before bwrap execs the actual command
3. Start `slirp4netns --configure --ready-fd 5 "$BWRAP_PID" tap0 &`
4. Wait for slirp4netns ready signal on fd 5
5. Release the sync fd to let bwrap proceed
Alternatively, use `--userns-block-fd` to block bwrap until network setup completes. This is what Guix daemon and Podman do.
The `--ready-fd` flag on slirp4netns writes a byte when initialization (TAP up + routing configured) is complete. Do not proceed without it — there is a window where the TAP device exists but has no route, causing the first DNS queries to fail.
**Warning signs:**
- Network works "most of the time" but occasionally fails at startup (race condition — proceeding before slirp4netns is ready)
- DNS fails inside sandbox but ping 10.0.2.2 works (route configured, DNS not yet set up)
- `nix shell` fails inside internet-only mode (missing /etc/resolv.conf pointing to 10.0.2.3)
**Phase to address:** Network isolation phase. Expect this to be the most complex phase. Plan extra time.
---
### Pitfall 3: slirp4netns DNS Breaks When Host Uses systemd-resolved
**What goes wrong:**
On the host, `/etc/resolv.conf` typically points to `127.0.0.53` (systemd-resolved stub) or a loopback address (dnsmasq). Inside the sandbox with `--unshare-net`, the network namespace has no loopback access to the host's DNS resolver. Bind-mounting the host's `/etc/resolv.conf` into the sandbox gives a file pointing to an address unreachable from the new namespace.
slirp4netns provides a built-in DNS resolver at `10.0.2.3`, but this is only active inside the slirp4netns virtual network. You must create or bind-mount a custom `resolv.conf` inside the sandbox that says `nameserver 10.0.2.3`.
**Why it happens:**
The existing claudebox script already bind-mounts `/etc/resolv.conf` from the host. When adding network isolation, this existing mount becomes wrong for the internet-only tier. Developers add slirp4netns but forget to also replace the resolv.conf.
**How to avoid:**
Before launching bwrap for internet-only mode, write a temporary resolv.conf:
**Prevention:** Use `env -i` before the bwrap call, then explicitly set only allowlisted variables:
```bash
RESOLV_TMP=$(mktemp)
echo "nameserver 10.0.2.3" > "$RESOLV_TMP"
trap 'rm -f "$RESOLV_TMP"' EXIT
# Then in bwrap:
--ro-bind "$RESOLV_TMP" /etc/resolv.conf
exec env -i \
HOME="$HOME" \
TERM="$TERM" \
PATH="$SANDBOX_PATH" \
LANG="$LANG" \
bwrap [flags] ...
```
For the "full network" tier, the existing host resolv.conf bind-mount is correct. Make the resolv.conf source conditional on network tier.
Alternatively, `--clearenv` is available in bwrap 0.8.0+. Verify the version in nixpkgs before relying on it.
**Warning signs:**
- `curl https://...` fails with "Could not resolve host" inside internet-only sandbox
- `nix shell` hangs at "downloading..." indefinitely
- DNS works in full-network mode but not in internet-only mode
**Detection:** Run `env` inside the sandbox. If it shows more than your allowlist, you have a leak. Build this as an automated test.
**Phase to address:** Network isolation phase, DNS subsection.
**Phase:** Must be correct from Phase 1. This is the core security invariant.
---
### Pitfall 4: slirp4netns Process Leaks When Sandbox Exits Abnormally
### Pitfall 2: Missing /dev Nodes Causing Silent Failures
**What goes wrong:**
slirp4netns is launched as a background process that must be killed when the sandbox exits. If the sandbox process is killed with SIGKILL, the bash trap handler does not run, and the slirp4netns process becomes an orphan owned by init. On long-running systems, these accumulate. Podman has a documented bug where zombie slirp4netns processes pile up.
**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:**
bash `trap ... EXIT` handles normal exits, SIGTERM, and SIGINT but not SIGKILL. There is no portable way to register a SIGKILL handler. The `--die-with-parent` flag on bwrap causes bwrap to die if its parent (the wrapper script) dies, but the reverse (killing bwrap kills slirp4netns) is not automatic.
**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.
**How to avoid:**
Use `slirp4netns --exit-fd` to give slirp4netns a file descriptor that it monitors. When the fd is closed (because the holding process exited), slirp4netns exits itself. This is the correct mechanism.
**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.
```bash
# Open a pipe; slirp4netns holds the read end
exec {EXIT_FD}<>/tmp/slirp-exit-pipe
slirp4netns --exit-fd "$EXIT_FD" --ready-fd "$READY_FD" "$BWRAP_PID" tap0 &
SLIRP_PID=$!
# Close the fd in the parent when done (or on EXIT trap)
trap "exec {EXIT_FD}>&-; kill $SLIRP_PID 2>/dev/null || true; rm -f ..." EXIT
```
**Prevention:** Use `--dev /dev` (not `--dev-bind /dev /dev` which exposes host devices) and then verify these exist inside:
Note: `--exit-fd` requires slirp4netns 0.4.0+. Verify nixpkgs version.
- `/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)
**Warning signs:**
- `ps aux | grep slirp4netns` shows accumulating processes after repeated claudebox runs
- Memory usage grows gradually on systems with heavy claudebox usage
- Killing claudebox with Ctrl+C leaves a slirp4netns running
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`.
**Phase to address:** Network isolation phase, cleanup subsection.
**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 5: Per-Project Hash Collides for Git Worktrees
### Pitfall 3: Nix Store and Nix Daemon Socket Access
**What goes wrong:**
Per-project instance directories are keyed by hashing the project path (CWD). Claude Code itself uses this same approach (`~/.claude/projects/<hash-of-path>/`). When the user uses git worktrees, the main repo at `/home/user/myproject` and the worktree at `/home/user/myproject-feature` get different hashes and different instance directories. This splits conversation history and project memory across multiple isolated instances, even though they're branches of the same repository.
**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.
Worse: if the worktree is checked out inside the main repo (at `/home/user/myproject/.worktrees/feature`), claudebox's CWD hash approach and Claude Code's internal path hash approach may disagree, creating double-isolation where the user thinks they're resuming a session but they're in a fresh one.
**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.
**Why it happens:**
Hashing CWD is simple and correct for the non-worktree case. The edge case is only apparent during development workflows that use worktrees heavily (which is increasingly common with Claude Code being used for parallel feature development).
**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.
**How to avoid:**
Before computing the instance hash, attempt to resolve the canonical repo root:
**Prevention:**
```bash
canonical_project_root() {
local cwd="$1"
# If we're in a git worktree, resolve to the main worktree's root
local git_common
git_common=$(git -C "$cwd" rev-parse --git-common-dir 2>/dev/null) || { echo "$cwd"; return; }
# git-common-dir returns the .git directory for the common (main) worktree
# Strip the /.git suffix to get the project root
echo "${git_common%/.git}"
}
INSTANCE_KEY=$(canonical_project_root "$CWD")
INSTANCE_HASH=$(printf '%s' "$INSTANCE_KEY" | sha256sum | cut -c1-16)
--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 \
```
This is a best-effort approach. Document the worktree behavior clearly so users know what to expect.
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).
**Warning signs:**
- User reports "it forgot everything I told it" when switching to a worktree
- Multiple instance directories accumulate for what the user thinks is one project
- `~/.claudebox/instances/` grows unexpectedly large
Also mount `--ro-bind /etc/nix /etc/nix` for nix.conf (channels, substituters, trusted keys).
**Phase to address:** Per-project isolation phase.
**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 6: Profile Config Format Creates Bash Parsing Complexity
### Pitfall 7: /proc Mount Leaks Host Information
**What goes wrong:**
Named profiles (`--profile foo`) must be stored in a config format that the bash script can parse. Using anything beyond simple `KEY=VALUE` pairs (e.g., TOML, YAML, JSON) requires either parsing tools inside the Nix derivation or adding jq/tomlq/yq as runtime dependencies specifically for the profile system. Profile config often needs list values (extra mounts, extra packages), which flat KEY=VALUE cannot represent cleanly.
**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.
Attempting to parse nested structures in bash leads to fragile code that breaks on paths with spaces, special characters, or newlines — all common in practice.
**Why it happens:** `--proc /proc` mounts a procfs, but its contents depend on whether `--unshare-pid` is used.
**Why it happens:**
Profile configs naturally want to describe lists (extra packages to add to PATH, extra bind mounts, extra env vars). The temptation is to use a "real" config format. But the wrapper script is bash, and adding a config language parser adds dependencies and complexity.
**Consequences:** Claude Code can read host process info. Not a direct secret leak, but violates the principle of minimal exposure.
**How to avoid:**
Use shell-sourceable profile files (`~/.claudebox/profiles/foo.sh`) that are sourced (not parsed) by the wrapper script. The profile file sets variables following a declared schema:
**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
# ~/.claudebox/profiles/foo.sh
PROFILE_NETWORK_TIER=internet-only
PROFILE_EXTRA_ENV=(SOME_VAR ANOTHER_VAR)
PROFILE_EXTRA_MOUNTS=(/data/myproject/secrets:/run/secrets:ro)
PROFILE_EXTRA_PACKAGES=(pkgs.python3 pkgs.postgresql)
--dev /dev \ # provides basic TTY
--dev-bind /dev/pts /dev/pts \ # PTY allocation
```
The main script sources the profile with `source "$profile_file"` after validating it contains no dangerous patterns. This avoids a config parser entirely.
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).
Risk: sourcing arbitrary files is a code injection vector if profile files are world-writable. Validate file permissions (must be owned by and only writable by the current user) before sourcing.
**Detection:** Run `tput colors` and `tty` inside the sandbox. Check that Claude Code shows colored output.
**Warning signs:**
- Profile parsing breaks on project paths containing spaces
- Lists of packages must be comma-separated, semicolon-separated, and newline-separated (inconsistency)
- Bash arrays can't be exported across source boundaries (requires workarounds)
**Phase to address:** Profile system phase.
**Phase:** Phase 1. Claude Code is a terminal app.
---
### Pitfall 7: Nix Devshell Injection Requires Realizing Store Paths Before bwrap
### Pitfall 9: XDG and Cache Directories Missing
**What goes wrong:**
Profile-specified packages (`PROFILE_EXTRA_PACKAGES`) must be resolved to actual Nix store paths before the bwrap call, so they can be added to `SANDBOX_PATH` and potentially bind-mounted. Attempting to run `nix shell nixpkgs#python3` inside the sandbox to "inject" a package only works if Nix daemon access is available inside the sandbox — and adds startup latency.
**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.
The correct approach is to resolve packages to store paths outside the sandbox before constructing the bwrap command. This requires a `nix build` or `nix eval` call in the pre-launch phase. If packages need to be fetched, this adds significant startup time (potentially 30-120 seconds for uncached packages) with no progress indication.
**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.
**Why it happens:**
The natural thought is "I'll just add the package to nix shell inside the sandbox." But that re-introduces the build step inside the sandbox, and the sandbox PATH doesn't include the injected package for non-shell invocations.
**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.
**How to avoid:**
In pre-launch (outside bwrap), resolve each profile package:
**Prevention:**
```bash
pkg_path=$(nix build --no-link --print-out-paths "nixpkgs#${pkg}" 2>/dev/null)
EXTRA_PATH="${EXTRA_PATH}:${pkg_path}/bin"
# 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)"
```
Cache this resolution: store the resolved store paths in `~/.claudebox/profiles/foo.resolved` with a lockfile and invalidate on flake lock update or nixpkgs channel change. Avoid re-resolving on every launch.
Create `$HOME/.claudebox/cache` and `$HOME/.claudebox/local` directories in the wrapper script before launching bwrap.
Show progress to the user when packages need to be fetched: "Resolving profile packages (first run may take a moment)..."
**Detection:** Run `echo $XDG_RUNTIME_DIR` and `ls -la ~/.cache` inside the sandbox.
**Warning signs:**
- claudebox start time grows from ~1 second to 30+ seconds after adding profile packages
- Profile package resolution is re-run on every launch even when nothing changed
- `SANDBOX_PATH` doesn't include profile packages because they were resolved inside the sandbox
**Phase to address:** Profile + Nix devshell injection phase.
**Phase:** Phase 1. Required for Nix and Node.js operation.
---
### Pitfall 8: Multiple Concurrent Instances of Same Project Race on Instance Directory
### Pitfall 10: Symlink Resolution Across Mount Boundaries
**What goes wrong:**
If a user runs two `claudebox` invocations in the same project directory (common when doing parallel work or forgetting a background session), both instances compute the same project hash and attempt to use the same `~/.claudebox/instances/<hash>/` directory simultaneously. Claude Code writes conversation history to JSONL files in that directory. Concurrent writes without coordination produce corrupted JSONL files.
**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.
This is not hypothetical: Claude Code already has a documented OAuth token refresh race condition when multiple instances run concurrently (GitHub issue #24317, #27933).
**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.
**Why it happens:**
The instance directory scheme assumes one session per project. Concurrent sessions of the same project break this assumption.
**Consequences:** Bind mounts silently succeed but the file is empty or inaccessible. DNS breaks, SSL certs are missing, etc.
**How to avoid:**
Add a lockfile to the instance directory:
**Prevention:** In the wrapper script, resolve symlinks before mounting:
```bash
LOCK_FILE="$INSTANCE_DIR/.claudebox.lock"
exec {LOCK_FD}>"$LOCK_FILE"
if ! flock -n "$LOCK_FD"; then
echo "Another claudebox session is already running for this project." >&2
echo "Use --force to run anyway (conversation history may be interleaved)." >&2
exit 1
fi
resolve_path() {
readlink -f "$1"
}
--ro-bind "$(resolve_path /etc/resolv.conf)" /etc/resolv.conf \
```
Or allow concurrent sessions but assign distinct JSONL sub-directories per session (using a timestamp suffix), accepting that conversation history is session-scoped not project-scoped.
Or mount entire directory trees that are known to be symlink farms (`/etc/ssl`, `/etc/static`).
**Warning signs:**
- Corrupted `~/.claude/projects/` JSONL files after running two terminals in the same project
- "Unexpected end of JSON input" errors in Claude Code on startup
- Session history appears partially missing
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.
**Phase to address:** Per-project isolation phase.
**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 9: IPv6 Tentative Address Delay Causes First-Connection Failures with slirp4netns
### Pitfall 11: SSL/TLS Certificate Chain Missing
**What goes wrong:**
When slirp4netns configures a TAP device with an IPv6 address, the Linux kernel puts the address into "tentative" state and runs Duplicate Address Detection (DAD). DAD takes several seconds to complete. During this window, outgoing connections to IPv6 addresses fail. The first `curl` or `nix shell` command issued immediately after sandbox startup may fail with a connection error, even though the same command succeeds one second later.
**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.
This is documented in the Guix daemon slirp4netns implementation as a reason to explicitly disable DAD.
**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.
**Why it happens:**
Standard IPv6 address assignment always includes DAD. Most production container runtimes disable DAD for virtual interfaces because they know the address is unique within a private namespace. This is non-obvious unless you've worked with container networking before.
**Consequences:** `curl https://...` fails. `nix shell` cannot download from cache.nixos.org. Claude Code cannot reach the Anthropic API. Game over.
**How to avoid:**
After configuring the TAP device inside the namespace, disable DAD:
**Prevention:**
```bash
# Inside the network namespace (or via nsenter):
sysctl -w net.ipv6.conf.tap0.accept_dad=0
sysctl -w net.ipv6.conf.tap0.dad_transmits=0
--ro-bind /etc/ssl /etc/ssl \
--ro-bind /etc/pki /etc/pki \ # if it exists
```
Or use `ip link set tap0 addrgenmode none` before assigning the address.
Add `NIX_SSL_CERT_FILE` and `SSL_CERT_FILE` to the env allowlist, pointing to the cert bundle path. On NixOS:
Alternatively, if IPv6 is not needed for the use case, only configure IPv4 on the TAP device and let IPv6 be absent. Most Anthropic API endpoints and Nix binary caches resolve over IPv4.
**Warning signs:**
- First network request after sandbox start intermittently fails
- Problem is timing-dependent and hard to reproduce consistently
- `sleep 3 && curl https://...` works but `curl https://...` immediately after sandbox start fails
**Phase to address:** Network isolation phase, IPv6 subsection.
---
### Pitfall 10: Profile-Defined Extra Mounts Can Expose Secrets
**What goes wrong:**
Allowing profiles to define arbitrary extra bind mounts via `PROFILE_EXTRA_MOUNTS` breaks the core security invariant if users put secret paths there. A profile for a "cloud deployment" project might mount `~/.aws` or `~/.ssh` — and this is exactly what the profile system is meant to support. But it means the "secrets never enter the sandbox" guarantee becomes conditional on user discipline.
The meta-risk: a compromised or misconfigured profile file (`~/.claudebox/profiles/work.sh`) can silently mount secrets without the user reviewing the audit display.
**Why it happens:**
The profile system is designed to give users power to mount what they need. The same power that makes profiles useful makes them dangerous if misused. The env audit (pre-launch review) exists for env vars, but mounts are not currently in the audit display.
**How to avoid:**
Extend the pre-launch env audit to display active mounts from the profile:
```
Active profile: work
Network tier: internet-only
Extra mounts:
/data/myproject/config -> /run/config (read-only)
~/.aws -> ~/.aws (read-write) <-- HIGHLIGHTED IN RED
Extra packages: python3, postgresql
```bash
NIX_SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
```
Highlight any mount that includes known-secret paths (`~/.ssh`, `~/.gnupg`, `~/.aws`, `~/.config/gcloud`, age key paths) in red with a warning. Do not block — the user may intentionally want to give Claude cloud access — but make it visible.
**Detection:** Run `curl -v https://cache.nixos.org` inside the sandbox. Check for certificate errors.
**Warning signs:**
- Profile silently mounts credentials without user awareness
- Pre-launch audit shows env vars but not mounts, giving false sense of security
- "It worked in the cloud project" — user discovers retrospectively that AWS keys were accessible
**Phase to address:** Profile system phase, audit integration subsection.
**Phase:** Phase 1. Without this, nothing network-related works.
---
## Technical Debt Patterns
## Minor Pitfalls
| Shortcut | Immediate Benefit | Long-term Cost | When Acceptable |
|----------|-------------------|----------------|-----------------|
| Auth files mounted read-only | Simpler, "more secure" | Authentication breaks after token expiry | Never for active sessions |
| Shell-sourceable profile files without permission check | Avoids parser complexity | Code injection via malicious profile file | Never — always check ownership/permissions |
| Skip slirp4netns --ready-fd synchronization | Simpler startup code | Race condition at sandbox start causes intermittent network failures | Never |
| Single global ~/.claudebox/ directory for all instances | Avoids hash computation | Concurrent sessions corrupt shared state | Never if per-project isolation is a stated goal |
| Re-run `nix build` for profile packages on every launch | Always up-to-date | 30+ second startup penalty | Never for interactive use; acceptable in CI |
| Hardcode `nameserver 10.0.2.3` in resolv.conf without checking slirp4netns version | Simple | Breaks if slirp4netns DNS address changes in future versions | Only as MVP, document and add TODO |
### 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.
---
## Integration Gotchas
### Pitfall 13: Home Directory Confusion
| Integration | Common Mistake | Correct Approach |
|-------------|----------------|------------------|
| Claude Code OAuth on Linux | Mounting `~/.claude/.credentials.json` read-only | Mount the auth directory read-write; credentials need to be refreshed on disk |
| slirp4netns + bwrap | Launching slirp4netns after bwrap execs | Capture bwrap child PID before exec using `--sync-fd`; start slirp4netns targeting that PID |
| systemd-resolved host DNS | Bind-mounting host `/etc/resolv.conf` into network-isolated sandbox | Write a fresh resolv.conf pointing to `10.0.2.3` when using slirp4netns tier |
| Git worktrees | Hash CWD for project identity | Resolve git common dir to get canonical project root before hashing |
| Nix devshell packages | Resolve packages inside sandbox using nix shell | Pre-resolve store paths outside sandbox before bwrap invocation; cache results |
| Multiple concurrent sessions | No coordination | Lockfile on instance directory or per-session sub-directories |
**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.
---
## Security Mistakes
### Pitfall 14: bwrap Argument Order Matters
| Mistake | Risk | Prevention |
|---------|------|------------|
| Sourcing profile files without permission validation | Arbitrary code execution if profile file is modified by another process or user | Check `stat` — file must be owned by current user and not group/world writable |
| Displaying extra mounts in audit but not highlighting secret paths | User doesn't notice `~/.ssh` is being mounted | Highlight known secret paths in red in the audit display |
| Relying on slirp4netns `--disable-host-loopback` for LAN isolation | Does not block access to non-loopback LAN addresses | slirp4netns `--disable-host-loopback` only blocks 127.x.x.x; true LAN isolation requires additional iptables rules inside the namespace |
| Storing instance hash as CWD path | Path is predictable; could be used to pre-create a malicious instance directory | Include the uid in the hash: `sha256sum(uid:path)` |
| Profile files with plaintext secrets | Profile file itself becomes a secret file that must be protected | Profile files should reference env var names, not values; actual values come from host environment at launch time |
**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.
---
## "Looks Done But Isn't" Checklist
### Pitfall 15: Hardcoded Paths in Nix-Built Binaries
- [ ] **Auth passthrough:** Verify token refresh still works 24 hours after initial auth by checking that `.credentials.json` is writable inside the sandbox
- [ ] **Internet-only network:** Verify LAN addresses (192.168.x.x, 10.x.x.x) are unreachable but github.com and api.anthropic.com work
- [ ] **Network offline tier:** Verify `curl https://github.com` times out, but `nix shell` still works (Nix daemon socket is a Unix socket, not network; must remain mounted)
- [ ] **Per-project isolation:** Verify two different projects get different instance directories and their conversation histories don't mix
- [ ] **slirp4netns cleanup:** Verify `ps aux | grep slirp4netns` shows no processes after claudebox exits normally AND after Ctrl+C
- [ ] **Profile audit display:** Verify the pre-launch audit shows active profile, network tier, extra mounts, AND extra env vars — not just env vars
- [ ] **Profile permission check:** Verify sourcing a world-writable profile file is rejected with a clear error
- [ ] **Concurrent sessions:** Verify running two claudebox instances in the same project does not corrupt JSONL history
**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.
---
## Recovery Strategies
## Phase-Specific Warnings
| Pitfall | Recovery Cost | Recovery Steps |
|---------|---------------|----------------|
| Auth passthrough breaks token refresh | MEDIUM | Re-authenticate via `claude /login`; fix mount to be read-write; may need to revoke and re-issue OAuth token |
| slirp4netns process leak | LOW | `pkill slirp4netns`; add --exit-fd to prevent recurrence |
| Instance directory corruption from concurrent sessions | MEDIUM | Delete corrupted JSONL files in `~/.claudebox/instances/<hash>/.claude/projects/`; conversation history lost but auth/settings preserved |
| Profile sources and mounts wrong packages | LOW | Remove or edit profile file; re-launch |
| IPv6 DAD causes intermittent startup failures | LOW | Add DAD disable to TAP device setup; or retry first connection |
| Wrong resolv.conf in network-isolated sandbox | LOW | Fix pre-launch resolv.conf generation for internet-only tier |
| 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
## Pitfall-to-Phase Mapping
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:
| Pitfall | Prevention Phase | Verification |
|---------|------------------|--------------|
| Auth passthrough read-only breaks OAuth refresh (#1) | Auth passthrough phase | `touch ~/.claude/.credentials.json` inside sandbox succeeds; token refresh after 24h |
| slirp4netns process coordination complexity (#2) | Network isolation phase | `--dry-run` shows correct bwrap flags; network works on first launch |
| DNS breaks with systemd-resolved in isolated namespace (#3) | Network isolation phase | `curl https://cache.nixos.org` works in internet-only mode |
| slirp4netns process leak (#4) | Network isolation phase | No orphan processes after 10 start/stop cycles |
| Git worktree hash collision (#5) | Per-project isolation phase | Two worktrees of same repo share instance directory |
| Profile config parsing fragility (#6) | Profile system phase | Profile with path containing spaces works correctly |
| Nix devshell injection startup latency (#7) | Profile + devshell phase | Cached packages resolve in <1 second on second launch |
| Concurrent session race on instance directory (#8) | Per-project isolation phase | Two parallel sessions warn/block appropriately |
| IPv6 DAD delay (#9) | Network isolation phase | First `curl` after sandbox start succeeds consistently |
| Profile mounts exposing secrets silently (#10) | Profile system phase | Secret path mounts appear highlighted in pre-launch audit |
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
- Claude Code GitHub issues: OAuth refresh race condition (#24317, #27933), credentials.json on Linux (confirmed at code.claude.com/docs/en/authentication) — HIGH confidence
- slirp4netns man page and rootless-containers/slirp4netns GitHub — HIGH confidence (official source)
- Guix daemon slirp4netns implementation (mail-archive.com/guix-commits, April 2025) — HIGH confidence (authoritative implementation reference)
- bubblewrap issue #392 (slirp4netns feature request) — MEDIUM confidence
- bubblewrap issue #633 (die-with-parent race condition) — HIGH confidence
- bubblewrap issue #504 (abstract network namespace sharing) — HIGH confidence
- Claude Code project storage structure (confirmed via inventivehq.com knowledge base and milvus.io deep dive) — HIGH confidence
- Claude Code GitHub issue #34437 (worktrees share project directory) — HIGH confidence
- Podman zombie slirp4netns issue #9777 — HIGH confidence
- IPv6 DAD behavior from Guix implementation notes — HIGH confidence
- 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)
---
*Pitfalls research for: claudebox v2.0 — network isolation, per-project profiles, auth passthrough*
*Researched: 2026-04-10*
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.

View file

@ -1,373 +1,335 @@
# 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)
**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.
**Scope:** NEW additions only. Existing validated stack (writeShellApplication, bubblewrap, comma-with-db, coreutils/git/curl/jq/ripgrep/fd/nix/nodejs) is carried forward unchanged.
## Recommended Stack
---
### Core: Nix Derivation via `writeShellApplication`
## New Runtime Dependencies
| 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 |
### Add to `runtimeInputs` / `runtimeDeps`
### Runtime Dependencies (runtimeInputs)
| 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 |
| 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#<pkg> -c <cmd>` via `, <cmd>` 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 |
**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.
### NOT in runtimeInputs (Important)
**No new Nix flake inputs needed** — `pkgs.slirp4netns` is in nixpkgs unstable.
| 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
## Network Isolation: Tiered Architecture
### `writeShellApplication` -- Use This
### Three Tiers
```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
'';
}
```
| 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). |
**Confidence:** HIGH -- this is the standard nixpkgs pattern for wrapper scripts.
### Internet Tier: The Shell Pattern
### Why NOT These Alternatives
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:
| 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
# 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"
--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
```
**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.
**Confidence:** HIGH -- these are standard bwrap namespace flags.
**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
### Filesystem Mounts
```bash
bwrap --unshare-net ... -- "$CLAUDE_BIN" ...
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
```
No slirp4netns. bwrap creates an isolated network namespace with only loopback (127.0.0.1) configured. `exec bwrap` still works here.
**Confidence:** HIGH for the pattern, MEDIUM for exact mount ordering (test on NixOS to confirm).
### Full Tier: No Change
### Mount Ordering Matters
Current behavior. No `--unshare-net`. `exec bwrap` still works.
bwrap processes mounts in order. Later mounts can overlay earlier ones. The correct order is:
### Network Tier Implication: `exec` vs `wait`
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)
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:
**Confidence:** HIGH -- this is well-documented bwrap behavior.
### Environment Handling
```bash
if [[ "$NETWORK_TIER" == "internet" ]]; then
# background + slirp4netns + wait pattern
else
exec bwrap ... -- "$SANDBOX_CMD"
fi
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).
## Auth Passthrough: Host ~/.claude Files
**Confidence:** HIGH
### 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:
### Process Execution
```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" \
bwrap \
--die-with-parent # Kill sandbox if parent dies
--new-session # New session (prevents tty hijacking)
-- \
claude --dangerously-skip-permissions "$@"
```
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.
**Confidence:** HIGH
**Guard the mount:** Only add `--ro-bind` if the credentials file exists on the host:
## 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#<package> -c <command> [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
AUTH_MOUNTS=()
if [[ -f "$HOME/.claude/.credentials.json" ]]; then
AUTH_MOUNTS+=(--ro-bind "$HOME/.claude/.credentials.json" "$HOME/.claude/.credentials.json")
fi
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
```
**Confidence:** HIGH for the pattern. The file path is confirmed from official docs.
Or more idiomatically in Nix, construct the PATH in the Nix expression and interpolate it into the script:
---
## Per-Project Instance Isolation
### Instance Directory Pattern
```
~/.claudebox/instances/<hash>/.claude/
```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" ...
'';
}
```
Where `<hash>` is a stable identifier for the project. Options:
**Use `lib.makeBinPath`** -- it joins `/nix/store/.../bin` paths with colons. This is the standard nixpkgs function for constructing PATH.
| 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 |
**Confidence:** HIGH
**Recommendation:** `$(basename "$CWD")-$(echo "$CWD" | md5sum | head -c8)` — readable in `ls` output, collision-resistant.
## 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
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" \
--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
```
**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.
Wait -- the daemon socket needs to be bind-mounted (not ro-bind) because it's a Unix socket:
```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"
--bind /nix/var/nix/daemon-socket /nix/var/nix/daemon-socket
```
**Why bash-sourced, not TOML/JSON/YAML:**
**Confidence:** MEDIUM -- socket bind-mount behavior should be tested. The daemon socket may need `--bind` not `--ro-bind`.
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`
## Testing Strategy
**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.
Since this is a shell script wrapped in Nix:
**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.
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
**Profile loading:**
No test framework needed. A simple `test.sh` with assertions suffices.
```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
---
**Confidence:** HIGH
## 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)
- 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**
---
*Stack research for: claudebox v2.0 network isolation, profiles, auth passthrough*
*Researched: 2026-04-10*
## 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`

View file

@ -1,156 +1,172 @@
# Project Research Summary
**Project:** claudebox v2.0 — Network Isolation & Profiles
**Domain:** Nix/bubblewrap sandbox wrapper for AI coding agents (Claude Code)
**Researched:** 2026-04-10
**Confidence:** HIGH (auth passthrough, instance isolation, network none/full tiers), MEDIUM-HIGH (internet-only tier, profiles)
**Project:** claudebox
**Domain:** Nix bubblewrap sandbox wrapper for AI coding agents
**Researched:** 2026-04-09
**Confidence:** MEDIUM-HIGH
## Executive Summary
claudebox v2.0 extends the validated v1.0 foundation (writeShellApplication, bubblewrap, comma-with-db) with four new capability areas: tiered network isolation, per-project instance isolation, named profiles, and host auth passthrough. The only new runtime dependency is a userspace networking sidecar (`pkgs.slirp4netns` or `pkgs.passt`) for the internet-only network tier. All other features are pure shell extensions with no new deps.
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 build order is strictly dependency-driven. Auth passthrough must come first because every downstream feature assumes Claude Code can authenticate inside the sandbox. Instance isolation depends on the auth mount path being stable. The `none` network tier validates the exec-to-wait refactoring before the high-complexity `inet` tier is added. Named profiles tie together all prior subsystems. Nix package injection is independently testable and should be last.
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 highest-risk feature is the internet-only network tier. It requires process coordination between bwrap and a sidecar: bwrap must be backgrounded (not exec'd), its sandbox PID captured via `--info-fd` or `--pidfile`, the sidecar started and waited on for readiness, and a custom `/etc/resolv.conf` injected because the host's resolv.conf points to a loopback DNS unreachable from the new network namespace.
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 existing stack carries forward unchanged. New additions:
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:**
- `pkgs.slirp4netns` (v1.3.3): internet-only network tier sidecar — well-documented `--ready-fd`/`--exit-fd` sync primitives for bash coordination
- `pkgs.passt` (pasta binary): alternative sidecar — Podman 5 default, NAT-free, cleaner DNS; consider as primary if bash integration proves clean
- Bash-sourced `.sh` profile files OR flat JSON + jq: named profile config
- `sha256sum` (coreutils, already present): instance directory hashing
- **`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):**
- Host auth passthrough — rw mount of `~/.claude/.credentials.json` (rw required for OAuth token refresh)
- Per-project instance isolation — `~/.claudebox/instances/<hash>/.claude/` with git worktree awareness
- Named profiles (`--profile foo` / `CLAUDEBOX_PROFILE=foo`) — env vars, mounts, packages, network tier
- Tiered network isolation: `none` (offline) and `inet` (internet, no LAN/Tailscale)
- 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):**
- Profile `extends` / inheritance
- Network tier and active profile shown in pre-launch env audit
- Profile `--list` and `--show` commands
- Instance dir GC (`--gc`)
**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 to v2.1+:**
- Full `nix develop .#devShell` integration — profile `packages` field covers 80% case
- Domain-level network allowlists
**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 (explicitly avoid):**
- Mounting `.credentials.json` read-only — breaks OAuth token refresh
- Auto-detecting and injecting devShell on every launch — breaks "no surprises" principle
- Storing secret values in profile files — profiles reference env var names, not values
**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
claudebox.sh grows four new functions plus modifications to arg parse, env builder, mount builder, and the exec block. The exec block must branch on network tier: `full` and `none` use `exec bwrap`; `inet` uses `bwrap ... &` + sidecar + `wait`.
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. **Arg parse** — adds `--profile NAME` and `--network full|inet|none` flags
2. **Profile loader** — reads `~/.claudebox/profiles/<name>.json` via jq; yields network, packages, env, mounts, passthrough settings
3. **Instance resolver** — resolves git worktree common dir, hashes canonical project root, creates instance dir
4. **Auth mount**`--bind "$HOME/.claude/.credentials.json"` (read-write, not read-only)
5. **Package injector**`nix build --no-link --print-out-paths nixpkgs#<pkg>` loop; prepends to SANDBOX_PATH
6. **Network setup**`--unshare-net` for none/inet; sidecar coordination for inet; temp resolv.conf for inet
7. **Exec block** — three-branch: full → `exec bwrap`; none → `exec bwrap --unshare-net`; inet → `bwrap --pidfile &` + sidecar + `wait`
8. **Pre-launch audit** — extended to show active profile, network tier, extra mounts
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. **Auth mount must be read-write, not read-only** — Claude Code's OAuth flow writes refreshed tokens back to `.credentials.json`. A `--ro-bind` causes silent EACCES; users get locked out. *Phase 1.*
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. **Sidecar requires process coordination** — bwrap must be backgrounded to capture sandbox PID; `--ready-fd` awaited before proceeding; `--exit-fd` used to prevent process leaks on abnormal exit. *Phase 3.*
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 breaks in isolated namespace** — Host `/etc/resolv.conf` points to `127.0.0.53` (loopback, unreachable in new namespace). Must generate temp resolv.conf with sidecar DNS gateway. *Phase 3.*
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 worktree hash collision** — Hashing CWD gives different hashes for worktrees of same repo. Use `git rev-parse --git-common-dir` to normalize. *Phase 2.*
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. **Concurrent sessions race on instance directory** — Two claudebox invocations in same project write to same files. Add flock lockfile. *Phase 2.*
6. **Profile sourcing requires permission validation** — Shell-sourcing without checking ownership/permissions is code injection. Validate file ownership. *Phase 4.*
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
### Phase 4: Auth Passthrough
Auth must come first — every downstream feature needs Claude to authenticate inside the sandbox.
- Mount `~/.claude/.credentials.json` read-write into instance dir
- Validate token refresh works (not just initial auth)
Based on research, suggested phase structure:
### Phase 5: Per-Project Instance Isolation
Depends on Phase 4 (auth mount path must be stable).
- `~/.claudebox/instances/<sha256(canonical_root)[0:16]>/` as `~/.claude`
- Git worktree-aware hashing via `git rev-parse --git-common-dir`
- flock-based concurrent session guard
### 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 6: Tiered Network Isolation
Highest complexity. Two sub-phases: `none` first (trivial), `inet` second (sidecar coordination).
- `none`: add `--unshare-net`, keep `exec bwrap`
- `inet`: `bwrap &` + slirp4netns/pasta sidecar + `--ready-fd`/`--exit-fd` + temp resolv.conf
- `--network` flag and `CLAUDEBOX_NETWORK` env var
### 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 7: Named Profiles
Ties together all prior subsystems.
- `--profile foo` / `CLAUDEBOX_PROFILE=foo` (flag wins)
- Profile schema: network, env, extra_env_passthrough, mounts, packages
- `~/.claudebox/profiles/<name>.json` parsed with jq
- Permission validation before loading
- Pre-launch audit extended with profile info
### Phase 8: Nix Package Injection
Last because it has startup latency risk and is independently testable.
- Profile `packages` field resolved via `nix build --no-link --print-out-paths`
- Store paths prepended to SANDBOX_PATH
- Result caching to avoid re-resolving
### 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
- Auth before isolation: credential mount path must be established first
- Isolation before profiles: per-project history makes profile defaults meaningful
- Network `none` before `inet`: validates exec→wait refactor cheaply
- Network before profiles: profiles set the network tier; implementation must exist first
- Profiles before package injection: package injection consumes profile packages field
- 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
- **Phase 6 (inet tier):** pasta/slirp4netns exact CLI flags need live verification
- **Phases 4, 5, 7, 8:** Standard patterns, skip research-phase
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 | Existing stack unchanged; slirp4netns/passt verified in nixpkgs |
| Features | HIGH | Auth file path confirmed from official Claude Code docs |
| Architecture | MEDIUM-HIGH | Existing codebase read directly; sidecar integration flags are MEDIUM |
| Pitfalls | HIGH | Sourced from official docs, upstream issue trackers |
| 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:** HIGH for Phases 4-5, 7-8. MEDIUM-HIGH for Phase 6 inet tier.
**Overall confidence:** MEDIUM-HIGH
### Gaps to Address
- **pasta vs slirp4netns final decision:** Attempt pasta first in Phase 6; fall back to slirp4netns if integration proves difficult
- **Profile format: JSON vs bash-sourced:** JSON is safer (no code injection); bash-sourced is simpler. Decide in Phase 7 planning.
- **Auth mount rw semantics:** Must verify token refresh works after Phase 4, not just initial auth
- **`--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)
- Claude Code official docs — `~/.claude/.credentials.json` path; credential precedence
- slirp4netns GitHub (v1.3.3) — `--ready-fd`, `--exit-fd`, `--configure`, `--disable-host-loopback`
- bubblewrap manpage — `--unshare-net`, `--info-fd`, `--pidfile`
- Existing codebase (claudebox.sh, flake.nix) — direct read
- 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)
- passt.top — pasta architecture, `--config-net`, LAN isolation
- Claude Code GitHub issues #24317, #27933 (OAuth refresh), #34437 (worktrees)
- 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-10*
*Research completed: 2026-04-09*
*Ready for roadmap: yes*

View file

@ -101,6 +101,14 @@ CWD=$(pwd)
# Ensure ~/.claudebox exists
mkdir -p "$HOME/.claudebox"
# Credential file mount (AUTH-01, AUTH-02)
CREDS_FILE="$HOME/.claude/.credentials.json"
if [[ -f "$CREDS_FILE" ]]; then
CREDS_MOUNT=true
else
CREDS_MOUNT=false
fi
# === Sandbox-aware prompting (AWARE-01, AWARE-02) ===
# Write SANDBOX.md -- fully managed, overwritten every launch (D-02)
@ -315,6 +323,9 @@ if [[ "$DRY_RUN" == true ]]; then
echo " --tmpfs $HOME \\"
echo " --bind $HOME/.claudebox $HOME/.claudebox \\"
echo " --symlink $HOME/.claudebox $HOME/.claude \\"
if [[ "$CREDS_MOUNT" == true ]]; then
echo " --bind $CREDS_FILE $HOME/.claude/.credentials.json \\"
fi
printf ' --ro-bind %q %s/.gitconfig \\\n' "$GITCONFIG_TMP" "$HOME"
echo " --bind $CWD $CWD \\"
echo " --chdir $CWD \\"
@ -323,28 +334,38 @@ if [[ "$DRY_RUN" == true ]]; then
exit 0
fi
# Build bwrap mount args array (allows conditional mounts)
BWRAP_ARGS=(
--clearenv
"${ENV_ARGS[@]}"
--tmpfs /
--proc /proc
--dev /dev
--tmpfs /tmp
--ro-bind /nix/store /nix/store
--bind /nix/var/nix /nix/var/nix
--ro-bind /etc/resolv.conf /etc/resolv.conf
--ro-bind /etc/ssl /etc/ssl
--ro-bind /etc/passwd /etc/passwd
--ro-bind /etc/group /etc/group
--ro-bind /etc/hosts /etc/hosts
--ro-bind /etc/nsswitch.conf /etc/nsswitch.conf
--ro-bind /etc/nix /etc/nix
--symlink "$(readlink -f "$(command -v env)")" /usr/bin/env
--tmpfs "$HOME"
--bind "$HOME/.claudebox" "$HOME/.claudebox"
--symlink "$HOME/.claudebox" "$HOME/.claude"
)
if [[ "$CREDS_MOUNT" == true ]]; then
BWRAP_ARGS+=(--bind "$CREDS_FILE" "$HOME/.claude/.credentials.json")
fi
BWRAP_ARGS+=(
--ro-bind "$GITCONFIG_TMP" "$HOME/.gitconfig"
--bind "$CWD" "$CWD"
--chdir "$CWD"
--
"${SANDBOX_CMD[@]}"
)
# exec bwrap (SAND-04 through SAND-15, UX-06, D-01)
exec bwrap \
--clearenv \
"${ENV_ARGS[@]}" \
--tmpfs / \
--proc /proc \
--dev /dev \
--tmpfs /tmp \
--ro-bind /nix/store /nix/store \
--bind /nix/var/nix /nix/var/nix \
--ro-bind /etc/resolv.conf /etc/resolv.conf \
--ro-bind /etc/ssl /etc/ssl \
--ro-bind /etc/passwd /etc/passwd \
--ro-bind /etc/group /etc/group \
--ro-bind /etc/hosts /etc/hosts \
--ro-bind /etc/nsswitch.conf /etc/nsswitch.conf \
--ro-bind /etc/nix /etc/nix \
--symlink "$(readlink -f "$(command -v env)")" /usr/bin/env \
--tmpfs "$HOME" \
--bind "$HOME/.claudebox" "$HOME/.claudebox" \
--symlink "$HOME/.claudebox" "$HOME/.claude" \
--ro-bind "$GITCONFIG_TMP" "$HOME/.gitconfig" \
--bind "$CWD" "$CWD" \
--chdir "$CWD" \
-- "${SANDBOX_CMD[@]}"
exec bwrap "${BWRAP_ARGS[@]}"