#!/usr/bin/env bash # claude-sbx -- run Claude Code inside a Docker sbx microVM with an egress allowlist. set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=config/config.sh source "${REPO_ROOT}/config/config.sh" mkdir -p "$RUNTIME_DIR" usage() { cat < Commands: start Provision Claude-ready sandbox (policy + VM + Claude Code + seed ~/.claude) stop Destroy sandbox shell Drop into bash inside the sandbox (launch claude yourself) sync-config Re-copy selected paths from ~/.claude into the sandbox reload-allowlist Re-apply config/allowlist.txt to sbx policy status Show sandbox + policy state Config: config/config.sh (override in config/config.local.sh) Allowlist: config/allowlist.txt EOF } log() { echo "[claude-sbx] $*"; } warn() { echo "[claude-sbx] WARN: $*" >&2; } die() { echo "[claude-sbx] ERROR: $*" >&2; exit 1; } require_tools() { command -v sbx >/dev/null || die "sbx not installed. brew install docker/tap/sbx" command -v npm >/dev/null || die "npm not installed on host (needed to pack Claude Code tarball)" } sandbox_exists() { sbx ls 2>/dev/null | awk 'NR>1 {print $1}' | grep -Fxq "$SANDBOX_NAME" } # sbx has no `cp` subcommand. sbx bind-mounts the workspace at the SAME path # inside the VM as on host, so copying into ${WORKSPACE_HOST}/.claude-sbx-staging/ # makes the file visible inside the VM at the identical path. STAGING="${WORKSPACE_HOST}/.claude-sbx-staging" _stage_file() { local src="$1" mkdir -p "$STAGING" local base base="$(basename "$src")" cp "$src" "$STAGING/$base" echo "$STAGING/$base" } _stage_cleanup() { [ -n "${STAGING:-}" ] && [ -d "$STAGING" ] || return 0 find "$STAGING" -maxdepth 1 -type f -delete rmdir "$STAGING" 2>/dev/null || true } ############################################# # Egress policy ############################################# apply_policy() { log "applying egress policy from $ALLOWLIST_FILE" # Set default to deny-all. Errors "default policy is already set" on # repeat runs -- safe to ignore; we only need deny-all to be the baseline. # NOTE: we intentionally do NOT run `sbx policy reset --force` here -- it # restarts the daemon and unsets the default, then every sbx command hangs # on an interactive TUI. sbx policy set-default deny-all 2>/dev/null || true # Reload semantics: drop existing local network-allow rules so the policy # reflects the file exactly. Without this, reload-allowlist would only # append -- stale rules from earlier runs (or ad-hoc additions) would # stay active and quietly widen the allowlist. # `sbx policy ls` NAME column is "local:"; `sbx policy rm network --id` # wants the bare uuid. local stale_names stale_names="$(sbx policy ls 2>/dev/null | awk '$2 == "network" && $3 == "local" && $4 == "allow" {print $1}' || true)" if [ -n "$stale_names" ]; then local count count=$(echo "$stale_names" | wc -l | tr -d ' ') log "clearing ${count} existing network rule(s)..." while IFS= read -r name; do [ -z "$name" ] && continue local id="${name#local:}" sbx policy rm network --id "$id" >/dev/null || warn "failed to remove rule $id" done <<< "$stale_names" fi local domains=() while IFS= read -r line; do line="${line%%#*}" line="$(echo "$line" | tr -d '[:space:]')" [ -z "$line" ] && continue domains+=("${line}") done < "$ALLOWLIST_FILE" if [ ${#domains[@]} -gt 0 ]; then local joined joined="$(IFS=,; echo "${domains[*]}")" sbx policy allow network "$joined" fi log " ${#domains[@]} domains allowed" } ############################################# # Workspace ############################################# ensure_workspace() { if [ ! -d "$WORKSPACE_HOST" ]; then log "creating workspace dir: $WORKSPACE_HOST" mkdir -p "$WORKSPACE_HOST" fi } ############################################# # Claude Code install ############################################# install_claude_code() { local version="${CLAUDE_CODE_VERSION}" if [ "$version" = "latest" ]; then log "resolving latest claude-code version from npm..." version=$(npm view @anthropic-ai/claude-code version 2>/dev/null) [ -z "$version" ] && die "couldn't resolve latest claude-code version" log " → ${version}" fi local tarball="${RUNTIME_DIR}/anthropic-ai-claude-code-${version}.tgz" if [ ! -f "$tarball" ]; then log "packing claude-code@${version} on host..." (cd "$RUNTIME_DIR" && npm pack "@anthropic-ai/claude-code@${version}" >/dev/null) fi log "installing claude-code@${version} in sandbox..." local guest_tgz guest_tgz=$(_stage_file "$tarball") sbx exec -u root "$SANDBOX_NAME" -- bash -lc " set -e npm install -g '${guest_tgz}' mkdir -p /home/agent/.local/bin ln -sf /usr/local/share/npm-global/bin/claude /home/agent/.local/bin/claude 2>/dev/null || true chown -h agent:agent /home/agent/.local/bin/claude 2>/dev/null || true claude --version " } ############################################# # Persistent env (non-secret only) ############################################# # /etc/profile.d/*.sh is the canonical spot for bash -l login env on Linux -- # guaranteed to be sourced by `sbx run ... bash -lc` in cmd_shell. install_persistent_env() { log "writing /etc/profile.d/claude-sbx.sh (non-secret)..." sbx exec -u root "$SANDBOX_NAME" -- bash -c "cat > /etc/profile.d/claude-sbx.sh <<'EOF' # non-secret sandbox env -- no tokens, no keys unset ANTHROPIC_API_KEY export NO_PROXY=\"localhost,127.0.0.1,::1,api.anthropic.com,auth.anthropic.com,claude.ai,statsig.anthropic.com,sentry.io\" export no_proxy=\$NO_PROXY # Override TERM if the host propagated a value the sandbox's terminfo # database doesn't know (Kitty, WezTerm, Alacritty direct modes, etc.). # xterm-256color is almost universally recognized and renders fine. if ! infocmp \"\${TERM:-}\" >/dev/null 2>&1; then export TERM=xterm-256color fi EOF chmod +x /etc/profile.d/claude-sbx.sh" } ############################################# # Seed Claude config into sandbox ############################################# # Copy-in, not bind-mount -- agent can mutate its copy, host originals untouched. # Selective: only the paths in CLAUDE_COPY_PATHS get copied (durable config, # not ephemeral state). See config/config.sh for the default list. seed_claude_config() { local src="${REAL_HOME}/.claude" [ -d "$src" ] || die "host ~/.claude not found at $src" local paths=() for p in "${CLAUDE_COPY_PATHS[@]}"; do if [ -e "${src}/${p}" ]; then paths+=("$p") else warn "skip ${p} -- not present at ${src}/${p}" fi done if [ ${#paths[@]} -eq 0 ]; then warn "nothing to copy, skipping" return 0 fi log "packing ~/.claude (${paths[*]})..." local tgz="${RUNTIME_DIR}/claude-config.tgz" tar -czf "$tgz" -C "$src" "${paths[@]}" local guest_tgz guest_tgz=$(_stage_file "$tgz") log "extracting into /home/agent/.claude..." sbx exec -u root "$SANDBOX_NAME" -- bash -c " set -e rm -rf /home/agent/.claude mkdir -p /home/agent/.claude tar -xzf '${guest_tgz}' -C /home/agent/.claude chown -R agent:agent /home/agent/.claude " rm -f "$tgz" } ############################################# # Subcommands ############################################# cmd_start() { require_tools ensure_workspace log "--- [1/5] egress policy" apply_policy log "--- [2/5] create sandbox (workspace bind-mounted: ${WORKSPACE_HOST})" if sandbox_exists; then log "sandbox '${SANDBOX_NAME}' already exists, skipping create" else sbx create shell "$WORKSPACE_HOST" --name "$SANDBOX_NAME" fi log "--- [3/5] install Claude Code (${CLAUDE_CODE_VERSION})" install_claude_code log "--- [4/5] persistent env" install_persistent_env log "--- [5/5] seed Claude config" seed_claude_config _stage_cleanup cat < ${WORKSPACE_HOST} (same path inside VM) Other ops: $(basename "$0") sync-config # refresh ~/.claude inside the VM $(basename "$0") reload-allowlist # re-apply egress policy $(basename "$0") status / stop EOF } cmd_stop() { require_tools if sandbox_exists; then log "destroying sandbox '${SANDBOX_NAME}'..." sbx rm "$SANDBOX_NAME" else log "sandbox '${SANDBOX_NAME}' not present" fi } cmd_shell() { require_tools sandbox_exists || die "sandbox '${SANDBOX_NAME}' not running. Run: $(basename "$0") start" exec sbx run "$SANDBOX_NAME" } cmd_reload_allowlist() { require_tools apply_policy } cmd_sync_config() { require_tools sandbox_exists || die "sandbox '${SANDBOX_NAME}' not running. Run: $(basename "$0") start" seed_claude_config _stage_cleanup } cmd_status() { require_tools echo "--- sandboxes ---" sbx ls 2>&1 || true echo "" echo "--- policy ---" sbx policy ls 2>&1 || true } main() { local cmd="${1:-}" [ -n "${1:-}" ] && shift case "$cmd" in start) cmd_start "$@" ;; stop) cmd_stop "$@" ;; shell) cmd_shell "$@" ;; reload-allowlist) cmd_reload_allowlist "$@" ;; sync-config) cmd_sync_config "$@" ;; status) cmd_status "$@" ;; ""|-h|--help) usage ;; *) echo "Unknown command: $cmd" >&2; usage; exit 1 ;; esac } main "$@"