# claudebox v2 — thin layer over `claude` built-in sandbox. # # What this script does: # 1. Writes hardened sandbox.* config into ./.claude/settings.local.json # (deep-merge: preserve existing non-sandbox keys, replace sandbox subtree). # 2. Launches `claude` inside the systemd user slice `claude-sandbox.slice`. # The NixOS module shipped with this flake hangs nftables CIDR-block # rules off that slice — blocking Tailscale CGNAT, RFC1918, MagicDNS. # # What this script does NOT do (intentional, post-rewrite): # - No bwrap orchestration. Claude's built-in /sandbox handles namespaces. # - No SANDBOX.md injection. User puts comma/nix info in their root CLAUDE.md. # - No per-project history overlay. Claude reads ~/.claude directly. # - No forced --dangerously-skip-permissions. /sandbox auto-allow is enough. # - No env file loading. Use direnv or .envrc. # Flags SKIP_AUDIT=false DRY_RUN=false CHECK_MODE=false NO_SLICE=false CLAUDE_ARGS=() while (( $# > 0 )); do case "$1" in --yes|-y) SKIP_AUDIT=true ;; --dry-run) DRY_RUN=true ;; --check) CHECK_MODE=true ;; --no-slice) NO_SLICE=true ;; --) shift; CLAUDE_ARGS+=("$@"); break ;; *) CLAUDE_ARGS+=("$1") ;; esac shift done # ANSI if [[ -t 2 && -z "${NO_COLOR:-}" ]]; then BOLD=$'\033[1m' RESET=$'\033[0m' CYAN=$'\033[36m' YELLOW=$'\033[33m' GREEN=$'\033[32m' RED=$'\033[31m' else BOLD="" RESET="" CYAN="" YELLOW="" GREEN="" RED="" fi CLAUDE_BIN="$(command -v claude)" JQ_BIN="$(command -v jq)" # --check: verify prerequisites and exit if [[ "$CHECK_MODE" == true ]]; then pass=true check() { if eval "$1" &>/dev/null; then echo "${GREEN}OK${RESET} $2" >&2 else echo "${RED}FAIL${RESET} $2" >&2 pass=false fi } warn() { if eval "$1" &>/dev/null; then echo "${GREEN}OK${RESET} $2" >&2 else echo "${YELLOW}WARN${RESET} $2" >&2 fi } echo "claudebox prerequisites:" >&2 echo "" >&2 check "command -v claude" "claude binary in PATH" check "command -v jq" "jq in PATH" check "command -v systemd-run" "systemd-run in PATH" check "systemctl --user is-active default.target" "systemd user instance running" warn "systemctl is-active --quiet nftables" "nftables service active" warn "nft list chain inet claudebox output 2>/dev/null | grep -q claude-sandbox" \ "nftables 'claudebox output' chain present (NixOS module loaded)" warn "[[ -v ANTHROPIC_API_KEY ]]" "ANTHROPIC_API_KEY set in env" 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 # Compute project root for settings.local.json placement. # Worktree-aware: git common dir resolves to the canonical repo, not the worktree. compute_canonical_root() { local cwd="$1" git_common git_common=$(git -C "$cwd" rev-parse --git-common-dir 2>/dev/null) || { echo "$cwd"; return } [[ "$git_common" != /* ]] && git_common="$cwd/$git_common" dirname "$(readlink -f "$git_common")" } CWD="$(pwd)" PROJECT_ROOT="$(compute_canonical_root "$CWD")" SETTINGS_DIR="$PROJECT_ROOT/.claude" SETTINGS_FILE="$SETTINGS_DIR/settings.local.json" # Hardened sandbox config. # - filesystem.denyRead: belt+suspenders against credential paths claude reads # by default. The mount-namespace-based isolation in /sandbox doesn't cover # reads (default-allow); denyRead is the documented mechanism. # - network.allowedDomains: opinionated baseline for typical dev work. # Override by editing settings.local.json after first run. # - allowManagedDomainsOnly: enforce strict allowlist, refuse other egress. SANDBOX_CONFIG=$(cat <<'JSON' { "sandbox": { "enabled": true, "filesystem": { "denyRead": [ "~/.ssh", "~/.gnupg", "~/.aws", "~/.config/gcloud", "~/.config/age", "~/.config/sops", "~/.config/tailscale", "/var/lib/tailscale", "/run/agenix", "/run/secrets" ] }, "network": { "allowedDomains": [ "api.anthropic.com", "statsig.anthropic.com", "github.com", "*.github.com", "*.githubusercontent.com", "objects.githubusercontent.com", "registry.npmjs.org", "*.npmjs.org", "pypi.org", "*.pypi.org", "files.pythonhosted.org", "crates.io", "*.crates.io", "static.crates.io", "rubygems.org", "cache.nixos.org", "*.cachix.org", "channels.nixos.org" ], "allowManagedDomainsOnly": true } } } JSON ) # Merge sandbox config into settings.local.json. # Existing top-level keys preserved (model, env, MCP, etc.). # `sandbox` subtree replaced wholesale — we own it, no recursive merge. merge_settings() { mkdir -p "$SETTINGS_DIR" if [[ -f "$SETTINGS_FILE" ]]; then local merged merged=$("$JQ_BIN" -s '.[0] + {sandbox: .[1].sandbox}' \ "$SETTINGS_FILE" <(echo "$SANDBOX_CONFIG")) printf '%s\n' "$merged" > "$SETTINGS_FILE" else printf '%s\n' "$SANDBOX_CONFIG" | "$JQ_BIN" . > "$SETTINGS_FILE" fi # Ensure settings.local.json is gitignored. local gi="$PROJECT_ROOT/.gitignore" if [[ -d "$PROJECT_ROOT/.git" || -f "$PROJECT_ROOT/.git" ]] \ && ! grep -qE '^\.claude/settings\.local\.json$' "$gi" 2>/dev/null \ && ! grep -qE '^\.claude/$' "$gi" 2>/dev/null; then echo "${YELLOW}note: .claude/settings.local.json not in .gitignore${RESET}" >&2 fi } # Audit: show what's being applied before launch. print_audit() { echo "${BOLD}${CYAN}=== claudebox ===${RESET}" >&2 echo "" >&2 echo "${BOLD}Project root:${RESET} $PROJECT_ROOT" >&2 echo "${BOLD}Settings:${RESET} $SETTINGS_FILE" >&2 echo "" >&2 echo "${BOLD}Sandbox config (managed by claudebox):${RESET}" >&2 printf '%s\n' "$SANDBOX_CONFIG" | "$JQ_BIN" -C . | sed 's/^/ /' >&2 echo "" >&2 echo "${BOLD}Network slice:${RESET}" >&2 if [[ "$NO_SLICE" == true ]]; then echo " ${YELLOW}DISABLED${RESET} (--no-slice) — CIDR block (Tailscale, RFC1918) not enforced" >&2 else echo " claude-sandbox.slice (nftables drops Tailscale CGNAT, RFC1918, MagicDNS)" >&2 fi echo "" >&2 echo "${BOLD}Launch:${RESET}" >&2 if [[ "$NO_SLICE" == true ]]; then echo " $CLAUDE_BIN ${CLAUDE_ARGS[*]:-}" >&2 else echo " systemd-run --user --scope --slice=claude-sandbox.slice -- $CLAUDE_BIN ${CLAUDE_ARGS[*]:-}" >&2 fi echo "" >&2 } if [[ "$SKIP_AUDIT" != true && "$DRY_RUN" != true ]]; then print_audit if [[ -t 0 ]]; then echo -n "Proceed? [Y/n] " >&2 read -r response < /dev/tty response="${response,,}" if [[ "$response" == "n" || "$response" == "no" ]]; then echo "Aborted." >&2 exit 1 fi else echo "${RED}stdin not a tty. Pass --yes or -y to skip confirmation.${RESET}" >&2 exit 1 fi fi # Apply settings merge after audit/confirmation. Skipped in dry-run. if [[ "$DRY_RUN" != true ]]; then merge_settings fi # Build launch command. if [[ "$NO_SLICE" == true ]]; then LAUNCH_CMD=("$CLAUDE_BIN" "${CLAUDE_ARGS[@]}") else LAUNCH_CMD=( systemd-run --user --scope --quiet --slice=claude-sandbox.slice --working-directory="$CWD" -- "$CLAUDE_BIN" "${CLAUDE_ARGS[@]}" ) fi if [[ "$DRY_RUN" == true ]]; then printf '%q ' "${LAUNCH_CMD[@]}" >&2 echo "" >&2 exit 0 fi exec "${LAUNCH_CMD[@]}"