claudebox/claudebox.sh

382 lines
11 KiB
Bash

# Parse claudebox flags
SKIP_AUDIT=false
DRY_RUN=false
CHECK_MODE=false
SHELL_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 ;;
--) shift; CLAUDE_ARGS+=("$@"); break ;;
*) CLAUDE_ARGS+=("$1") ;;
esac
shift
done
export SKIP_AUDIT # consumed by Plan 02 audit display
# --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
# 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)
# Ensure ~/.claudebox exists
mkdir -p "$HOME/.claudebox"
# Credential file mount (AUTH-01, AUTH-02)
# Use ~/.claudebox (the host-side claudebox config dir), not ~/.claude
# ~/.claude -> ~/.claudebox symlink only exists inside the sandbox at runtime
CREDS_FILE="$HOME/.claudebox/.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)
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. Both ~/.claude and ~/.claudebox
point to the same directory inside the sandbox.
## 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
# Ensure CLAUDE.md has @SANDBOX.md import (D-03, D-08, AWARE-01)
CLAUDEMD="$HOME/.claudebox/CLAUDE.md"
if [[ ! -f "$CLAUDEMD" ]]; then
printf '%s\n' "@SANDBOX.md" > "$CLAUDEMD"
elif [[ "$(head -1 "$CLAUDEMD")" != "@SANDBOX.md" ]]; then
tmp=$(mktemp)
{ printf '%s\n' "@SANDBOX.md"; cat "$CLAUDEMD"; } > "$tmp"
mv "$tmp" "$CLAUDEMD"
fi
# 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
--setenv NIX_SSL_CERT_FILE /etc/ssl/certs/ca-certificates.crt
--setenv SSL_CERT_FILE /etc/ssl/certs/ca-certificates.crt
)
# Populate sandbox audit data
AUDIT_SANDBOX_KEYS=(HOME USER PATH SHELL TMPDIR XDG_RUNTIME_DIR NIX_SSL_CERT_FILE SSL_CERT_FILE)
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"
AUDIT_SANDBOX_VALS[NIX_SSL_CERT_FILE]="/etc/ssl/certs/ca-certificates.crt"
AUDIT_SANDBOX_VALS[SSL_CERT_FILE]="/etc/ssl/certs/ca-certificates.crt"
# 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 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' "~/.claude" "$HOME/.claudebox" >&2
if [[ "$CREDS_MOUNT" == true ]]; then
printf ' %-12s %s (read-only)\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)")"
echo " --tmpfs $HOME \\"
echo " --bind $HOME/.claudebox $HOME/.claudebox \\"
echo " --symlink $HOME/.claudebox $HOME/.claude \\"
if [[ "$CREDS_MOUNT" == true ]]; then
echo " --ro-bind $CREDS_FILE $HOME/.claudebox/.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
--tmpfs "$HOME"
--bind "$HOME/.claudebox" "$HOME/.claudebox"
--symlink "$HOME/.claudebox" "$HOME/.claude"
)
if [[ "$CREDS_MOUNT" == true ]]; then
BWRAP_ARGS+=(--ro-bind "$CREDS_FILE" "$HOME/.claudebox/.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[@]}"