claudebox/claudebox.sh
Christopher Mühl 29996a2d40
fix: resolve SSL cert symlinks before entering sandbox
On NixOS /etc/ssl/certs/ca-certificates.crt points through /etc/static
which is not mounted. Resolve to the actual /nix/store path first.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 18:25:07 +02:00

497 lines
16 KiB
Bash

# Parse claudebox flags
SKIP_AUDIT=false
DRY_RUN=false
CHECK_MODE=false
SHELL_MODE=false
GC_MODE=false
CLAUDE_ARGS=()
while (( $# > 0 )); do
case "$1" in
--yes|-y) SKIP_AUDIT=true ;;
--dry-run) DRY_RUN=true ;;
--check) CHECK_MODE=true ;;
--shell) SHELL_MODE=true ;;
--gc) GC_MODE=true ;;
--) shift; CLAUDE_ARGS+=("$@"); break ;;
*) CLAUDE_ARGS+=("$1") ;;
esac
shift
done
export SKIP_AUDIT # consumed by Plan 02 audit display
# Garbage-collect stale instance directories (D-11, INST-04)
gc_instances() {
local removed=0
local projects_dir="$HOME/.claudebox/projects"
if [[ ! -d "$projects_dir" ]]; then
echo "No projects directory found at $projects_dir" >&2
return
fi
for dir in "$projects_dir"/*/; do
[[ -d "$dir" ]] || continue
local root_file="$dir/project-root"
[[ -f "$root_file" ]] || continue
local root_path
root_path=$(< "$root_file")
if [[ ! -d "$root_path" ]]; then
rm -rf "$dir"
echo "Removed: $dir (project root gone: $root_path)" >&2
(( removed++ )) || true
fi
done
echo "GC complete: $removed instance(s) removed." >&2
}
# --check: verify prerequisites and exit (D-10, UX-05)
if [[ "$CHECK_MODE" == true ]]; then
pass=true
green=$'\033[32m' red=$'\033[31m' yellow=$'\033[33m' reset=$'\033[0m'
check_cmd() {
if command -v "$1" &>/dev/null; then
echo "${green}OK${reset} $1" >&2
else
echo "${red}FAIL${reset} $1 -- not found" >&2
pass=false
fi
}
echo "claudebox prerequisites:" >&2
echo "" >&2
check_cmd bwrap
check_cmd claude
check_cmd git
check_cmd curl
check_cmd nix
if [[ -d "$HOME/.claudebox" ]]; then
echo "${green}OK${reset} ~/.claudebox exists" >&2
else
echo "${red}FAIL${reset} ~/.claudebox -- not found (will be created on first run)" >&2
fi
if [[ -v ANTHROPIC_API_KEY ]]; then
echo "${green}OK${reset} ANTHROPIC_API_KEY is set" >&2
else
echo "${yellow}WARN${reset} ANTHROPIC_API_KEY is not set" >&2
fi
echo "" >&2
if [[ "$pass" == true ]]; then
echo "${green}All checks passed.${reset}" >&2
exit 0
else
echo "${red}Some checks failed.${reset}" >&2
exit 1
fi
fi
# --gc: remove stale instance directories and exit (D-12, INST-04)
if [[ "$GC_MODE" == true ]]; then
gc_instances
exit 0
fi
# ANSI formatting (D-03)
if [[ -t 2 ]] && [[ "${NO_COLOR:-}" == "" ]]; then
BOLD=$'\033[1m'
RESET=$'\033[0m'
DIM=$'\033[2m'
CYAN=$'\033[36m'
YELLOW=$'\033[33m'
GREEN=$'\033[32m'
RED=$'\033[31m'
else
BOLD="" RESET="" DIM="" CYAN="" YELLOW="" GREEN="" RED=""
fi
# Mask sensitive values (D-04)
mask_value() {
local name="$1" value="$2"
local upper="${name^^}"
if [[ "$upper" == *KEY* || "$upper" == *TOKEN* || "$upper" == *SECRET* || "$upper" == *PASSWORD* || "$upper" == *CREDENTIAL* ]]; then
if (( ${#value} > 11 )); then
echo "${value:0:7}...${value: -4}"
else
echo "***"
fi
else
echo "$value"
fi
}
# SANDBOX_PATH is injected by flake.nix via makeBinPath (only runtimeInputs, no host PATH)
# Resolve binary paths from runtimeInputs
SANDBOX_BASH="$(command -v bash)"
CLAUDE_BIN="$(command -v claude)"
# Record CWD
CWD=$(pwd)
# Compute canonical project root — worktree-aware (D-08, INST-02)
compute_canonical_root() {
local cwd="$1"
local git_common
git_common=$(git -C "$cwd" rev-parse --git-common-dir 2>/dev/null) || {
echo "$cwd"
return
}
# git returns relative ".git" for normal repos; make absolute
if [[ "$git_common" != /* ]]; then
git_common="$cwd/$git_common"
fi
dirname "$(readlink -f "$git_common")"
}
# Ensure ~/.claudebox exists
mkdir -p "$HOME/.claudebox"
# Per-project instance isolation (D-04, D-07, D-09, D-10, INST-01)
CANONICAL_ROOT=$(compute_canonical_root "$CWD")
INSTANCE_HASH=$(printf '%s' "$CANONICAL_ROOT" | sha256sum | cut -c1-16)
INSTANCE_DIR="$HOME/.claudebox/projects/$INSTANCE_HASH"
mkdir -p "$INSTANCE_DIR"
if [[ ! -f "$INSTANCE_DIR/project-root" ]]; then
printf '%s\n' "$CANONICAL_ROOT" > "$INSTANCE_DIR/project-root"
fi
# Ensure history.jsonl source exists — bwrap bind requires source to exist (D-04)
touch "$HOME/.claudebox/history.jsonl"
# Credential file mount (AUTH-01, AUTH-02)
# Credential file lives in ~/.claudebox on the host; mounted into sandbox at ~/.claude/.credentials.json
CREDS_FILE="$HOME/.claudebox/.credentials.json"
if [[ -f "$CREDS_FILE" ]]; then
CREDS_MOUNT=true
else
CREDS_MOUNT=false
fi
# Claude Code config file mount (~/.claude.json)
# Stores auth tokens and user preferences; must be read-write so Claude Code
# can update tokens and write backups without prompting for re-auth.
CLAUDE_JSON_FILE="$HOME/.claude.json"
if [[ -f "$CLAUDE_JSON_FILE" ]]; then
CLAUDE_JSON_MOUNT=true
else
CLAUDE_JSON_MOUNT=false
fi
# === Sandbox-aware prompting (AWARE-01, AWARE-02) ===
# Write SANDBOX.md -- fully managed, overwritten every launch (D-02)
cat > "$HOME/.claudebox/SANDBOX.md" << 'SANDBOXEOF'
# Sandbox Environment
You are running inside a bubblewrap (bwrap) sandbox managed by claudebox.
Your filesystem is isolated -- only the current working directory and
essential system paths are mounted. Your ~/.claude directory is bind-mounted
from the host, with per-project isolation for conversation history.
## Installing Tools
You have two ways to install tools on the fly:
**Comma (preferred for quick one-off commands):**
`, ripgrep` runs ripgrep without permanent installation. Comma uses
nix-index to find the right package automatically.
**Nix shell (for persistent access within the session):**
`nix shell nixpkgs#python3 -c python3 script.py` runs a command with
a package available. To keep it in your PATH for the session:
`nix shell nixpkgs#python3` then use `python3` normally.
## Default Restrictions
By default, the following are not mounted into the sandbox:
- SSH keys (~/.ssh)
- GPG and age keys (~/.gnupg, age key files)
- Cloud credentials (~/.aws, ~/.config/gcloud)
- Tailscale state
If your setup has been customized, some of these may be available.
## Git
Your git identity (name and email) is pre-configured from the host.
The `safe.directory` setting trusts the mounted working directory.
For remote operations, prefer HTTPS URLs over SSH since SSH keys
are not available by default.
SANDBOXEOF
# Generate minimal .gitconfig (D-05)
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
# Parallel display data for env audit (D-01)
declare -a AUDIT_SANDBOX_KEYS=()
declare -A AUDIT_SANDBOX_VALS=()
declare -a AUDIT_HOST_KEYS=()
declare -A AUDIT_HOST_VALS=()
declare -a AUDIT_EXTRA_KEYS=()
declare -A AUDIT_EXTRA_VALS=()
# Build environment --setenv args array (D-03, D-04, SAND-02, SAND-03)
# Sandbox-generated vars -- set directly, never from host
ENV_ARGS=(
--setenv HOME "$HOME"
--setenv USER "$USER"
--setenv PATH "$SANDBOX_PATH"
--setenv SHELL "$SANDBOX_BASH"
--setenv TMPDIR /tmp
--setenv XDG_RUNTIME_DIR /tmp
)
# Populate sandbox audit data
AUDIT_SANDBOX_KEYS=(HOME USER PATH SHELL TMPDIR XDG_RUNTIME_DIR)
AUDIT_SANDBOX_VALS[HOME]="$HOME"
AUDIT_SANDBOX_VALS[USER]="$USER"
AUDIT_SANDBOX_VALS[PATH]="$SANDBOX_PATH"
AUDIT_SANDBOX_VALS[SHELL]="$SANDBOX_BASH"
AUDIT_SANDBOX_VALS[TMPDIR]="/tmp"
AUDIT_SANDBOX_VALS[XDG_RUNTIME_DIR]="/tmp"
# SSL cert path: resolve to real nix store path so symlinks work inside the sandbox.
# On NixOS, /etc/ssl/certs/ca-certificates.crt -> /etc/static/ssl/... -> /nix/store/...
# The sandbox mounts /nix/store but not /etc/static, so we must resolve before entering.
_SSL_CERT_DEFAULT="/etc/ssl/certs/ca-certificates.crt"
_NIX_SSL_CERT="${NIX_SSL_CERT_FILE:-$_SSL_CERT_DEFAULT}"
_NIX_SSL_CERT="$(readlink -f "$_NIX_SSL_CERT" 2>/dev/null || echo "$_NIX_SSL_CERT")"
_SSL_CERT="${SSL_CERT_FILE:-$_NIX_SSL_CERT}"
_SSL_CERT="$(readlink -f "$_SSL_CERT" 2>/dev/null || echo "$_SSL_CERT")"
ENV_ARGS+=(
--setenv NIX_SSL_CERT_FILE "$_NIX_SSL_CERT"
--setenv SSL_CERT_FILE "$_SSL_CERT"
)
AUDIT_SANDBOX_KEYS+=(NIX_SSL_CERT_FILE SSL_CERT_FILE)
AUDIT_SANDBOX_VALS[NIX_SSL_CERT_FILE]="$_NIX_SSL_CERT"
AUDIT_SANDBOX_VALS[SSL_CERT_FILE]="$_SSL_CERT"
# Allowlisted host vars -- only pass if set on host
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}")
AUDIT_HOST_KEYS+=("$var")
AUDIT_HOST_VALS[$var]="${!var}"
fi
done
# CLAUDEBOX_EXTRA_ENV escape hatch (D-03, comma-separated)
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}")
AUDIT_EXTRA_KEYS+=("$var")
AUDIT_EXTRA_VALS[$var]="${!var}"
fi
done
fi
# Env files: ~/.claudebox/env (global) and <project>/.claudebox.env (per-project)
# Format: KEY=VALUE lines; blank lines and lines starting with # are ignored.
load_env_file() {
local file="$1"
[[ -f "$file" ]] || return 0
while IFS= read -r line || [[ -n "$line" ]]; do
# strip leading whitespace, skip blanks and comments
line="${line#"${line%%[! ]*}"}"
[[ -z "$line" || "$line" == '#'* ]] && continue
# require KEY=VALUE form
[[ "$line" != *=* ]] && continue
local key="${line%%=*}"
local val="${line#*=}"
# strip optional surrounding quotes from value
if [[ "$val" == '"'*'"' || "$val" == "'"*"'" ]]; then
val="${val:1:${#val}-2}"
fi
ENV_ARGS+=(--setenv "$key" "$val")
AUDIT_EXTRA_KEYS+=("$key")
AUDIT_EXTRA_VALS[$key]="$val"
done < "$file"
}
load_env_file "$HOME/.claudebox/env"
load_env_file "$CANONICAL_ROOT/.claudebox.env"
# Env audit display (D-01, D-02, D-03, D-04, D-07, UX-01)
print_audit() {
echo "${BOLD}${CYAN}=== Sandbox Environment ===${RESET}" >&2
echo "" >&2
# Unified env list: sandbox [~], host allowlisted [>], extra [+] (D-06, D-07, D-08, D-09, D-10)
for var in "${AUDIT_SANDBOX_KEYS[@]}"; do
if [[ "$var" == "PATH" ]]; then
echo " ${GREEN}[~]${RESET} PATH=" >&2
IFS=':' read -ra path_entries <<< "${AUDIT_SANDBOX_VALS[PATH]}"
for entry in "${path_entries[@]}"; do
echo " ${DIM}${entry}${RESET}" >&2
done
else
echo " ${GREEN}[~]${RESET} ${var}=$(mask_value "$var" "${AUDIT_SANDBOX_VALS[$var]}")" >&2
fi
done
for var in "${AUDIT_HOST_KEYS[@]}"; do
echo " ${YELLOW}[>]${RESET} ${var}=$(mask_value "$var" "${AUDIT_HOST_VALS[$var]}")" >&2
done
for var in "${AUDIT_EXTRA_KEYS[@]}"; do
echo " ${CYAN}[+]${RESET} ${var}=$(mask_value "$var" "${AUDIT_EXTRA_VALS[$var]}")" >&2
done
echo "" >&2
# Mounts section
echo "${BOLD}Mounts:${RESET}" >&2
printf ' %-12s %s (read-write)\n' "CWD" "$CWD" >&2
printf ' %-12s %s (read-write)\n' "$HOME/.claude" "$HOME/.claude" >&2
printf ' %-12s %s (read-write, project: %s)\n' "projects/" "$INSTANCE_DIR" "$CANONICAL_ROOT" >&2
printf ' %-12s %s (read-write)\n' "history" "$HOME/.claudebox/history.jsonl" >&2
printf ' %-12s %s (read-only overlay)\n' "SANDBOX.md" "$HOME/.claudebox/SANDBOX.md" >&2
if [[ "$CREDS_MOUNT" == true ]]; then
printf ' %-12s %s (read-write)\n' "credentials" "$CREDS_FILE" >&2
fi
echo "" >&2
# Network section (Phase 4 placeholder — full isolation comes in Phase 6)
echo "${BOLD}Network:${RESET}" >&2
echo " full (host network)" >&2
}
# Env audit and confirmation (D-05, D-06, D-07, UX-01, UX-02, UX-03)
if [[ "$SKIP_AUDIT" != true && "$DRY_RUN" != true ]]; then
print_audit
# TTY check (D-06)
if [[ -t 0 ]]; then
echo -n "Proceed? [Y/n] " >&2
read -r response < /dev/tty
response="${response,,}" # lowercase
if [[ "$response" == "n" || "$response" == "no" ]]; then
echo "Aborted." >&2
exit 1
fi
else
echo "${RED}Error: stdin is not a terminal. Pass --yes or -y to skip confirmation.${RESET}" >&2
exit 1
fi
fi
# Build sandbox command
if [[ "$SHELL_MODE" == true ]]; then
SANDBOX_CMD=("$SANDBOX_BASH" "${CLAUDE_ARGS[@]}")
else
SANDBOX_CMD=("$CLAUDE_BIN" --dangerously-skip-permissions "${CLAUDE_ARGS[@]}")
fi
# --dry-run: print the bwrap command without executing (D-09, UX-04)
if [[ "$DRY_RUN" == true ]]; then
{
echo "bwrap \\"
echo " --clearenv \\"
# Guard: ENV_ARGS must be a multiple of 3 (--setenv NAME VALUE triplets)
if (( ${#ENV_ARGS[@]} % 3 != 0 )); then
echo "BUG: ENV_ARGS length ${#ENV_ARGS[@]} is not a multiple of 3" >&2
exit 1
fi
dry_run_i=0
while (( dry_run_i < ${#ENV_ARGS[@]} )); do
printf ' %s %s %q \\\n' "${ENV_ARGS[$dry_run_i]}" "${ENV_ARGS[$((dry_run_i+1))]}" "${ENV_ARGS[$((dry_run_i+2))]}"
dry_run_i=$(( dry_run_i + 3 ))
done
echo " --tmpfs / \\"
echo " --proc /proc \\"
echo " --dev /dev \\"
echo " --tmpfs /tmp \\"
echo " --ro-bind /nix/store /nix/store \\"
echo " --bind /nix/var/nix /nix/var/nix \\"
echo " --ro-bind /etc/resolv.conf /etc/resolv.conf \\"
echo " --ro-bind /etc/ssl /etc/ssl \\"
echo " --ro-bind /etc/passwd /etc/passwd \\"
echo " --ro-bind /etc/group /etc/group \\"
echo " --ro-bind /etc/hosts /etc/hosts \\"
echo " --ro-bind /etc/nsswitch.conf /etc/nsswitch.conf \\"
echo " --ro-bind /etc/nix /etc/nix \\"
printf ' --symlink %q /usr/bin/env \\\n' "$(readlink -f "$(command -v env)")"
printf ' --symlink %q /bin/sh \\\n' "$(readlink -f "$(command -v bash)")"
echo " --tmpfs $HOME \\"
echo " --bind $HOME/.claude $HOME/.claude \\"
echo " --bind $INSTANCE_DIR $HOME/.claude/projects \\"
echo " --bind $HOME/.claudebox/history.jsonl $HOME/.claude/history.jsonl \\"
echo " --bind $HOME/.claudebox/SANDBOX.md $HOME/.claude/SANDBOX.md \\"
if [[ "$CLAUDE_JSON_MOUNT" == true ]]; then
echo " --bind $CLAUDE_JSON_FILE $HOME/.claude.json \\"
fi
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 \\"
printf ' -- %s\n' "${SANDBOX_CMD[*]}"
} >&2
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
--symlink "$(readlink -f "$(command -v bash)")" /bin/sh
--tmpfs "$HOME"
# Phase 5: direct ~/.claude bind (D-01) — all plugins/skills/hooks/MCP visible
--bind "$HOME/.claude" "$HOME/.claude"
# Phase 5: overlay projects/ with per-project isolated dir (D-02, INST-01)
--bind "$INSTANCE_DIR" "$HOME/.claude/projects"
# Phase 5: overlay history.jsonl with sandbox-side file (D-03)
--bind "$HOME/.claudebox/history.jsonl" "$HOME/.claude/history.jsonl"
# Phase 5: inject SANDBOX.md as file overlay (D-06)
--bind "$HOME/.claudebox/SANDBOX.md" "$HOME/.claude/SANDBOX.md"
)
if [[ "$CLAUDE_JSON_MOUNT" == true ]]; then
BWRAP_ARGS+=(--bind "$CLAUDE_JSON_FILE" "$HOME/.claude.json")
fi
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 "${BWRAP_ARGS[@]}"