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>
246 lines
7.3 KiB
Bash
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[@]}"
|