claudebox/.planning/phases/01-minimal-viable-sandbox/01-01-PLAN.md
Christopher Mühl 71790d714b
docs(01): create phase plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 11:02:11 +02:00

380 lines
15 KiB
Markdown

---
phase: 01-minimal-viable-sandbox
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- flake.nix
- claudebox.sh
autonomous: true
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
must_haves:
truths:
- "Running `nix build` in the project root produces a working `claudebox` binary"
- "claudebox launches Claude Code inside bwrap with only allowlisted env vars"
- "Secret paths (~/.ssh, ~/.gnupg, ~/.aws, etc.) are not visible inside the sandbox"
- "Git works inside the sandbox with user identity from host"
- "comma and nix shell work inside the sandbox for on-demand tool installation"
- "Ctrl+C terminates the session cleanly; exit code passes through"
artifacts:
- path: "flake.nix"
provides: "Nix flake with claudebox as default package"
contains: "writeShellApplication"
- path: "claudebox.sh"
provides: "Shell script body with bwrap sandbox invocation"
contains: "exec bwrap"
key_links:
- from: "flake.nix"
to: "claudebox.sh"
via: "builtins.readFile ./claudebox.sh"
pattern: "builtins.readFile ./claudebox.sh"
- from: "claudebox.sh"
to: "bwrap"
via: "exec bwrap with --clearenv"
pattern: "exec bwrap.*--clearenv"
- from: "claudebox.sh"
to: "claude"
via: "CLAUDE_BIN resolved from host PATH before clearenv"
pattern: "CLAUDE_BIN=.*command -v claude"
---
<objective>
Create the complete claudebox Nix flake and shell script that launches Claude Code inside a bubblewrap sandbox with full environment isolation, filesystem isolation, secret hiding, git support, and tool provisioning.
Purpose: This is the entire deliverable for Phase 1 -- a working `claudebox` command.
Output: `flake.nix` and `claudebox.sh` in the project root.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/01-minimal-viable-sandbox/01-CONTEXT.md
@.planning/phases/01-minimal-viable-sandbox/01-RESEARCH.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Create flake.nix with all inputs and writeShellApplication</name>
<files>flake.nix</files>
<read_first>
.planning/phases/01-minimal-viable-sandbox/01-RESEARCH.md
</read_first>
<action>
Create `flake.nix` in the project root with the following exact structure:
```nix
{
description = "claudebox - sandboxed Claude Code";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
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};
comma-with-db = nix-index-database.packages.${system}.comma-with-db;
in {
packages.${system} = {
claudebox = pkgs.writeShellApplication {
name = "claudebox";
runtimeInputs = [
pkgs.bubblewrap
pkgs.coreutils
pkgs.git
pkgs.curl
pkgs.jq
pkgs.ripgrep
pkgs.fd
pkgs.nix
comma-with-db
pkgs.bash
pkgs.nodejs
];
text = builtins.readFile ./claudebox.sh;
};
default = self.packages.${system}.claudebox;
};
};
}
```
Key points per user decisions:
- Per D-02: `comma-with-db` comes from the `nix-index-database` flake, using `packages.${system}.comma-with-db` (not legacyPackages).
- Per NIX-01/NIX-02: Flake with pinned inputs. `nixpkgs.follows` ensures single nixpkgs instance.
- Per NIX-03: `default` package alias so `nix run` and `nix profile install` work.
- Per SAND-01/SAND-10: `writeShellApplication` produces the binary and wires runtimeInputs into PATH.
- Claude Code is NOT in runtimeInputs -- it's discovered from host PATH at runtime (see Research Pattern 5).
</action>
<verify>
<automated>grep -q 'writeShellApplication' flake.nix && grep -q 'comma-with-db' flake.nix && grep -q 'nix-index-database' flake.nix && grep -q 'builtins.readFile ./claudebox.sh' flake.nix && echo "PASS" || echo "FAIL"</automated>
</verify>
<acceptance_criteria>
- flake.nix contains `description = "claudebox - sandboxed Claude Code"`
- flake.nix contains `nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"`
- flake.nix contains `nix-index-database.packages.${system}.comma-with-db`
- flake.nix contains `name = "claudebox"`
- flake.nix contains `builtins.readFile ./claudebox.sh`
- flake.nix contains all 11 runtimeInputs: bubblewrap, coreutils, git, curl, jq, ripgrep, fd, nix, comma-with-db, bash, nodejs
- flake.nix contains `default = self.packages.${system}.claudebox`
</acceptance_criteria>
<done>flake.nix exists with correct flake structure, all runtime dependencies, and readFile of claudebox.sh</done>
</task>
<task type="auto">
<name>Task 2: Create claudebox.sh with complete bwrap sandbox logic</name>
<files>claudebox.sh</files>
<read_first>
.planning/phases/01-minimal-viable-sandbox/01-RESEARCH.md
.planning/phases/01-minimal-viable-sandbox/01-CONTEXT.md
</read_first>
<action>
Create `claudebox.sh` in the project root. This is the shell script body read by `writeShellApplication` (which adds `set -euo pipefail` and prepends runtimeInputs to PATH automatically). The script must implement the following sections in order:
**Section 1: Resolve claude binary from host PATH (before clearenv strips it)**
```bash
CLAUDE_BIN=$(command -v claude) || {
echo "error: claude not found in PATH" >&2
echo "Install Claude Code first: https://docs.anthropic.com/en/docs/claude-code" >&2
exit 1
}
```
**Section 2: Capture sandbox PATH**
The runtimeInputs-constructed PATH is available as `$PATH` at this point. Capture it for passing into the sandbox:
```bash
SANDBOX_PATH="$PATH"
```
**Section 3: Record CWD**
```bash
CWD=$(pwd)
```
**Section 4: Ensure ~/.claudebox exists**
```bash
mkdir -p "$HOME/.claudebox"
```
**Section 5: Generate minimal .gitconfig (per D-05)**
Read host git identity, write a temp .gitconfig, set up cleanup trap:
```bash
GIT_NAME=$(git config --global user.name 2>/dev/null || echo "Claude User")
GIT_EMAIL=$(git config --global user.email 2>/dev/null || echo "claude@localhost")
GITCONFIG_TMP=$(mktemp)
trap 'rm -f "$GITCONFIG_TMP"' EXIT
cat > "$GITCONFIG_TMP" <<GITEOF
[user]
name = $GIT_NAME
email = $GIT_EMAIL
[safe]
directory = *
GITEOF
```
**Section 6: Build environment --setenv args array (per D-03, D-04, SAND-02, SAND-03)**
Sandbox-generated vars (D-04) are set directly, never from host:
```bash
ENV_ARGS=(
--setenv HOME "$HOME"
--setenv USER "$USER"
--setenv PATH "$SANDBOX_PATH"
--setenv SHELL /bin/bash
--setenv TMPDIR /tmp
--setenv XDG_RUNTIME_DIR /tmp
--setenv NIX_SSL_CERT_FILE /etc/ssl/certs/ca-certificates.crt
--setenv SSL_CERT_FILE /etc/ssl/certs/ca-certificates.crt
)
```
Allowlisted host vars -- only pass if set on host:
```bash
HOST_ALLOWLIST=(TERM EDITOR LANG LC_ALL ANTHROPIC_API_KEY)
for var in "${HOST_ALLOWLIST[@]}"; do
if [[ -v "$var" ]]; then
ENV_ARGS+=(--setenv "$var" "${!var}")
fi
done
```
CLAUDEBOX_EXTRA_ENV escape hatch (per D-03, comma-separated):
```bash
if [[ -v CLAUDEBOX_EXTRA_ENV ]]; then
IFS=',' read -ra EXTRAS <<< "$CLAUDEBOX_EXTRA_ENV"
for var in "${EXTRAS[@]}"; do
var="${var// /}" # trim whitespace
if [[ -n "$var" ]] && [[ -v "$var" ]]; then
ENV_ARGS+=(--setenv "$var" "${!var}")
fi
done
fi
```
**Section 7: exec bwrap (per SAND-04 through SAND-15, UX-06, D-01)**
```bash
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/static /etc/static \
--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 "$(command -v env)" /usr/bin/env \
--tmpfs "$HOME" \
--bind "$HOME/.claudebox" "$HOME/.claude" \
--ro-bind "$GITCONFIG_TMP" "$HOME/.gitconfig" \
--bind "$CWD" "$CWD" \
--chdir "$CWD" \
-- "$CLAUDE_BIN" --dangerously-skip-permissions "$@"
```
Mount ordering rationale (most general to most specific):
1. `--tmpfs /` -- empty root (SAND-04)
2. `/proc`, `/dev`, `/tmp` -- system essentials (SAND-11)
3. `/nix/store` ro, `/nix/var/nix` rw -- Nix access (SAND-06, SAND-07, TOOL-03)
4. `/etc/*` -- DNS, SSL, user resolution, nix config (SAND-12, SAND-13)
5. `/usr/bin/env` symlink -- shebang support (Pitfall 8)
6. `$HOME` tmpfs -- clean home dir
7. `~/.claudebox` as `~/.claude` -- Claude config (SAND-08)
8. `.gitconfig` -- git identity (GIT-01, GIT-02)
9. `$CWD` -- project directory (SAND-05), most specific = last
Per D-01: all args after claudebox's own flags pass through via `"$@"`. Phase 1 has no claudebox-specific flags (--yes/--dry-run/--check are Phase 2), so ALL args pass through.
Per UX-06: `--dangerously-skip-permissions` is always injected before `"$@"`.
Per SAND-14/SAND-15: `exec` ensures no intermediate shell -- signals propagate, exit code passes through.
Per SAND-09: Secret paths are never mounted. The tmpfs root and tmpfs HOME ensure nothing leaks. Only explicit bind-mounts above are visible.
IMPORTANT: Do NOT add `#!/bin/bash` or `set -euo pipefail` -- `writeShellApplication` adds these automatically.
</action>
<verify>
<automated>grep -q 'exec bwrap' claudebox.sh && grep -q 'clearenv' claudebox.sh && grep -q 'CLAUDE_BIN' claudebox.sh && grep -q 'CLAUDEBOX_EXTRA_ENV' claudebox.sh && grep -q 'GITCONFIG_TMP' claudebox.sh && grep -q 'dangerously-skip-permissions' claudebox.sh && echo "PASS" || echo "FAIL"</automated>
</verify>
<acceptance_criteria>
- claudebox.sh does NOT contain `#!/bin/bash` or `set -euo pipefail` (writeShellApplication adds these)
- claudebox.sh contains `CLAUDE_BIN=$(command -v claude)` with error handling on failure
- claudebox.sh contains `SANDBOX_PATH="$PATH"`
- claudebox.sh contains `mkdir -p "$HOME/.claudebox"`
- claudebox.sh contains git config reading: `git config --global user.name` and `git config --global user.email`
- claudebox.sh contains `GITCONFIG_TMP=$(mktemp)` and `trap 'rm -f "$GITCONFIG_TMP"' EXIT`
- claudebox.sh contains `safe.directory = *` in the generated gitconfig
- claudebox.sh contains ENV_ARGS array with --setenv for HOME, USER, PATH, SHELL, TMPDIR, XDG_RUNTIME_DIR, NIX_SSL_CERT_FILE, SSL_CERT_FILE
- claudebox.sh contains HOST_ALLOWLIST with TERM, EDITOR, LANG, LC_ALL, ANTHROPIC_API_KEY
- claudebox.sh contains CLAUDEBOX_EXTRA_ENV parsing with `IFS=',' read -ra EXTRAS`
- claudebox.sh contains `exec bwrap` with `--clearenv`
- claudebox.sh contains `--tmpfs /` before any other mount
- claudebox.sh contains `--ro-bind /nix/store /nix/store`
- claudebox.sh contains `--bind /nix/var/nix /nix/var/nix` (rw, not ro-bind)
- claudebox.sh contains `--ro-bind /etc/resolv.conf /etc/resolv.conf`
- claudebox.sh contains `--ro-bind /etc/ssl /etc/ssl` AND `--ro-bind /etc/static /etc/static`
- claudebox.sh contains `--ro-bind /etc/nix /etc/nix`
- claudebox.sh contains `--ro-bind /etc/passwd /etc/passwd` and `--ro-bind /etc/group /etc/group`
- claudebox.sh contains `--symlink` for `/usr/bin/env`
- claudebox.sh contains `--tmpfs "$HOME"` BEFORE the claudebox and CWD bind mounts
- claudebox.sh contains `--bind "$HOME/.claudebox" "$HOME/.claude"`
- claudebox.sh contains `--ro-bind "$GITCONFIG_TMP" "$HOME/.gitconfig"`
- claudebox.sh contains `--bind "$CWD" "$CWD"` and `--chdir "$CWD"`
- claudebox.sh contains `-- "$CLAUDE_BIN" --dangerously-skip-permissions "$@"` at the end
- claudebox.sh does NOT contain any mount of ~/.ssh, ~/.gnupg, ~/.aws, ~/.config/gcloud, or /var/lib/tailscale
</acceptance_criteria>
<done>claudebox.sh exists with complete bwrap invocation covering all SAND-*, TOOL-*, GIT-*, and UX-06 requirements</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Host -> Sandbox | Environment variables cross from untrusted host env into sandbox via allowlist |
| Host -> Sandbox | Filesystem paths cross via explicit bind mounts |
| Sandbox -> Host | CWD is mounted read-write, so Claude can modify project files (intended) |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-01-01 | Information Disclosure | Environment variables | mitigate | `--clearenv` + explicit allowlist. Only SAND-03 vars pass through. CLAUDEBOX_EXTRA_ENV is user-opt-in. |
| T-01-02 | Information Disclosure | Secret filesystem paths | mitigate | tmpfs root + tmpfs HOME. Only explicit bind mounts visible. ~/.ssh, ~/.gnupg, ~/.aws never mounted. Verify by absence in mount list. |
| T-01-03 | Tampering | Host filesystem via CWD mount | accept | CWD is intentionally rw -- Claude needs to edit project files. Scope is limited to the single directory. |
| T-01-04 | Information Disclosure | Git credential helpers | mitigate | Host ~/.gitconfig is NOT mounted. A minimal generated .gitconfig with only user.name, user.email, and safe.directory is used instead. No credential helper config enters sandbox. |
| T-01-05 | Elevation of Privilege | Nix daemon socket access | accept | Daemon socket is rw to allow `nix shell`. The daemon runs as root on host but nix client operations are normal user operations. Nix daemon has its own access controls. |
| T-01-06 | Information Disclosure | /etc/passwd, /etc/group | accept | Read-only mount of user database. Contains usernames/UIDs only, no password hashes (those are in /etc/shadow which is not mounted). Required for basic tool functionality. |
| T-01-07 | Spoofing | CLAUDE_BIN resolution | accept | claude binary is resolved from host PATH before sandbox. If attacker controls host PATH, they already have full host access. Not a sandbox boundary issue. |
</threat_model>
<verification>
After both tasks complete:
1. `nix flake check` passes (or at least doesn't error on flake structure)
2. `grep -c 'exec bwrap' claudebox.sh` returns 1
3. `grep -c 'clearenv' claudebox.sh` returns 1
4. No secret paths appear in claudebox.sh mounts: `grep -E '\.ssh|\.gnupg|\.aws|gcloud|tailscale' claudebox.sh` returns nothing
</verification>
<success_criteria>
- flake.nix and claudebox.sh exist in project root
- flake.nix defines claudebox as default package with all 11 runtimeInputs
- claudebox.sh implements complete bwrap sandbox with env allowlist, filesystem isolation, git identity, and tool provisioning
- All 24 phase requirements (SAND-01 through SAND-15, TOOL-01 through TOOL-03, GIT-01, GIT-02, NIX-01 through NIX-03, UX-06) are addressed
</success_criteria>
<output>
After completion, create `.planning/phases/01-minimal-viable-sandbox/01-01-SUMMARY.md`
</output>