claudebox/claudebox.sh
Christopher Mühl 72dfde91a8
feat!: thin layer over Claude /sandbox + nftables CIDR block
Drops bwrap orchestration, history overlay, forced
--dangerously-skip-permissions, SANDBOX.md injection, env-file
loading. claude --sandbox handles kernel isolation; claudebox
manages settings.local.json sandbox.* keys and installs nftables
rules matched on claude-sandbox.slice cgroup membership.

New flake outputs: nixosModules.default + checks.wrapper-syntax.
Docs updated to reflect the layered (not structural) FS guarantee.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 12:19:40 +02:00

246 lines
7.3 KiB
Bash

# 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[@]}"