diff --git a/.dockerignore b/.dockerignore index 92213e0..fac5ce7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,2 @@ .git *.md -.claudebox-credentials.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4874dcc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +# Runs the test suites on push and pull requests. +# +# The launcher unit tests and the Docker integration tests run on GitHub-hosted +# Linux runners. The Apple `container` integration test needs a real Apple +# Silicon Mac (macOS 26+) with nested virtualization — which GitHub-hosted macOS +# runners do NOT provide — so it only runs on a self-hosted macOS runner, gated +# behind the HAS_MACOS_RUNNER repository variable. Set that variable to 'true' +# once you have such a runner registered; until then the job is skipped. + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + linux: + name: Launcher + Docker (ubuntu) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Launcher unit tests + run: test/unit.sh + + - name: Docker integration tests + run: test/run.sh + + container: + name: Apple container integration (self-hosted macOS) + # GitHub-hosted macOS runners can't run Apple `container` (no nested virt), + # so this targets a self-hosted Apple Silicon runner and is skipped unless + # the repo variable HAS_MACOS_RUNNER is set to 'true'. + if: ${{ vars.HAS_MACOS_RUNNER == 'true' }} + runs-on: [self-hosted, macOS] + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Apple container integration tests + run: test/run-container.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d1f95d6..17c0fff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,16 +24,16 @@ jobs: packages: write steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Set up QEMU (for arm64 emulation) - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -41,7 +41,7 @@ jobs: - name: Derive image tags id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 with: images: ${{ env.IMAGE }} tags: | @@ -50,7 +50,7 @@ jobs: type=raw,value=latest - name: Build and push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . platforms: linux/amd64,linux/arm64 diff --git a/.gitignore b/.gitignore index 13c80a6..e00ad50 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ -.claudebox-credentials.json .DS_Store test/build.log +test/build-container.log +scratch/ diff --git a/Dockerfile b/Dockerfile index 3925468..ada856f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -70,8 +70,22 @@ RUN if [ "$INSTALL_PYTHON" = "true" ]; then \ # classic builder, which doesn't set it. ARG GO_VERSION=1.24.2 ARG TARGETARCH +# Pinned SHA256s for the Go tarball, per arch (from https://go.dev/dl/). The +# download is checksum-verified before extraction, so a tampered or corrupted +# tarball fails the build instead of landing in the image. +ARG GO_SHA256_amd64=68097bd680839cbc9d464a0edce4f7c333975e27a90246890e9f1078c7e702ad +ARG GO_SHA256_arm64=756274ea4b68fa5535eb9fe2559889287d725a8da63c6aae4d5f23778c229f4b RUN if [ "$INSTALL_GO" = "true" ]; then \ - curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-${TARGETARCH:-amd64}.tar.gz" | tar -C /usr/local -xzf -; \ + arch="${TARGETARCH:-amd64}"; \ + case "$arch" in \ + amd64) sha="$GO_SHA256_amd64" ;; \ + arm64) sha="$GO_SHA256_arm64" ;; \ + *) echo "No pinned Go SHA256 for arch: $arch" >&2; exit 1 ;; \ + esac; \ + curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-${arch}.tar.gz" -o /go.tgz; \ + echo "${sha} /go.tgz" | sha256sum -c -; \ + tar -C /usr/local -xzf /go.tgz; \ + rm /go.tgz; \ fi ENV PATH="/usr/local/go/bin:/root/go/bin:${PATH}" diff --git a/README.md b/README.md index 3db6bf9..0fb28c2 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,8 @@ claudebox -p 3000:3000 # then visit http://localhost:3000 on your machine ``` +Published ports bind to `127.0.0.1` on your host by default, so the service is reachable from your machine but not from other devices on your LAN. To deliberately expose it to the LAN, pin an explicit host IP — e.g. `-p 0.0.0.0:3000:3000`. + The format is `-p HOST:CONTAINER`. Repeat the flag for multiple ports — say a frontend and its API: ```bash @@ -130,6 +132,35 @@ Claudebox flags and Claude flags can be mixed freely: claudebox --ssh -p 3000:3000 --docker --resume ``` +### Running an autonomous loop (Ralph) + +By default the container just launches Claude. `--exec` instead runs a command +of your choosing in the sandbox — handy for an unattended orchestrator that +spawns Claude itself, like [Ralph](https://github.com/snarktank/ralph) (a loop +that re-runs Claude until every PRD item passes). + +Ralph's loop and all the Claude iterations it spawns run inside **one** +sandboxed container. Point `--exec` at Ralph's script (it lives in your mounted +project): + +```bash +claudebox --exec 'bash scripts/ralph/ralph.sh --tool claude 20' +``` + +Each iteration is a fresh `claude --dangerously-skip-permissions --print` +process (clean context), while the repo, installed dependencies, and build +caches persist across iterations so Ralph's typecheck/test steps stay fast. +Commits land in your real repo because the project is mounted at its host path. +The LAN firewall and the `CAP_NET_ADMIN` drop wrap the whole loop, so neither +Ralph nor any Claude it spawns can reach your local network or alter the +firewall — the same guarantees as a plain `claudebox` run, just for longer. + +> Ralph commits but doesn't push. If you want it to push over SSH, add `--ssh`. +> If it needs its own isolated Docker daemon for builds/tests, add `--docker`. + +See [ralph.md](ralph.md) for a fuller walkthrough — separate-checkout setup, +auth notes, and gotchas (e.g. use absolute paths, not `~`, in `--exec`). + ## Flags | Flag | Description | @@ -138,7 +169,9 @@ claudebox --ssh -p 3000:3000 --docker --resume | `--host-docker` | Mount the host Docker socket — grants host-root power and escapes the sandbox; use only when you need the host's daemon | | `--allow-lan` | Allow the container to reach your local network (the LAN firewall is on by default) | | `--ssh` | Forward your local SSH agent into the container for git over SSH | -| `-p`, `--port ` | Publish a container port to your host so you can reach it — e.g. `-p 3000:3000`, then open http://localhost:3000 (repeatable) | +| `--no-history` | Don't share your host `~/.claude` into the box — config (settings, CLAUDE.md, hooks, …) is still mounted read-only and auth still works, but sessions/history stay host-only and the box's own history is discarded on exit | +| `--exec ` | Run `` in the sandbox instead of launching Claude — for loops/orchestrators that spawn Claude themselves (e.g. [Ralph](#running-an-autonomous-loop-ralph)). The LAN firewall and `CAP_NET_ADMIN` drop still apply | +| `-p`, `--port ` | Publish a container port to your host, bound to `127.0.0.1` so it isn't exposed to your LAN — e.g. `-p 3000:3000`, then open http://localhost:3000 (repeatable). Pin an explicit host IP like `0.0.0.0:3000:3000` to expose it | | `--build` | (Re)build the image before launching | | `--with ` | With `--build`, include only these optional languages: `go`, `python`, `rust` | | `--help`, `-h` | Show usage and exit | @@ -198,12 +231,40 @@ Optional (included by default, configurable with `--with`): - **Python** 3 + pip + venv - **Rust** (stable via rustup) +## Experimental: macOS VM backend + +`claudebox` sandboxes Claude in a **Linux container**. For a sandbox that mirrors +your Mac's userland exactly (BSD tools, Keychain, macOS paths) there's an +experimental **macOS VM** backend, `claudebox-vm`, built on [tart]. It clones a +prepared macOS base image, boots it, and runs Claude over SSH with your project +and credentials forwarded in. Like the container, the VM gets internet but no +LAN by default, enforced from the host so a root agent inside the guest can't +undo it. See **[claudebox-vm.md](claudebox-vm.md)** for dependencies and setup. +(Experimental — Apple Silicon only.) + +Your `~/.claude` is shared into the guest so config and sessions persist, with one +exception: `settings.json` is copied in one-way instead of shared read-write. Its +`hooks` and `statusLine.command` entries are shell commands Claude executes, so a +writable share would let a sandboxed agent edit them and have that command run on +your host the next time you start Claude there. Copying it in keeps the guest's +config working while making sure guest edits can't escape back to the host. + +[tart]: https://tart.run + ## Testing -`test/run.sh` builds a lean image and verifies the sandbox end to end — the firewall rules, the `CAP_NET_ADMIN` drop, internet egress, LAN blocking, and rootless Docker (`--docker`). Run it after changing the Dockerfile, the entrypoint, or the `claudebox` wrapper: +There are three suites. Run them after changing the Dockerfile, the entrypoint, or the `claudebox` wrapper: ```bash -test/run.sh +test/unit.sh # launcher logic — no runtime needed +test/run.sh # Docker integration +test/run-container.sh # Apple container integration (macOS only) ``` -It exits non-zero if any check fails. On hosts that block the user-namespace mapping rootless Docker needs (e.g. some nested CI containers), the rootless test automatically falls back to `--privileged` and says so — normal `claudebox --docker` runs unprivileged. +- **`test/unit.sh`** exercises the launcher's decision logic with fake `container`/`docker` executables on `PATH`, so it needs no real runtime and runs anywhere: runtime selection (`--runtime`, `CLAUDEBOX_RUNTIME`, auto-detect), the `--docker` rootful-vs-rootless split, LAN wiring, the settings banner, `--host-docker`, and the Apple Silicon hint. +- **`test/run.sh`** builds a lean image with Docker and verifies the sandbox end to end — the firewall rules, the `CAP_NET_ADMIN` drop, internet egress, LAN blocking, and rootless Docker (`--docker`). On hosts that block the user-namespace mapping rootless Docker needs (e.g. some nested CI containers), the rootless test automatically falls back to `--privileged` and says so — normal `claudebox --docker` runs unprivileged. +- **`test/run-container.sh`** is the same end-to-end check driven through Apple `container` and the rootful in-sandbox Docker path. It **skips cleanly** when `container` isn't installed. + +Each exits non-zero if any check fails. + +**CI note:** `unit.sh` and `run.sh` run on GitHub-hosted `ubuntu-latest`. `run-container.sh` must be run **locally on an Apple Silicon Mac** (macOS 26+) — GitHub-hosted macOS runners can't run it because Apple `container` needs nested virtualization, which those runners don't provide. Run it on your dev Mac before releasing changes that touch the container-runtime paths. diff --git a/claudebox b/claudebox index 3a8ef1c..66403f2 100755 --- a/claudebox +++ b/claudebox @@ -16,22 +16,55 @@ BUILD_WITH="" BLOCK_LAN=true RUN_DIND=false MOUNT_DOCKER_SOCK=false +# --no-history: don't share the host ~/.claude into the box. Config surfaces are +# still mounted read-only, but sessions/history/memory stay host-only and the +# box's own history is discarded with the container. +NO_HISTORY=false PORT_ARGS=() +PORT_SPECS=() +PORTS_DISP="" +EXTRA_MOUNTS=() CLAUDE_ARGS=() +# Optional command override. With --exec the container runs this command (in a +# shell) instead of `claude`, while keeping the LAN firewall and CAP_NET_ADMIN +# drop wrapped around it — useful for driving loops/orchestrators in the sandbox +# (e.g. ralph: --exec 'bash scripts/ralph/ralph.sh --tool claude'). +EXEC_CMD="" + +# Container runtime. Prefer Apple `container` when it's present and running — +# each container is its own hypervisor-isolated micro-VM, which (unlike Docker +# Desktop) can host a working Docker daemon inside the sandbox. Otherwise fall +# back to Docker. Override with --runtime or CLAUDEBOX_RUNTIME=container|docker. +RUNTIME="${CLAUDEBOX_RUNTIME:-}" + +# Resolve a host path to its absolute form (works for files and dirs). Returns +# non-zero if the path doesn't exist. +resolve_path() { + local p="$1" + if [[ -d "$p" ]]; then + (cd "$p" && pwd) + elif [[ -e "$p" ]]; then + printf '%s/%s' "$(cd "$(dirname "$p")" && pwd)" "$(basename "$p")" + else + return 1 + fi +} # Available optional tools ALL_TOOLS="go,python,rust" print_help() { cat <<'EOF' -claudebox — run Claude Code in a sandboxed Docker container. +claudebox — run Claude Code in a sandboxed container. Usage: claudebox [claudebox flags] [claude flags] -By default the container can reach the internet but not your local network, -and has no Docker access. Any flags not listed below are passed through to -Claude Code (e.g. --resume, --model, --print, --dangerously-skip-permissions). +Uses Apple `container` when it's installed and running (each container is its +own micro-VM), otherwise Docker. By default the container can reach the internet +but not your local network, and has no Docker access. Any flags not listed below +are passed through to Claude Code (e.g. --resume, --model, --print, +--dangerously-skip-permissions). claudebox flags: --docker Start an isolated rootless Docker daemon inside the @@ -42,7 +75,23 @@ claudebox flags: --allow-lan Allow the container to reach your local network (the LAN firewall is on by default). --ssh Forward your local SSH agent for git over SSH. - -p, --port Publish a container port to your host (repeatable). + --no-history Don't share your host ~/.claude into the box. Config + (settings, CLAUDE.md, hooks, ...) is still mounted + read-only, but sessions/history stay host-only and the + box's own history is discarded on exit. + -p, --port Publish a container port to your host, bound to 127.0.0.1 + so it isn't exposed to your LAN (repeatable). Pin an + explicit host IP (e.g. 0.0.0.0:8080:80) to expose it. + --mount Also mount a host path into the container at the same + path (repeatable). Useful for handing Claude files that + live outside the project dir, e.g. screenshots. + --exec Run in the sandbox (via a shell) instead of + launching claude. The LAN firewall and CAP_NET_ADMIN drop + still apply. Use for loops/orchestrators that spawn claude + themselves, e.g. --exec 'bash scripts/ralph/ralph.sh + --tool claude'. + --runtime Force the container runtime: container or docker + (default: auto — container if available, else docker). --build (Re)build the image before launching. --with With --build, include only these optional languages: go, python, rust (comma-separated). @@ -73,6 +122,10 @@ while [[ $# -gt 0 ]]; do BLOCK_LAN=false shift ;; + --no-history) + NO_HISTORY=true + shift + ;; --docker) RUN_DIND=true shift @@ -81,8 +134,20 @@ while [[ $# -gt 0 ]]; do MOUNT_DOCKER_SOCK=true shift ;; + --runtime) + RUNTIME="$2" + shift 2 + ;; -p|--port) - PORT_ARGS+=(-p "$2") + PORT_SPECS+=("$2") + shift 2 + ;; + --mount) + EXTRA_MOUNTS+=("$2") + shift 2 + ;; + --exec) + EXEC_CMD="$2" shift 2 ;; *) @@ -92,6 +157,35 @@ while [[ $# -gt 0 ]]; do esac done +# On Apple Silicon Macs, recommend Apple's `container` runtime when it isn't +# installed. Unlike Docker Desktop's single shared LinuxKit VM, `container` runs +# each container in its own micro-VM — stronger isolation, and the only way to +# run a working Docker daemon *inside* the sandbox (see --docker) on macOS. +if [[ "$(uname)" == "Darwin" && "$(uname -m)" == "arm64" ]] \ + && ! command -v container >/dev/null 2>&1; then + echo "💡 You're on Apple Silicon. Consider installing Apple's 'container' runtime for" >&2 + echo " stronger per-container VM isolation (and working Docker-in-sandbox):" >&2 + echo " https://github.com/apple/container/releases" >&2 +fi + +# Resolve the container runtime: an explicit choice wins, otherwise prefer Apple +# `container` when it's installed and its services are running, else Docker. +if [[ -z "$RUNTIME" ]]; then + if command -v container >/dev/null 2>&1 && container system status >/dev/null 2>&1; then + RUNTIME=container + elif command -v docker >/dev/null 2>&1; then + RUNTIME=docker + else + echo "Error: no container runtime found. Install Apple 'container' (macOS 26+," >&2 + echo " Apple Silicon) or Docker, then re-run." >&2 + exit 1 + fi +fi +if ! command -v "$RUNTIME" >/dev/null 2>&1; then + echo "Error: requested runtime '$RUNTIME' is not on PATH." >&2 + exit 1 +fi + # Build the image if requested if [[ "$DO_BUILD" == true ]]; then SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -107,57 +201,126 @@ if [[ "$DO_BUILD" == true ]]; then done fi - echo "Building claudebox image..." >&2 - docker build ${BUILD_ARGS[@]+"${BUILD_ARGS[@]}"} -t "$IMAGE_NAME" "$SCRIPT_DIR" + echo "Building claudebox image ($RUNTIME)..." >&2 + "$RUNTIME" build ${BUILD_ARGS[@]+"${BUILD_ARGS[@]}"} -t "$IMAGE_NAME" "$SCRIPT_DIR" echo "Build complete." >&2 fi -if ! docker image inspect "$IMAGE_NAME" &>/dev/null; then +if ! "$RUNTIME" image inspect "$IMAGE_NAME" &>/dev/null; then SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - echo "claudebox image not found. Building..." >&2 - docker build -t "$IMAGE_NAME" "$SCRIPT_DIR" + echo "claudebox image not found. Building ($RUNTIME)..." >&2 + "$RUNTIME" build -t "$IMAGE_NAME" "$SCRIPT_DIR" echo "Build complete." >&2 fi # Collect volumes to mount. No fixed --name: Docker assigns a unique name so # claudebox can run multiple times (even in the same folder) without conflicts. -DOCKER_ARGS=( +RUN_ARGS=( --rm -v "$(pwd):${CONTAINER_WORKDIR}" -w "${CONTAINER_WORKDIR}" ) +# Cap the process count under Docker so a runaway or malicious agent can't +# fork-bomb the shared host kernel. Overridable via CLAUDEBOX_PIDS_LIMIT. Apple +# `container` runs each box in its own micro-VM, so a fork bomb stays confined to +# that VM and no host-level cap is needed there. +if [[ "$RUNTIME" == docker ]]; then + RUN_ARGS+=(--pids-limit "${CLAUDEBOX_PIDS_LIMIT:-4096}") +fi + +# On the default path (no Docker access) trim the sandbox further: drop +# CAP_NET_RAW (raw sockets / packet crafting the box never needs) and block +# privilege escalation through setuid binaries. Not a full cap-drop — the box +# runs as root and still needs DAC_OVERRIDE/FOWNER to edit your mounted files. +# Skipped when Docker access is on (those paths need a broader capability/seccomp +# profile) and under Apple `container`, whose per-VM isolation covers this and +# whose flag support here is unverified. +if [[ "$RUNTIME" == docker && "$RUN_DIND" != true && "$MOUNT_DOCKER_SOCK" != true ]]; then + RUN_ARGS+=(--cap-drop NET_RAW --security-opt no-new-privileges) +fi + +# Publish ports. Bind each to loopback by default (Docker's default is 0.0.0.0, +# which exposes the service to your whole LAN) so a server Claude starts is +# reachable from your machine but not from other hosts. A spec that already pins +# a host IP (hostIP:hostPort:ctr, including a bracketed IPv6 host) is respected — +# put 0.0.0.0 there to deliberately expose it to the LAN. Only rewritten under +# Docker; Apple `container` uses per-VM networking with different semantics. +for spec in ${PORT_SPECS[@]+"${PORT_SPECS[@]}"}; do + if [[ "$RUNTIME" == docker && "$spec" != *"["* ]]; then + hostpart="${spec%%/*}" # drop any /proto suffix + colons="${hostpart//[!:]/}" # keep only colons, to count them + case "${#colons}" in + 0) spec="127.0.0.1::${spec}" ;; # bare container port -> random host port + 1) spec="127.0.0.1:${spec}" ;; # hostPort:ctrPort + esac # 2+ colons: host IP already set, leave it + fi + PORT_ARGS+=(-p "$spec") + PORTS_DISP+="${PORTS_DISP:+, }$spec" +done + # Forward port mappings if [[ ${#PORT_ARGS[@]} -gt 0 ]]; then - DOCKER_ARGS+=("${PORT_ARGS[@]}") + RUN_ARGS+=("${PORT_ARGS[@]}") fi +# Mount any extra host paths (--mount) at the same path inside the container, so +# a path Claude is given resolves identically inside and out. Non-existent paths +# are skipped with a warning rather than failing the whole launch. +EXTRA_MOUNT_PATHS=() +for raw in ${EXTRA_MOUNTS[@]+"${EXTRA_MOUNTS[@]}"}; do + if abs="$(resolve_path "$raw")"; then + RUN_ARGS+=(-v "${abs}:${abs}") + EXTRA_MOUNT_PATHS+=("$abs") + else + echo "Warning: --mount path not found, skipping: $raw" >&2 + fi +done + # Block egress to the local network. NET_ADMIN lets the entrypoint install the # firewall rules; the entrypoint then drops the capability before launching # Claude so it can't undo them. if [[ "$BLOCK_LAN" == true ]]; then - DOCKER_ARGS+=(--cap-add=NET_ADMIN -e CLAUDEBOX_BLOCK_LAN=true) + RUN_ARGS+=(--cap-add NET_ADMIN -e CLAUDEBOX_BLOCK_LAN=true) fi -# Run an isolated, rootless Docker daemon inside the container (--docker). It -# needs unconfined seccomp/apparmor to set up its user namespaces, but NOT -# --privileged or host root, so the sandbox boundary holds. +# Run an isolated Docker daemon inside the container (--docker). if [[ "$RUN_DIND" == true ]]; then - DOCKER_ARGS+=( - -e CLAUDEBOX_DIND=true - --security-opt seccomp=unconfined - --security-opt apparmor=unconfined - ) + RUN_ARGS+=(-e CLAUDEBOX_DIND=true) + if [[ "$RUNTIME" == container ]]; then + # Apple container: the box is its own micro-VM, so grant full caps and let + # the entrypoint run a normal rootful daemon inside it. Full power stays + # within the disposable VM, isolated from the host by the hypervisor — + # which is why Docker-in-sandbox works here but not under Docker Desktop. + RUN_ARGS+=(--cap-add ALL -e CLAUDEBOX_DIND_ROOTFUL=true) + else + # Docker: a rootless daemon needs unconfined seccomp/apparmor to set up its + # user namespaces, but NOT --privileged or host root, so the sandbox + # boundary holds. (Works on native Linux hosts; the nested user-namespace + # step is blocked under Docker Desktop.) + RUN_ARGS+=( + --security-opt seccomp=unconfined + --security-opt apparmor=unconfined + ) + fi fi # Forward SSH agent if requested and available if [[ "$FORWARD_SSH" == true ]]; then - if [[ "$(uname)" == "Darwin" ]]; then + if [[ "$RUNTIME" == container ]]; then + # Apple container proxies host unix sockets into the guest, so the real + # macOS $SSH_AUTH_SOCK can be bind-mounted directly (no Docker Desktop proxy). + if [[ -n "${SSH_AUTH_SOCK:-}" ]]; then + RUN_ARGS+=(-v "${SSH_AUTH_SOCK}:/tmp/ssh-agent.sock" -e "SSH_AUTH_SOCK=/tmp/ssh-agent.sock") + else + echo "Warning: --ssh under Apple container needs SSH_AUTH_SOCK set on the host." >&2 + fi + elif [[ "$(uname)" == "Darwin" ]]; then # Docker Desktop proxies the host SSH agent at this fixed path. The real # macOS $SSH_AUTH_SOCK lives outside the Linux VM and can't be mounted. - DOCKER_ARGS+=(-v /run/host-services/ssh-auth.sock:/tmp/ssh-agent.sock -e "SSH_AUTH_SOCK=/tmp/ssh-agent.sock") + RUN_ARGS+=(-v /run/host-services/ssh-auth.sock:/tmp/ssh-agent.sock -e "SSH_AUTH_SOCK=/tmp/ssh-agent.sock") elif [[ -n "${SSH_AUTH_SOCK:-}" ]]; then - DOCKER_ARGS+=(-v "${SSH_AUTH_SOCK}:/tmp/ssh-agent.sock" -e "SSH_AUTH_SOCK=/tmp/ssh-agent.sock") + RUN_ARGS+=(-v "${SSH_AUTH_SOCK}:/tmp/ssh-agent.sock" -e "SSH_AUTH_SOCK=/tmp/ssh-agent.sock") else echo "Warning: --ssh passed but SSH_AUTH_SOCK is not set." >&2 fi @@ -165,36 +328,95 @@ fi # Forward the Anthropic API key if set if [[ -n "${ANTHROPIC_API_KEY:-}" ]]; then - DOCKER_ARGS+=(-e "ANTHROPIC_API_KEY") + RUN_ARGS+=(-e "ANTHROPIC_API_KEY") fi -# Forward Claude config directory so settings/memory persist +# Forward a command override (--exec). The entrypoint runs it in place of claude +# once the sandbox is set up. Passthrough claude args are meaningless in this +# mode, so warn if any were also given. +if [[ -n "$EXEC_CMD" ]]; then + RUN_ARGS+=(-e "CLAUDEBOX_EXEC=$EXEC_CMD") + if [[ ${#CLAUDE_ARGS[@]} -gt 0 ]]; then + echo "Warning: --exec is set, so passthrough claude args are ignored: ${CLAUDE_ARGS[*]}" >&2 + fi +fi + +# Forward Claude config directory so settings/memory persist. With --no-history +# the shared read-write mount is skipped — only the read-only config surfaces +# below cross into the box, so sessions/history/memory stay host-only and +# whatever the box writes to its own ~/.claude dies with the container. CLAUDE_CONFIG_DIR="${HOME}/.claude" if [[ -d "$CLAUDE_CONFIG_DIR" ]]; then - DOCKER_ARGS+=(-v "${CLAUDE_CONFIG_DIR}:/root/.claude") + if [[ "$NO_HISTORY" == false ]]; then + RUN_ARGS+=(-v "${CLAUDE_CONFIG_DIR}:/root/.claude") + fi + # Parts of ~/.claude are a code-execution / instruction-injection surface that + # Claude reads on the *host*: settings.json's `hooks` and `statusLine.command` + # are shell commands, hook/plugin scripts are executables, and commands/, + # agents/, CLAUDE.md steer future runs. The mount above is read-write, so a + # sandboxed agent could rewrite any of them and have it take effect on the host + # the next time you run Claude natively — a guest->host escape. Re-mount each of + # these read-only on top of the shared dir so the box can read them but not + # modify them. Data dirs (projects/, todos/, history/, …) stay writable so + # sessions persist. This is a deny-list of known surfaces — see to-fix.md. + for ro in settings.json settings.local.json CLAUDE.md statusline-command.sh hooks commands agents plugins skills; do + if [[ -e "${CLAUDE_CONFIG_DIR}/${ro}" ]]; then + RUN_ARGS+=(-v "${CLAUDE_CONFIG_DIR}/${ro}:/root/.claude/${ro}:ro") + fi + done fi -# Forward Claude config file (.claude.json) if it exists +# Forward Claude config file (.claude.json) if it exists. Under --no-history it +# is staged read-only instead of mounted read-write: the entrypoint copies it to +# the real path (stripping the per-project prompt history it embeds, via jq), so +# the box gets login state + settings but its edits never reach the host copy. CLAUDE_CONFIG_FILE="${HOME}/.claude.json" if [[ -f "$CLAUDE_CONFIG_FILE" ]]; then - DOCKER_ARGS+=(-v "${CLAUDE_CONFIG_FILE}:/root/.claude.json") + if [[ "$NO_HISTORY" == true ]]; then + RUN_ARGS+=(-v "${CLAUDE_CONFIG_FILE}:/root/.claude.json.host:ro") + else + RUN_ARGS+=(-v "${CLAUDE_CONFIG_FILE}:/root/.claude.json") + fi fi # On macOS, Claude Code stores the OAuth credential in the Keychain rather than a # file, so the Linux container has no credentials and reports "not logged in". # Read the Keychain credential (the full JSON blob, incl. refresh token) and -# forward it as an env var *by name* — so the value never lands on the host disk -# or in the process list. The container's entrypoint writes it to -# ~/.claude/.credentials.json. On Linux hosts the credential is already a file in -# the mounted ~/.claude, so this is skipped. +# forward it as an env var *by name* — so the value stays out of the process list. +# The entrypoint materializes it into an in-memory tmpfs inside the container (the +# --tmpfs below), so the plaintext token never persists on the host disk. On Linux +# hosts the credential is already a file in the mounted ~/.claude, so this is +# skipped. +# Forward a credential blob (env var by name) and, under Docker, add the +# in-memory tmpfs the entrypoint stages it in. Stage the token in a tmpfs so the +# entrypoint never writes the plaintext into the bind-mounted ~/.claude (where it +# would persist on the host after exit). Docker only — the --tmpfs flag; under +# Apple container the box is a disposable micro-VM (and the flag support is +# unverified). Shared by the macOS Keychain and Linux --no-history paths. +forward_credentials() { + export CLAUDEBOX_CREDENTIALS="$1" + RUN_ARGS+=(-e CLAUDEBOX_CREDENTIALS) + if [[ "$RUNTIME" == docker ]]; then + RUN_ARGS+=(--tmpfs /root/.claude/.cbox:mode=700) + fi +} if [[ "$(uname)" == "Darwin" ]]; then - if CLAUDEBOX_CREDENTIALS="$(security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null)" \ - && [[ -n "$CLAUDEBOX_CREDENTIALS" ]]; then - export CLAUDEBOX_CREDENTIALS - DOCKER_ARGS+=(-e CLAUDEBOX_CREDENTIALS) + if CRED_BLOB="$(security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null)" \ + && [[ -n "$CRED_BLOB" ]]; then + forward_credentials "$CRED_BLOB" else echo "Warning: could not read Claude credentials from the macOS Keychain." >&2 fi +elif [[ "$NO_HISTORY" == true && -f "$CLAUDE_CONFIG_DIR/.credentials.json" ]]; then + # Linux hosts keep the credential as a file in ~/.claude, which --no-history + # no longer mounts — forward it the same way as the macOS Keychain blob so + # the entrypoint materializes it container-locally. + if CRED_BLOB="$(cat "$CLAUDE_CONFIG_DIR/.credentials.json")" \ + && [[ -n "$CRED_BLOB" ]]; then + forward_credentials "$CRED_BLOB" + else + echo "Warning: could not read ~/.claude/.credentials.json for the sandbox." >&2 + fi fi # Forward the host Docker socket so the container can drive the host's daemon @@ -203,18 +425,55 @@ fi # --docker instead for an isolated daemon unless you specifically need the host's. DOCKER_SOCK="/var/run/docker.sock" if [[ "$MOUNT_DOCKER_SOCK" == true ]]; then - if [[ -S "$DOCKER_SOCK" ]]; then - DOCKER_ARGS+=(-v "${DOCKER_SOCK}:/var/run/docker.sock") + # Resolve a symlinked socket (macOS points /var/run/docker.sock at + # ~/.docker/run/docker.sock) so the real socket is what gets mounted — Apple + # container proxies the mounted unix socket into the guest just as Docker does. + HOST_SOCK="$DOCKER_SOCK" + [[ -L "$DOCKER_SOCK" ]] && HOST_SOCK="$(readlink "$DOCKER_SOCK")" + if [[ -S "$HOST_SOCK" ]]; then + RUN_ARGS+=(-v "${HOST_SOCK}:/var/run/docker.sock") else - echo "Warning: --host-docker passed but $DOCKER_SOCK not found." >&2 + echo "Warning: --host-docker passed but no Docker socket found at $DOCKER_SOCK." >&2 fi fi # Allocate a TTY if stdin is a terminal (interactive use) if [ -t 0 ]; then - DOCKER_ARGS+=(-it) + RUN_ARGS+=(-i -t) +fi + +# Print the effective settings on every launch so it's clear what the sandbox is +# configured with (runtime, network, Docker access, forwards). +if [[ "$RUN_DIND" == true && "$RUNTIME" == container ]]; then + DOCKER_MODE="in-sandbox daemon (rootful, isolated micro-VM)" +elif [[ "$RUN_DIND" == true ]]; then + DOCKER_MODE="in-sandbox daemon (rootless)" +elif [[ "$MOUNT_DOCKER_SOCK" == true ]]; then + DOCKER_MODE="host socket (effective host root)" +else + DOCKER_MODE="off" fi +PORTS_DISP="${PORTS_DISP:-none}" + +MOUNTS_DISP="none" +if [[ ${#EXTRA_MOUNT_PATHS[@]} -gt 0 ]]; then + MOUNTS_DISP="$(printf '%s, ' "${EXTRA_MOUNT_PATHS[@]}")" + MOUNTS_DISP="${MOUNTS_DISP%, }" +fi + +echo "claudebox settings:" >&2 +echo " runtime : $RUNTIME" >&2 +echo " image : $IMAGE_NAME" >&2 +echo " workdir : $CONTAINER_WORKDIR" >&2 +echo " LAN egress : $([[ "$BLOCK_LAN" == true ]] && echo blocked || echo allowed)" >&2 +echo " docker : $DOCKER_MODE" >&2 +echo " ssh agent : $([[ "$FORWARD_SSH" == true ]] && echo forwarded || echo off)" >&2 +echo " history : $([[ "$NO_HISTORY" == true ]] && echo "container-local, discarded on exit (--no-history)" || echo "shared from host ~/.claude")" >&2 +echo " ports : $PORTS_DISP" >&2 +echo " extra mounts : $MOUNTS_DISP" >&2 +echo " command : $([[ -n "$EXEC_CMD" ]] && echo "$EXEC_CMD" || echo "claude (default)")" >&2 + # Warn whenever a flag weakens the sandbox boundary if [[ "$BLOCK_LAN" != true ]]; then echo "⚠️ --allow-lan: the container can reach your local network (LAN, router, metadata endpoints)." >&2 @@ -223,8 +482,5 @@ if [[ "$MOUNT_DOCKER_SOCK" == true ]]; then echo "⚠️ --host-docker: the host Docker socket is mounted. This grants effective host root and lets the" >&2 echo " container escape the sandbox and bypass the LAN firewall. Only use with trusted sessions." >&2 fi -if [[ "$RUN_DIND" == true ]]; then - echo "🐳 --docker: starting an isolated rootless Docker daemon inside the container (host Docker untouched)." >&2 -fi -exec docker run "${DOCKER_ARGS[@]}" "$IMAGE_NAME" ${CLAUDE_ARGS[@]+"${CLAUDE_ARGS[@]}"} +exec "$RUNTIME" run "${RUN_ARGS[@]}" "$IMAGE_NAME" ${CLAUDE_ARGS[@]+"${CLAUDE_ARGS[@]}"} diff --git a/claudebox-vm b/claudebox-vm new file mode 100755 index 0000000..a63dd1f --- /dev/null +++ b/claudebox-vm @@ -0,0 +1,363 @@ +#!/usr/bin/env bash +# claudebox-vm — run Claude Code inside an isolated macOS VM (host-parity sandbox). +# +# EXPERIMENTAL: needs `tart` and a prepared base VM image, and a Mac with enough +# free disk for a macOS image (~25GB). The full flow (clone → boot → remount → +# seed → run) has been exercised end to end against a real image. +# +# Unlike `claudebox` (a Linux container), this boots a real macOS guest so the +# sandbox mirrors the host userland exactly (BSD tools, Keychain, macOS paths). +# It leans on tart, which does the Virtualization.framework work: APFS +# copy-on-write clone of a base image, CPU/memory config, headless boot, and IP +# discovery. Each run clones the base, boots it, seeds credentials, runs Claude +# over SSH against the mounted project, then deletes the clone on exit. +# +# By default the guest gets internet but no LAN, enforced on the host by Softnet +# (one-time password prompt on first run to set its setuid bit; see +# claudebox-vm.md "Network isolation"). --allow-lan opts out. +# +# One-time base prep (not done here): +# - tart pull/clone a macOS base into $BASE (e.g. a cirruslabs macos image) +# - provision it to match your host (Brewfile, dotfiles) — this is the manual +# "mirror" step; there is no auto-sync +# - install your SSH public key into the base's ~admin/.ssh/authorized_keys so +# this script can SSH in non-interactively +# +# Usage: claudebox-vm [flags] [claude args...] +set -euo pipefail + +BASE="${CLAUDEBOX_VM_BASE:-claudebox-base}" # prepared base image (the template) +CPUS="${CLAUDEBOX_VM_CPUS:-4}" +MEMORY="${CLAUDEBOX_VM_MEMORY:-8192}" # MiB +SSH_USER="${CLAUDEBOX_VM_USER:-admin}" +KEEP=false # keep the clone after exit (debug) +SSH_FORWARD=false # forward the host SSH agent (--ssh) +ALLOW_LAN=false # default: block LAN egress (host-enforced Softnet) +NO_HISTORY=false # don't share host ~/.claude; guest history is throwaway +CLAUDE_ARGS=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --base) BASE="$2"; shift 2 ;; + --cpus) CPUS="$2"; shift 2 ;; + --memory) MEMORY="$2"; shift 2 ;; + --user) SSH_USER="$2"; shift 2 ;; + --keep) KEEP=true; shift ;; + --ssh) SSH_FORWARD=true; shift ;; + --allow-lan) ALLOW_LAN=true; shift ;; + --no-history) NO_HISTORY=true; shift ;; + -h|--help) + sed -n '2,26p' "$0" | sed 's/^# \{0,1\}//' + exit 0 ;; + *) CLAUDE_ARGS+=("$1"); shift ;; + esac +done + +command -v tart >/dev/null 2>&1 || { + echo "Error: tart is not installed. Install with: brew install cirruslabs/cli/tart" >&2 + exit 1 +} +tart list >/dev/null 2>&1 || { echo "Error: tart is not responding." >&2; exit 1; } +if ! tart list 2>/dev/null | awk '{print $2}' | grep -qx "$BASE"; then + echo "Error: base VM '$BASE' not found. Prepare it first (see header)." >&2 + exit 1 +fi + +# Network isolation. By default the guest reaches the internet but NOT your local +# network, enforced on the HOST by Softnet (tart --net-softnet) — a userspace +# packet filter the VM's traffic is forced through. Because it runs on the host, +# a root agent inside the guest can't disable it (unlike an in-guest firewall), +# so it's a real boundary like the container backend's. tart execs Softnet +# directly (no sudo) and relies on its setuid-root bit: Softnet uses root only +# to attach to vmnet, then drops privileges back to the calling user. tart would +# offer to set the bit itself on first run, but we background it with output +# discarded, so its interactive prompt can never appear — do the one-time setup +# here in the foreground instead. Setuid beats a NOPASSWD sudoers rule: replacing +# the binary clears the bit, so a user-level process can't parlay it into root. +# (A brew upgrade also clears it — we just re-prompt.) --allow-lan drops back to +# plain host NAT. Fail closed: if isolation is requested but Softnet isn't +# installed / can't run, refuse rather than boot LAN-exposed. +NET_ARGS=() +NET_DESC="isolated — internet only, LAN blocked (host-enforced Softnet)" +if [[ "$ALLOW_LAN" == true ]]; then + NET_DESC="host NAT — LAN reachable (--allow-lan)" +else + SOFTNET="$(command -v softnet 2>/dev/null || true)" + if [[ -z "$SOFTNET" ]]; then + echo "Error: LAN isolation needs Softnet, which isn't installed:" >&2 + echo " brew install cirruslabs/cli/softnet" >&2 + echo " Or pass --allow-lan to run the VM without isolation." >&2 + exit 1 + fi + # Operate on the real file, not brew's bin/ symlink into the Cellar — chown/ + # chmod would follow it anyway, but the setuid test (-u) would not. + SOFTNET="$(readlink -f "$SOFTNET")" + if ! [[ -u "$SOFTNET" && "$(stat -f %u "$SOFTNET")" == 0 ]]; then + if [[ ! -t 0 ]]; then + echo "Error: Softnet needs a one-time setuid-root setup so tart can run it," >&2 + echo " but there's no TTY to ask for a sudo password. Run once manually:" >&2 + echo " sudo chown root \"$SOFTNET\" && sudo chmod u+s \"$SOFTNET\"" >&2 + echo " Or pass --allow-lan to run the VM without isolation." >&2 + exit 1 + fi + echo "One-time setup: macOS requires root to attach a network filter to a VM." >&2 + echo "Your password authorizes a one-time grant to the Softnet binary (setuid)" >&2 + echo "so it can create that attachment — it drops root immediately after, and" >&2 + echo "nothing else gains any privileges. You'll only be asked again if the" >&2 + echo "binary changes (e.g. after 'brew upgrade softnet')." >&2 + if ! sudo chown root "$SOFTNET" || ! sudo chmod u+s "$SOFTNET"; then + echo "Error: couldn't set the setuid bit on $SOFTNET; the VM's network" >&2 + echo " can't be isolated. Pass --allow-lan to run without isolation." >&2 + exit 1 + fi + fi + NET_ARGS=(--net-softnet) + # Punch a specific hole (e.g. a dev server the agent needs) without opening + # the whole LAN: set CLAUDEBOX_VM_NET_ALLOW to a CIDR (maps to Softnet's + # --net-softnet-allow, longest-prefix match, block still wins on ties). + if [[ -n "${CLAUDEBOX_VM_NET_ALLOW:-}" ]]; then + NET_ARGS+=(--net-softnet-allow "$CLAUDEBOX_VM_NET_ALLOW") + NET_DESC+=" (+allow ${CLAUDEBOX_VM_NET_ALLOW})" + fi +fi + +PROJECT="$(pwd)" +# Distinct per-run clone name. No Math.random available; use PID + seconds. +CLONE="cbx-$$-$(date +%s)" + +# Always tear the clone down on exit (unless --keep). Stopping first releases the +# APFS clone; delete reclaims the diverged blocks. +cleanup() { + [[ "$KEEP" == true ]] && { echo "Kept VM: $CLONE (delete with: tart delete $CLONE)" >&2; return; } + tart stop "$CLONE" >/dev/null 2>&1 || true + tart delete "$CLONE" >/dev/null 2>&1 || true +} +trap cleanup EXIT INT TERM + +echo "claudebox-vm settings:" >&2 +echo " base : $BASE" >&2 +echo " clone : $CLONE" >&2 +echo " cpus : $CPUS" >&2 +echo " memory : ${MEMORY} MiB" >&2 +echo " project : $PROJECT" >&2 +echo " network : $NET_DESC" >&2 +echo " ssh : $SSH_FORWARD (host agent forwarding)" >&2 +if [[ "$NO_HISTORY" == true ]]; then + echo " history : guest-local, discarded with the clone (--no-history)" >&2 +else + echo " history : shared from host ~/.claude" >&2 +fi + +# APFS copy-on-write clone of the base — near-instant, near-zero disk up front. +tart clone "$BASE" "$CLONE" +tart set "$CLONE" --cpu "$CPUS" --memory "$MEMORY" + +# Share the project and the host ~/.claude into the guest (read-write). tart +# mounts shares under /Volumes/My Shared Files/; we remount that share at +# the project's parent directory below so Claude's session files line up with a +# native run. The project share is named after its basename so that +# / equals the host path exactly. Note: settings.json is +# deliberately NOT linked back to the host copy — see the ~/.claude setup below +# for why. +# +# The remount is skipped for shallow paths (a project directly under +# /Users/ would put the share root over the guest's home directory) and +# when the basename collides with the claudehome share name. +PROJECT_NAME="$(basename "$PROJECT")" +PROJECT_PARENT="$(dirname "$PROJECT")" +REMAP=true +case "$PROJECT" in + /*/*/*/*) ;; # parent is >= 3 levels deep + *) REMAP=false ;; +esac +[[ "$PROJECT_NAME" == "claudehome" ]] && REMAP=false +[[ "$REMAP" == false ]] && PROJECT_NAME="project" +DIRS=(--dir "${PROJECT_NAME}:$PROJECT") +# --no-history: don't share ~/.claude at all. Skipping just the symlinks below +# wouldn't be enough — the share itself stays readable (and writable) at +# /Volumes/My Shared Files/claudehome, so the guest could still reach host +# sessions/history through it. Config is copied in over SSH instead. +[[ -d "$HOME/.claude" && "$NO_HISTORY" == false ]] && DIRS+=(--dir "claudehome:$HOME/.claude") + +# Boot headless. The `tart run` process owns the VM's lifetime, so it stays in +# the background for the whole session. +tart run "$CLONE" --no-graphics ${NET_ARGS[@]+"${NET_ARGS[@]}"} "${DIRS[@]}" >/dev/null 2>&1 & +RUN_PID=$! + +# Wait for the guest to get an IP (tart blocks until it's assigned or the wait +# times out). +IP="$(tart ip "$CLONE" --wait 60 2>/dev/null || true)" +[[ -n "$IP" ]] || { echo "Error: VM never reported an IP." >&2; exit 1; } + +SSH_OPTS=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null + -o ConnectTimeout=5 -o LogLevel=ERROR) +vm_ssh() { ssh "${SSH_OPTS[@]}" "${SSH_USER}@${IP}" "$@"; } + +# Wait for SSH to accept connections (base image must trust our key — see header). +for _ in $(seq 1 60); do + vm_ssh true 2>/dev/null && break + sleep 1 +done +vm_ssh true 2>/dev/null || { echo "Error: SSH to $IP never came up." >&2; exit 1; } + +SHARE_ROOT="/Volumes/My Shared Files" + +# Map the project to its real host path inside the guest so Claude stores +# sessions under ~/.claude/projects/ as a native run would. A symlink +# is not enough: Node resolves symlinks in process.cwd(), so Claude would still +# see the share path. Instead, unmount tart's automount and remount the virtiofs +# share at the project's parent directory — the project share is named after its +# basename (above), so it lands at exactly the host path, as a real mount that +# realpath() preserves. Must run before anything in the guest holds the share +# open. Waits for the automount first (SSH can come up before it); if the +# remount at the parent fails, restores the automount and falls back to the +# share path. +REMOTE_DIR="${SHARE_ROOT}/${PROJECT_NAME}" +if [[ "$REMAP" == true ]]; then + if vm_ssh "i=0; until mount | grep -q AppleVirtIOFS; do i=\$((i+1)); [ \$i -ge 30 ] && exit 1; sleep 1; done; \ + sudo mkdir -p '$PROJECT_PARENT' || exit 1; \ + sudo umount '$SHARE_ROOT' 2>/dev/null || sudo diskutil unmount '$SHARE_ROOT' || exit 1; \ + sudo mount_virtiofs com.apple.virtio-fs.automount '$PROJECT_PARENT' || \ + { sudo mkdir -p '$SHARE_ROOT'; sudo mount_virtiofs com.apple.virtio-fs.automount '$SHARE_ROOT'; exit 1; }" >/dev/null 2>&1; then + SHARE_ROOT="$PROJECT_PARENT" + REMOTE_DIR="$PROJECT" + else + echo "Warning: couldn't remount the share at the project's host path; using the share path." >&2 + fi +fi + +# Point the guest's ~/.claude at the shared host config so settings/memory/ +# sessions persist and match the host. We symlink each entry individually rather +# than the whole directory, so we can hold ONE file back: settings.json. +# +# Two files are held back from the share: +# +# settings.json — a code-execution surface. Its `hooks` and +# `statusLine.command` keys are shell commands Claude runs. +# Shared read-write, a sandboxed agent could edit them and +# get that command run on the *host* next time you launch +# Claude there — a guest->host escape. We copy it in one-way +# (like ~/.claude.json) so guest edits never reach the host. +# .credentials.json — your host's live Claude auth token. Sharing it would let a +# guest read and exfiltrate it. Unlike the Linux container, +# the VM seeds auth via the guest Keychain (below), so this +# file isn't needed in the guest at all — we just skip it. +# +# Everything else (projects, sessions, todos, memory, history) stays linked and +# writable so it persists exactly as before — unless --no-history, which keeps +# the host ~/.claude off the guest entirely (no share) and copies only the HOLD +# config surfaces in one-way over SSH, so guest sessions die with the clone. +if [[ -d "$HOME/.claude" ]]; then + HOLD="settings.json settings.local.json CLAUDE.md statusline-command.sh hooks commands agents plugins skills .credentials.json" + if [[ "$NO_HISTORY" == true ]]; then + # No claudehome share is mounted, so copy the config from the host side. + # tar over SSH stdin handles the directories in HOLD (hooks, plugins, …); + # .credentials.json is skipped as always (auth is seeded via the guest + # Keychain below). + COPY=() + for s in $HOLD; do + [[ "$s" == .credentials.json ]] && continue + [[ -e "$HOME/.claude/$s" ]] && COPY+=("$s") + done + if vm_ssh "rm -rf ~/.claude && mkdir -p ~/.claude" >/dev/null 2>&1; then + if [[ ${#COPY[@]} -gt 0 ]] && ! tar -C "$HOME/.claude" -cf - "${COPY[@]}" 2>/dev/null \ + | vm_ssh "tar -C ~/.claude -xf -" >/dev/null 2>&1; then + echo "Warning: couldn't copy the Claude config into the guest." >&2 + fi + else + echo "Warning: couldn't set up ~/.claude in the guest." >&2 + fi + else + # Rebuild the guest ~/.claude: symlink the DATA entries back to the shared host + # copy so sessions/memory/history persist read-write, but HOLD BACK the code- + # execution / instruction-injection surfaces — copy those in one-way as guest- + # local files instead of linking. A read-write symlink would let a guest agent + # rewrite e.g. ~/.claude/hooks/*.sh on the host and get that code run there the + # next time you launch Claude natively — a guest->host escape. .credentials.json + # is skipped entirely (auth is seeded via the guest Keychain below). This + # mirrors the container backend's read-only config mounts. The share is mounted + # inside the guest, so the copy is a local `cp -R`, no scp needed. + vm_ssh "rm -rf ~/.claude && mkdir -p ~/.claude && \ + for e in '${SHARE_ROOT}/claudehome/'* '${SHARE_ROOT}/claudehome/'.*; do \ + b=\$(basename \"\$e\"); \ + case \" $HOLD . .. \" in *\" \$b \"*) continue ;; esac; \ + ln -sfn \"\$e\" ~/.claude/\"\$b\"; \ + done; \ + for s in $HOLD; do \ + [ \"\$s\" = .credentials.json ] && continue; \ + [ -e '${SHARE_ROOT}/claudehome/'\"\$s\" ] && cp -R '${SHARE_ROOT}/claudehome/'\"\$s\" ~/.claude/\"\$s\"; \ + done; true" >/dev/null 2>&1 \ + || echo "Warning: couldn't set up ~/.claude in the guest." >&2 + fi +fi + +# Copy ~/.claude.json in. It's a file (not shareable via tart --dir) and holds +# the logged-in account state, onboarding flag, and much of your config. Without +# it the interactive TUI treats the guest as a fresh install ("not logged in", +# no settings) even when the Keychain token is present. +if [[ -f "$HOME/.claude.json" ]]; then + # ~/.claude.json also embeds per-project prompt history, so --no-history + # strips it before the copy (login state and settings are kept). Best-effort: + # needs jq; falls back to copying as-is with a warning. + CLAUDE_JSON="" + if [[ "$NO_HISTORY" == true ]]; then + if ! CLAUDE_JSON="$(jq '(.projects // {}) |= map_values(del(.history))' "$HOME/.claude.json" 2>/dev/null)" || [[ -z "$CLAUDE_JSON" ]]; then + CLAUDE_JSON="" + echo "Warning: couldn't strip prompt history from ~/.claude.json (jq missing or failed); copying it as-is." >&2 + fi + fi + [[ -n "$CLAUDE_JSON" ]] || CLAUDE_JSON="$(cat "$HOME/.claude.json")" + printf '%s' "$CLAUDE_JSON" | vm_ssh "cat > ~/.claude.json" >/dev/null 2>&1 \ + || echo "Warning: couldn't copy ~/.claude.json into the guest." >&2 +fi + +# Seed the guest Keychain with the host's Claude credential (macOS stores it +# there, not in a file). The blob is piped over SSH stdin (so its JSON quotes +# need no escaping) and read with $(cat). SSH sessions start with the login +# keychain locked, so unlock it first, then add the item. +if BLOB="$(security find-generic-password -s 'Claude Code-credentials' -w 2>/dev/null)" && [[ -n "$BLOB" ]]; then + if printf '%s' "$BLOB" | vm_ssh "security unlock-keychain -p '${CLAUDEBOX_VM_PASS:-admin}' login.keychain 2>/dev/null; security add-generic-password -U -s 'Claude Code-credentials' -a '$SSH_USER' -w \"\$(cat)\"" >/dev/null 2>&1; then + : + else + echo "Warning: failed to seed Claude credential into the VM Keychain." >&2 + fi +else + echo "Warning: no Claude credential found in the host Keychain." >&2 +fi + +# Run Claude in the mounted project. -t for an interactive TTY. The Claude CLI +# installs to ~/.local/bin, which a non-interactive SSH shell doesn't have on +# PATH, so add it explicitly. +# +# Unlock the login keychain in THIS session too. The credential was added in the +# separate seed session above, but keychain unlock state doesn't carry across SSH +# sessions and non-interactive logins start locked — so without this Claude reads +# a locked keychain and reports "Not logged in · Run: security unlock-keychain". +# set-keychain-settings disables the auto-lock timeout so it won't re-lock during +# a long session. +# Forward the host's SSH agent into the interactive session (--ssh) so git +# push/pull over SSH work in the guest without ever copying a key in. Opt-in +# because it lets the sandboxed agent sign with your host keys for as long as +# the session lasts (mirrors claudebox --ssh). $AGENT_FLAG is expanded unquoted +# on purpose: empty means "no extra arg" (bash 3.2 chokes on empty arrays). +AGENT_FLAG="" +if [[ "$SSH_FORWARD" == true ]]; then + if [[ -n "${SSH_AUTH_SOCK:-}" ]]; then + AGENT_FLAG="-A" + else + echo "Warning: --ssh passed but SSH_AUTH_SOCK is not set on the host." >&2 + fi +fi + +# Assemble the remote claude invocation, quoting each passthrough arg with +# printf %q so args containing spaces/metacharacters survive the remote shell — +# a bare ${CLAUDE_ARGS[*]} word-splits e.g. --print "a b" into separate args. +REMOTE_CLAUDE="claude" +for a in ${CLAUDE_ARGS[@]+"${CLAUDE_ARGS[@]}"}; do + REMOTE_CLAUDE+=" $(printf '%q' "$a")" +done + +ssh -t $AGENT_FLAG "${SSH_OPTS[@]}" "${SSH_USER}@${IP}" \ + "security unlock-keychain -p '${CLAUDEBOX_VM_PASS:-admin}' login.keychain >/dev/null 2>&1; \ + security set-keychain-settings login.keychain >/dev/null 2>&1; \ + export PATH=\"\$HOME/.local/bin:\$PATH\"; cd $(printf '%q' "$REMOTE_DIR") && $REMOTE_CLAUDE" diff --git a/claudebox-vm-base b/claudebox-vm-base new file mode 100755 index 0000000..7e68dcb --- /dev/null +++ b/claudebox-vm-base @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# claudebox-vm-base — build the golden macOS base VM that claudebox-vm clones. +# +# One-time (or occasional) setup: pulls a minimal macOS image, boots it, trusts +# your SSH key, installs the Claude CLI, then shuts it down. After this, +# `claudebox-vm` clones this base per run. Re-run with --force to rebuild (e.g. +# to refresh macOS or re-provision). +# +# Requires: tart, sshpass, an SSH public key, and ~30GB free disk. +# +# Usage: claudebox-vm-base [--force] [--image ] [--name ] [--key ] +set -euo pipefail + +IMAGE="${CLAUDEBOX_VM_IMAGE:-ghcr.io/cirruslabs/macos-sequoia-vanilla:latest}" +NAME="${CLAUDEBOX_VM_BASE:-claudebox-base}" +PUBKEY="${CLAUDEBOX_VM_PUBKEY:-$HOME/.ssh/id_ed25519.pub}" +PASS="${CLAUDEBOX_VM_PASS:-admin}" # default cirruslabs admin password +USER_NAME="${CLAUDEBOX_VM_USER:-admin}" +FORCE=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --force) FORCE=true; shift ;; + --image) IMAGE="$2"; shift 2 ;; + --name) NAME="$2"; shift 2 ;; + --key) PUBKEY="$2"; shift 2 ;; + -h|--help) sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "Unknown option: $1" >&2; exit 1 ;; + esac +done + +command -v tart >/dev/null 2>&1 || { echo "Error: tart not installed (brew install cirruslabs/cli/tart)." >&2; exit 1; } +command -v sshpass >/dev/null 2>&1 || { echo "Error: sshpass not installed (brew install sshpass)." >&2; exit 1; } +[[ -f "$PUBKEY" ]] || { echo "Error: SSH public key not found: $PUBKEY" >&2; exit 1; } + +if tart list 2>/dev/null | awk '{print $2}' | grep -qx "$NAME"; then + if [[ "$FORCE" == true ]]; then + echo "==> Removing existing base '$NAME' (--force)" + tart stop "$NAME" >/dev/null 2>&1 || true + tart delete "$NAME" + else + echo "Base '$NAME' already exists. Re-run with --force to rebuild." >&2 + exit 1 + fi +fi + +# Always try to stop the base on exit so a failed build doesn't leave it running. +cleanup() { tart stop "$NAME" >/dev/null 2>&1 || true; } +trap cleanup EXIT INT TERM + +echo "==> Pulling + cloning base image (this is the big download, ~25-30GB)" +echo " image: $IMAGE" +tart clone "$IMAGE" "$NAME" + +echo "==> Booting the base headless" +tart run "$NAME" --no-graphics >/dev/null 2>&1 & + +echo "==> Waiting for the VM to get an IP" +IP="$(tart ip "$NAME" --wait 90 2>/dev/null || true)" +[[ -n "$IP" ]] || { echo "Error: VM never reported an IP." >&2; exit 1; } +echo " IP: $IP" + +SSH_OPTS=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=5 -o LogLevel=ERROR) + +echo "==> Waiting for SSH (default ${USER_NAME}/${PASS})" +for _ in $(seq 1 60); do + sshpass -p "$PASS" ssh "${SSH_OPTS[@]}" "${USER_NAME}@${IP}" true 2>/dev/null && break + sleep 2 +done +sshpass -p "$PASS" ssh "${SSH_OPTS[@]}" "${USER_NAME}@${IP}" true 2>/dev/null \ + || { echo "Error: SSH never came up." >&2; exit 1; } + +echo "==> Installing your SSH public key" +sshpass -p "$PASS" ssh "${SSH_OPTS[@]}" "${USER_NAME}@${IP}" \ + "mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys && echo OK" \ + < "$PUBKEY" >/dev/null + +echo "==> Installing the Claude CLI in the guest" +# Key auth now works, so no more password. +ssh "${SSH_OPTS[@]}" "${USER_NAME}@${IP}" \ + 'curl -fsSL https://claude.ai/install.sh | bash >/tmp/claude-install.log 2>&1; ~/.local/bin/claude --version' + +echo "==> Shutting the base down cleanly" +tart stop "$NAME" +trap - EXIT + +echo +echo "Done. Base '$NAME' is ready. Run 'claudebox-vm' from a project to use it." diff --git a/claudebox-vm.md b/claudebox-vm.md new file mode 100644 index 0000000..9345bd9 --- /dev/null +++ b/claudebox-vm.md @@ -0,0 +1,188 @@ +# claudebox-vm + +Run Claude Code inside an isolated **macOS virtual machine** (a host-parity +sandbox), instead of a Linux container. Because the guest is real macOS, the +sandbox mirrors your Mac's userland exactly — BSD tools, Keychain, macOS paths — +which a Linux container can't do. It's built on [tart], which drives Apple's +Virtualization.framework: an APFS copy-on-write clone of a prepared base image, +headless boot, and SSH. + +> **Status: experimental.** It works end to end (clone → boot → SSH → mount → seed +> credentials → run Claude), but expect rough edges. See Caveats. + +## Dependencies + +You need all of these on the host: + +| Dependency | Why | Install / note | +| --- | --- | --- | +| **Apple Silicon Mac** | Virtualization.framework macOS guests are arm64 only | M1 or newer | +| **macOS 13+** (host) | Required by tart / the framework | Sonoma+ recommended | +| **Homebrew** | to install tart | https://brew.sh | +| **tart** | runs the VM | `brew install cirruslabs/cli/tart` (see note below) | +| **softnet** | host-side LAN firewall for the VM (on by default) | `brew install cirruslabs/cli/softnet`, plus a one-time password prompt on first run — see [Network isolation](#network-isolation) | +| **~30 GB free disk** | the macOS base image is ~25 GB, plus a running clone | check `df -h /System/Volumes/Data` | +| **An SSH keypair** | non-interactive login to the guest | `~/.ssh/id_ed25519` (or generate one) | +| **`sshpass`** | only for the one-time base prep (first login uses `admin`/`admin`) | `brew install sshpass` | +| **A prepared base VM** named `claudebox-base` | the template that gets cloned per run | one-time setup below | +| **Claude credential in your Keychain** | forwarded into the VM so Claude is logged in | already there if you use Claude Code on macOS | + +Note: newer Homebrew gates third-party taps, so you may need to trust the tap +first: + +```sh +brew trust cirruslabs/cli +brew install cirruslabs/cli/tart +``` + +## One-time base setup + +`claudebox-vm` clones a prepared base image called `claudebox-base` on every run. +Build it once: + +```sh +# 1. Pull a minimal macOS base (vanilla = smallest; ~50 GB on disk) +tart clone ghcr.io/cirruslabs/macos-sequoia-vanilla:latest claudebox-base + +# 2. Boot it headless to provision it +tart run claudebox-base --no-graphics & +ip=$(tart ip claudebox-base) # wait a few seconds for it to appear + +# 3. Trust your SSH key (first login is admin/admin via sshpass) +sshpass -p admin ssh -o StrictHostKeyChecking=no admin@"$ip" \ + "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys" < ~/.ssh/id_ed25519.pub + +# 4. Install the Claude CLI in the guest +ssh admin@"$ip" 'curl -fsSL https://claude.ai/install.sh | bash' + +# 5. Shut it down cleanly — this is now your golden base +tart stop claudebox-base +``` + +Optionally provision the base further (brew, dotfiles, tools) to match your host +— that's the "mirror my Mac" step, and it's manual: the base drifts from your +host until you re-provision. + +## Usage + +```sh +claudebox-vm [flags] [claude args...] +``` + +From any project directory. Each run: APFS-clones the base, boots it, mounts the +project at its real host path, links your `~/.claude` and copies `~/.claude.json` +in, seeds the Claude Keychain credential, runs Claude, then **deletes the clone +on exit**. + +Examples: + +```sh +claudebox-vm # interactive Claude TUI, in a macOS VM +claudebox-vm --print "hello" # non-interactive +claudebox-vm --cpus 6 --memory 12288 +claudebox-vm --keep # don't delete the clone on exit (debug) +claudebox-vm --no-history # keep host ~/.claude out of the guest (throwaway history) +``` + +### Flags and env + +| Flag | Env | Default | +| --- | --- | --- | +| `--base ` | `CLAUDEBOX_VM_BASE` | `claudebox-base` | +| `--cpus ` | `CLAUDEBOX_VM_CPUS` | `4` | +| `--memory ` | `CLAUDEBOX_VM_MEMORY` | `8192` | +| `--user ` | `CLAUDEBOX_VM_USER` | `admin` | +| `--keep` | — | off (clone is deleted on exit) | +| `--ssh` | — | off (forward the host SSH agent into the guest session, for git push etc.) | +| `--allow-lan` | — | off (LAN egress is blocked by default — see [Network isolation](#network-isolation)) | +| `--no-history` | — | off (host `~/.claude` is shared; pass to keep sessions/history host-only — see [What crosses into the VM](#what-crosses-into-the-vm)) | +| — | `CLAUDEBOX_VM_NET_ALLOW` | unset (a CIDR to allow through the LAN block, e.g. `192.168.1.20/32`) | +| — | `CLAUDEBOX_VM_PASS` | `admin` (guest Keychain unlock password) | + +Any unrecognized args are passed through to `claude`. + +## Network isolation + +By default the VM can reach the **internet but not your local network** — your +router, other machines on your LAN, and other VMs are all blocked, matching the +container backend. The point of both sandboxes is to run Claude with +`--dangerously-skip-permissions` safely, and this is the backstop: a +prompt-injected agent can't poke at devices on your network. + +The block is enforced **on the host, not inside the VM**. The guest's packets +are forced through [Softnet], a filter process running on your Mac — so an agent +with root inside the guest can't switch it off, any more than a container with +`CAP_NET_ADMIN` dropped can rewrite its firewall. An in-guest firewall would be +theater: the guest user has passwordless sudo, and root can disable anything +that lives in the same box. + +### The one-time password prompt + +The first time you run `claudebox-vm`, it asks for your macOS password. Here is +exactly why, and exactly what it authorizes: + +- macOS requires **root** to attach a network filter to a VM (Apple's `vmnet` + framework is root-gated; that's an OS rule, not a claudebox choice). +- Your password authorizes a **one-time grant to the Softnet binary** — the + setuid bit (`chown root` + `chmod u+s` on that one file) — so it can create + that attachment. Softnet drops root immediately after attaching, before it + handles a single packet. +- Nothing else gains any privileges: not claudebox, not tart, not Claude, and + not the VM. There is no sudoers rule, no daemon, no kernel extension. +- You'll only be asked again if the binary changes (e.g. after + `brew upgrade softnet`) — the OS clears the setuid bit on a replaced file, + which is also why this grant can't be hijacked by swapping the binary. + +Every VM product that filters guest networking on macOS needs an equivalent +admin blessing at install time; this is the minimal form of it. (If you've +enabled Touch ID for sudo, the prompt is a fingerprint touch instead of a +password.) + +### Opting out or punching holes + +- `--allow-lan` boots the VM on plain NAT with **no isolation** — the guest can + reach your whole LAN. Use it deliberately. +- `CLAUDEBOX_VM_NET_ALLOW=` keeps the block but allows one specific range + (e.g. a dev server the agent needs): maps to Softnet's `--net-softnet-allow`. + +If isolation is on (the default) but Softnet isn't installed or can't be set +up, `claudebox-vm` refuses to boot rather than silently running LAN-exposed. + +Note the LAN block is about your *local* network: the agent can still reach the +public internet in both sandboxes — that's by design, Claude needs it. + +## What crosses into the VM + +- **The project**, mounted at its real host path (so Claude's session files line + up with a native run). +- **`~/.claude/`**, mounted read-write (settings, memory, sessions) — note this + means Claude in the VM writes back to your host `~/.claude`. With + `--no-history` it isn't mounted at all: only the config surfaces + (`settings.json`, `CLAUDE.md`, `hooks/`, `plugins/`, …) are copied in one-way, + and sessions/history created in the guest are discarded with the clone. +- **`~/.claude.json`**, copied in (account state + settings; needed for the TUI + to show you as logged in). It also embeds per-project prompt history, which + `--no-history` strips before the copy (needs `jq`; warns and copies as-is + without it). +- **One Keychain item** — `Claude Code-credentials` only — seeded into the guest + Keychain over SSH. No other Keychain entries or secrets are copied. +- **Your SSH agent** (only with `--ssh`) — forwarded via agent forwarding, so the + guest can sign with your host keys (e.g. `git push`) but the keys themselves + never leave the host. + +## Caveats + +- **Boot time** is ~30–60s per run (the CoW clone is instant; macOS boot is the + cost). +- **`~/.claude` is read-write**, so the VM mutates your host `~/.claude` + (pass `--no-history` to keep it host-only; guest sessions are then throwaway). +- The guest Keychain unlock uses the base image's default password (`admin` for + cirruslabs images) via `CLAUDEBOX_VM_PASS`. +- macOS EULA allows at most **2 macOS VMs per physical Mac**. +- LAN egress is blocked by default via a host-enforced [Softnet] filter, like + the container backend — see [Network isolation](#network-isolation). A + `brew upgrade softnet` clears the setuid bit, so the next run re-prompts for + your password once. + +[tart]: https://tart.run +[Softnet]: https://github.com/cirruslabs/softnet diff --git a/entrypoint.sh b/entrypoint.sh index 63b3234..9763877 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -15,12 +15,19 @@ # on the host. set -euo pipefail +# Tell Claude it's running in a sandbox. Claude refuses +# --dangerously-skip-permissions as root unless this is set; inside this +# container Claude runs as root and the container itself is the sandbox, so the +# flag is safe to allow here. +export IS_SANDBOX=1 + FIREWALL_APPLIED=false apply_lan_firewall() { if ! command -v iptables >/dev/null 2>&1; then - echo "Warning: CLAUDEBOX_BLOCK_LAN=true but iptables is unavailable; LAN is NOT blocked." >&2 - return + echo "Error: CLAUDEBOX_BLOCK_LAN=true but iptables is unavailable; cannot block the LAN." >&2 + echo " Refusing to start unprotected. Rebuild the image, or pass --allow-lan to opt out." >&2 + exit 1 fi # Allow DNS to whatever resolvers the container was assigned first, even if @@ -38,13 +45,63 @@ apply_lan_firewall() { # via the private bridge gateway. OUTPUT covers traffic the container sends # itself (incl. rootless Docker's userspace networking); FORWARD covers any # nested-container traffic that is routed rather than locally generated. - for cidr in 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 169.254.0.0/16; do + # One list drives both the apply loop and the fail-closed verify loop below, + # so they can't drift apart. + BLOCKED_V4="10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 169.254.0.0/16" + for cidr in $BLOCKED_V4; do iptables -A OUTPUT -d "$cidr" -j REJECT --reject-with icmp-net-prohibited 2>/dev/null \ || iptables -A OUTPUT -d "$cidr" -j DROP iptables -A FORWARD -d "$cidr" -j REJECT --reject-with icmp-net-prohibited 2>/dev/null \ || iptables -A FORWARD -d "$cidr" -j DROP done + # Fail closed: confirm each block rule actually landed before we hand control + # to Claude (which runs with IS_SANDBOX=1 / full auto). If the firewall was + # requested but a rule is missing — e.g. the runtime didn't grant NET_ADMIN — + # refuse to start rather than run with the LAN silently reachable. + for cidr in $BLOCKED_V4; do + if ! iptables -C OUTPUT -d "$cidr" -j REJECT --reject-with icmp-net-prohibited 2>/dev/null \ + && ! iptables -C OUTPUT -d "$cidr" -j DROP 2>/dev/null; then + echo "Error: CLAUDEBOX_BLOCK_LAN=true but the OUTPUT block for ${cidr} is not present." >&2 + echo " Refusing to start with the LAN reachable (does the runtime grant NET_ADMIN?)." >&2 + exit 1 + fi + done + + # IPv6: the blocks above are IPv4-only. If the container has *global* IPv6 + # connectivity, mirror them with ip6tables — drop ULA fc00::/7 (which also + # covers IPv6 cloud metadata, fd00:ec2::254) and link-local fe80::/10, while + # public IPv6 (2000::/3) stays reachable. With no global IPv6 there's nothing + # to reach, so skip (the common Docker case). Allow the resolvers' own IPv6 + # addresses first, same as the IPv4 path. + if ip -6 addr show scope global 2>/dev/null | grep -q "inet6"; then + if ! command -v ip6tables >/dev/null 2>&1; then + echo "Error: CLAUDEBOX_BLOCK_LAN=true and the box has IPv6, but ip6tables is" >&2 + echo " unavailable; cannot block the IPv6 LAN. Refusing to start unprotected." >&2 + exit 1 + fi + while read -r kw addr _; do + [[ "$kw" == "nameserver" && "$addr" == *:* ]] || continue + ip6tables -A OUTPUT -d "$addr" -p udp --dport 53 -j ACCEPT 2>/dev/null || true + ip6tables -A OUTPUT -d "$addr" -p tcp --dport 53 -j ACCEPT 2>/dev/null || true + done < /etc/resolv.conf + BLOCKED_V6="fc00::/7 fe80::/10" + for cidr in $BLOCKED_V6; do + ip6tables -A OUTPUT -d "$cidr" -j REJECT --reject-with icmp6-adm-prohibited 2>/dev/null \ + || ip6tables -A OUTPUT -d "$cidr" -j DROP + ip6tables -A FORWARD -d "$cidr" -j REJECT --reject-with icmp6-adm-prohibited 2>/dev/null \ + || ip6tables -A FORWARD -d "$cidr" -j DROP + done + for cidr in $BLOCKED_V6; do + if ! ip6tables -C OUTPUT -d "$cidr" -j REJECT --reject-with icmp6-adm-prohibited 2>/dev/null \ + && ! ip6tables -C OUTPUT -d "$cidr" -j DROP 2>/dev/null; then + echo "Error: CLAUDEBOX_BLOCK_LAN=true but the IPv6 OUTPUT block for ${cidr} is missing." >&2 + echo " Refusing to start with the IPv6 LAN reachable." >&2 + exit 1 + fi + done + fi + FIREWALL_APPLIED=true } @@ -79,12 +136,36 @@ start_rootless_dockerd() { echo "Warning: rootless dockerd did not become ready in time; see /var/log/dockerd-rootless.log" >&2 } +start_rootful_dockerd() { + # Under Apple `container` the box is its own micro-VM, so a normal rootful + # daemon is both the simplest option and safe — full privileges stay inside + # the disposable VM. dockerd defaults to the /var/run/docker.sock the (root) + # docker CLI already talks to, so no DOCKER_HOST is needed. + dockerd >/var/log/dockerd.log 2>&1 & + + # Wait up to ~30s for the daemon to accept connections. + for _ in $(seq 1 60); do + if docker info >/dev/null 2>&1; then + return + fi + sleep 0.5 + done + echo "Warning: rootful dockerd did not become ready in time; see /var/log/dockerd.log" >&2 +} + if [[ "${CLAUDEBOX_BLOCK_LAN:-false}" == "true" ]]; then apply_lan_firewall fi if [[ "${CLAUDEBOX_DIND:-false}" == "true" ]]; then - start_rootless_dockerd + # Apple container gives the box its own kernel, so it runs a rootful daemon + # (CLAUDEBOX_DIND_ROOTFUL); Docker uses the rootless daemon to avoid needing + # host privileges. + if [[ "${CLAUDEBOX_DIND_ROOTFUL:-false}" == "true" ]]; then + start_rootful_dockerd + else + start_rootless_dockerd + fi fi # Materialise the macOS Keychain credential (forwarded as an env var) into the @@ -93,17 +174,54 @@ fi # a file in the mounted ~/.claude. if [[ -n "${CLAUDEBOX_CREDENTIALS:-}" ]]; then mkdir -p /root/.claude - install -m 600 /dev/null /root/.claude/.credentials.json - printf '%s' "$CLAUDEBOX_CREDENTIALS" > /root/.claude/.credentials.json + # Write the forwarded token to the file Claude reads, then drop it from the + # environment. If the launcher provided an in-memory tmpfs staging dir + # (/root/.claude/.cbox — Docker on macOS), write the token THERE and point + # .credentials.json at it with a symlink, so the plaintext token lives in RAM + # only and never lands on the host's bind-mounted ~/.claude. Without the tmpfs + # (Linux hosts, or Apple container) fall back to writing the file directly. + if [[ -d /root/.claude/.cbox ]]; then + CRED_FILE=/root/.claude/.cbox/.credentials.json + ln -sfn .cbox/.credentials.json /root/.claude/.credentials.json + else + CRED_FILE=/root/.claude/.credentials.json + fi + install -m 600 /dev/null "$CRED_FILE" + printf '%s' "$CLAUDEBOX_CREDENTIALS" > "$CRED_FILE" unset CLAUDEBOX_CREDENTIALS fi +# One-way ~/.claude.json (claudebox --no-history): the launcher mounts the host +# file read-only at this staging path instead of read-write at the real one. +# Copy it to where Claude reads it — container-local, so box edits never reach +# the host copy — stripping the per-project prompt history it embeds. +if [[ -f /root/.claude.json.host ]]; then + if ! jq '(.projects // {}) |= map_values(del(.history))' /root/.claude.json.host > /root/.claude.json 2>/dev/null; then + cp /root/.claude.json.host /root/.claude.json + echo "Warning: couldn't strip prompt history from .claude.json; copied it as-is." >&2 + fi +fi + +# Decide what to run. Normally that's claude with the passthrough args. If +# CLAUDEBOX_EXEC is set (claudebox --exec ...), run that command in a shell +# instead — e.g. a loop/orchestrator that spawns claude itself. It runs in the +# same sandbox: IS_SANDBOX is exported above (so any claude it launches allows +# --dangerously-skip-permissions), and the firewall/CAP_NET_ADMIN drop below +# still wrap it. +if [[ -n "${CLAUDEBOX_EXEC:-}" ]]; then + RUN_CMD=(bash -c "$CLAUDEBOX_EXEC") +else + RUN_CMD=(claude "$@") +fi + # If we installed the firewall, drop CAP_NET_ADMIN from the bounding set before -# exec'ing Claude. On exec the kernel recomputes even a root process's permitted -# set as (file caps | root-default) & bounding set, so removing it here means -# Claude starts without it and can't reacquire it to undo the firewall. +# exec'ing. On exec the kernel recomputes even a root process's permitted set as +# (file caps | root-default) & bounding set, so removing it here means the +# process starts without it and can't reacquire it to undo the firewall. The +# reduced bounding set is inherited by every child, so a claude that ralph (or +# any --exec command) spawns is equally unable to touch the firewall. if [[ "$FIREWALL_APPLIED" == true ]]; then - exec setpriv --bounding-set -net_admin claude "$@" + exec setpriv --bounding-set -net_admin "${RUN_CMD[@]}" fi -exec claude "$@" +exec "${RUN_CMD[@]}" diff --git a/ralph.md b/ralph.md new file mode 100644 index 0000000..f05385d --- /dev/null +++ b/ralph.md @@ -0,0 +1,71 @@ +# Running Ralph in claudebox + +[Ralph](https://github.com/snarktank/ralph) is an autonomous agent loop: it +re-runs Claude Code until every item in a PRD passes. `claudebox --exec` lets +that whole loop run inside a single sandbox — each iteration is a fresh `claude` +process, but the LAN firewall and `CAP_NET_ADMIN` drop wrap the entire run, so +neither Ralph nor any Claude it spawns can reach your local network or alter the +firewall. + +## Quick start + +The normal setup is to copy Ralph's files into your own repo (e.g. under +`scripts/ralph/`). Then run claudebox **from that repo** — the project is +mounted as the working dir, so the script path is just relative: + +```bash +claudebox --exec 'bash scripts/ralph/ralph.sh --tool claude 20' +``` + +The `20` is the max iterations. Ralph exits early once it sees +`COMPLETE` in Claude's output. + +You should see a `command : bash scripts/ralph/ralph.sh ...` line in the +claudebox settings banner confirming the override is active (instead of the +default `claude`). + +### Alternative: Ralph in a separate checkout + +If you keep Ralph outside the project, mount it in and use an **absolute** path +(see the `~` gotcha below): + +```bash +claudebox --mount ~/Dev/ralph \ + --exec "bash $HOME/Dev/ralph/ralph.sh --tool claude 20" +``` + +`$HOME` is expanded by your host shell before claudebox sees it (double quotes, +not single), so the container gets the real absolute path. + +## How it works + +- The whole Ralph loop runs in **one** container. Each iteration spawns a fresh + `claude --dangerously-skip-permissions --print` (clean context), while the + repo, installed dependencies, and build caches persist across iterations so + Ralph's typecheck/test steps stay fast. +- Commits land in your **real** repo because the project is mounted at its host + path. +- `IS_SANDBOX=1` is set in the container, so the in-container `claude` processes + accept `--dangerously-skip-permissions`. + +## Gotchas + +- **Use absolute paths in `--exec`, not `~`** (only relevant to the separate- + checkout variant). The command runs in a shell as **root** inside the + container, so `~` expands to `/root`, not your host home. + `--exec 'bash ~/Dev/ralph/ralph.sh'` looks for `/root/Dev/ralph/ralph.sh` and + fails. Let your host shell expand the path first — use `"$HOME/..."` in double + quotes. The `~` in `--mount ~/Dev/ralph` is fine for the same reason: your + host shell expands it before claudebox sees it. + +- **Auth.** Ralph's non-interactive `claude --print` needs a working login. On + macOS claudebox forwards your Keychain token automatically. If iterations fail + with a not-logged-in error, run `claudebox` once interactively first to + confirm auth, then re-run the loop. + +## Related flags + +- `--ssh` — Ralph commits but doesn't push. Add this if you want it to push over + SSH. +- `--docker` — add if Ralph's build/test steps need their own isolated Docker + daemon inside the sandbox. diff --git a/test/in-container.sh b/test/in-container.sh index 724a729..39fcb3c 100755 --- a/test/in-container.sh +++ b/test/in-container.sh @@ -26,13 +26,19 @@ if [[ "${CLAUDEBOX_BLOCK_LAN:-false}" == "true" ]]; then fi if [[ "${CLAUDEBOX_DIND:-false}" == "true" ]]; then - echo "[dind] rootless daemon reachable + runs a container" + # rootful under Apple container (its own micro-VM), rootless under Docker + if [[ "${CLAUDEBOX_DIND_ROOTFUL:-false}" == "true" ]]; then + mode="rootful"; dind_log="/var/log/dockerd.log" + else + mode="rootless"; dind_log="/var/log/dockerd-rootless.log" + fi + echo "[dind] $mode daemon reachable + runs a container" if docker version >/dev/null 2>&1 && docker run --rm hello-world 2>/dev/null | grep -qi "hello from docker"; then - pass "rootless docker ran hello-world" + pass "$mode docker ran hello-world" else - fail "rootless docker not working" - echo " --- dockerd-rootless.log (tail) ---" - tail -8 /var/log/dockerd-rootless.log 2>/dev/null | sed 's/^/ /' + fail "$mode docker not working" + echo " --- $(basename "$dind_log") (tail) ---" + tail -8 "$dind_log" 2>/dev/null | sed 's/^/ /' fi fi diff --git a/test/run-container.sh b/test/run-container.sh new file mode 100755 index 0000000..aa807c4 --- /dev/null +++ b/test/run-container.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# claudebox test bed for the Apple `container` runtime (macOS 26+, Apple Silicon). +# +# Mirrors test/run.sh but drives Apple `container` and the rootful in-sandbox +# Docker path (CLAUDEBOX_DIND_ROOTFUL). Skips cleanly if `container` isn't +# installed/running, so it's a no-op on Linux CI. Builds a lean image, then +# verifies the firewall, capability drop, network egress, and Docker end to end. +# +# Run this LOCALLY on an Apple Silicon Mac (macOS 26+). GitHub-hosted macOS +# runners can't run it — Apple `container` needs nested virtualization, which +# those runners don't provide; use a self-hosted Mac runner for CI. +# +# Usage: test/run-container.sh +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" +IMAGE="${CLAUDEBOX_TEST_IMAGE:-claudebox:test}" +INCONTAINER="$ROOT/test/in-container.sh" + +if ! command -v container >/dev/null 2>&1 || ! container system status >/dev/null 2>&1; then + echo "SKIP: Apple 'container' is not installed/running (needs macOS 26+, Apple Silicon)." + exit 0 +fi + +TOTAL=0; FAILED=0 +report() { # + TOTAL=$((TOTAL + 1)) + if [[ "$1" == 0 ]]; then printf ' \033[32mPASS\033[0m %s\n' "$2" + else printf ' \033[31mFAIL\033[0m %s\n' "$2"; FAILED=$((FAILED + 1)); fi +} + +echo "==> Building lean test image ($IMAGE) with container" +if container build -t "$IMAGE" \ + --build-arg INSTALL_GO=false --build-arg INSTALL_RUST=false --build-arg INSTALL_PYTHON=false \ + . > test/build-container.log 2>&1; then + report 0 "image builds" +else + report 1 "image builds" + echo " build failed — tail of test/build-container.log:" + tail -15 test/build-container.log | sed 's/^/ /' + echo "Aborting."; exit 1 +fi + +echo "==> Firewall rules are installed correctly" +rules="$(container run --rm --cap-add NET_ADMIN --entrypoint bash "$IMAGE" -c ' + eval "$(sed -n "/^apply_lan_firewall()/,/^}/p" /usr/local/bin/claudebox-entrypoint.sh)" + apply_lan_firewall + iptables -S OUTPUT + iptables -S FORWARD' 2>/dev/null)" +miss=0 +for r in '10.0.0.0/8' '172.16.0.0/12' '192.168.0.0/16' '169.254.0.0/16'; do + echo "$rules" | grep -q -- "-A OUTPUT -d $r -j REJECT" || miss=1 + echo "$rules" | grep -q -- "-A FORWARD -d $r -j REJECT" || miss=1 +done +report $miss "private ranges REJECTed on OUTPUT + FORWARD" + +echo "==> Sandbox: LAN blocked, CAP_NET_ADMIN dropped, internet up" +out="$(container run --rm --cap-add NET_ADMIN -e CLAUDEBOX_BLOCK_LAN=true \ + -v "$INCONTAINER:/root/.local/bin/claude:ro" "$IMAGE" 2>&1)" +rc=$? +report $rc "firewall + cap-drop + egress verified from inside" +[[ $rc == 0 ]] || echo "$out" | sed 's/^/ /' + +echo "==> Rootful Docker (--docker under container)" +# How claudebox --docker runs under Apple container: full caps in the micro-VM, +# rootful daemon (CLAUDEBOX_DIND_ROOTFUL). Full power stays inside the VM. +out="$(container run --rm --cap-add ALL \ + -e CLAUDEBOX_DIND=true -e CLAUDEBOX_DIND_ROOTFUL=true \ + -v "$INCONTAINER:/root/.local/bin/claude:ro" "$IMAGE" 2>&1)" +rc=$? +report $rc "rootful daemon starts and runs hello-world" +[[ $rc == 0 ]] || echo "$out" | sed 's/^/ /' + +echo "==> Fail closed: firewall requested but rules can't be applied" +# BLOCK_LAN requested but NET_ADMIN withheld, so iptables can't install rules. +# The entrypoint must refuse to start rather than run Claude with the LAN open. +out="$(container run --rm -e CLAUDEBOX_BLOCK_LAN=true \ + -v "$INCONTAINER:/root/.local/bin/claude:ro" "$IMAGE" 2>&1)" +rc=$? +if [[ $rc != 0 ]] && ! echo "$out" | grep -q "RESULT: PASS"; then + report 0 "refuses to start when firewall rules can't be applied" +else + report 1 "refuses to start when firewall rules can't be applied" + echo "$out" | sed 's/^/ /' +fi + +echo "==> Fail closed: iptables missing" +# Hide iptables by dropping /usr/sbin from PATH; expect the explicit error. +out="$(container run --rm --cap-add NET_ADMIN -e CLAUDEBOX_BLOCK_LAN=true -e PATH=/usr/bin:/bin \ + -v "$INCONTAINER:/root/.local/bin/claude:ro" "$IMAGE" 2>&1)" +rc=$? +if [[ $rc != 0 ]] && echo "$out" | grep -q "iptables is unavailable"; then + report 0 "refuses to start when iptables is unavailable" +else + report 1 "refuses to start when iptables is unavailable" + echo "$out" | sed 's/^/ /' +fi + +echo "==> IPv6 firewall rules (only if the runtime provides global IPv6)" +# Unlike the Docker suite (which spins up an --ipv6 network), just check whether +# this runtime hands the box a global IPv6 address; if so the branch engages and +# we assert the ip6tables blocks, otherwise skip. Keeps the check runtime-agnostic. +v6probe="$(container run --rm --cap-add NET_ADMIN --entrypoint bash "$IMAGE" -c ' + if ip -6 addr show scope global 2>/dev/null | grep -q inet6; then + eval "$(sed -n "/^apply_lan_firewall()/,/^}/p" /usr/local/bin/claudebox-entrypoint.sh)" + apply_lan_firewall >/dev/null 2>&1 || true + echo HAVE_V6; ip6tables -S OUTPUT; ip6tables -S FORWARD + else + echo NO_V6 + fi' 2>/dev/null)" +if echo "$v6probe" | grep -q HAVE_V6; then + miss6=0 + for r in 'fc00::/7' 'fe80::/10'; do + echo "$v6probe" | grep -q -- "-A OUTPUT -d $r -j REJECT" || miss6=1 + echo "$v6probe" | grep -q -- "-A FORWARD -d $r -j REJECT" || miss6=1 + done + report $miss6 "IPv6 ULA + link-local REJECTed on OUTPUT + FORWARD" + [[ $miss6 == 0 ]] || echo "$v6probe" | sed 's/^/ /' +else + echo " SKIP IPv6 firewall (this runtime's network has no global IPv6)" +fi + +echo +echo "=================================" +printf " %d/%d checks passed\n" "$((TOTAL - FAILED))" "$TOTAL" +echo "=================================" +[[ $FAILED == 0 ]] diff --git a/test/run.sh b/test/run.sh index 86b5ef0..fea727b 100755 --- a/test/run.sh +++ b/test/run.sh @@ -67,6 +67,15 @@ rc=$? report $rc "firewall + cap-drop + egress verified from inside" [[ $rc == 0 ]] || echo "$out" | sed 's/^/ /' +echo "==> Default-path hardening (NET_RAW dropped + no-new-privileges)" +# The launcher adds these on the default path; confirm the entrypoint (firewall +# install, cap-drop, exec) and egress all still work under the tighter profile. +out="$(docker run --rm --cap-add=NET_ADMIN --cap-drop=NET_RAW --security-opt no-new-privileges \ + -e CLAUDEBOX_BLOCK_LAN=true -v "$INCONTAINER:/root/.local/bin/claude:ro" "$IMAGE" 2>&1)" +rc=$? +report $rc "sandbox works with NET_RAW dropped + no-new-privileges" +[[ $rc == 0 ]] || echo "$out" | sed 's/^/ /' + echo "==> Rootless Docker (--docker)" run_dind() { # extra docker args (e.g. --privileged) passed through docker run --rm "${SECOPTS[@]}" "$@" -e CLAUDEBOX_DIND=true \ @@ -92,6 +101,74 @@ fi report $rc "rootless daemon starts and runs hello-world ($mode)" [[ $rc == 0 ]] || echo "$out" | sed 's/^/ /' +echo "==> Fail closed: firewall requested but rules can't be applied" +# BLOCK_LAN requested but the container is denied NET_ADMIN, so iptables can't +# install rules. The entrypoint must refuse to start (non-zero) rather than run +# Claude with the LAN reachable. Note: no --cap-add=NET_ADMIN here, on purpose. +out="$(docker run --rm -e CLAUDEBOX_BLOCK_LAN=true \ + -v "$INCONTAINER:/root/.local/bin/claude:ro" "$IMAGE" 2>&1)" +rc=$? +if [[ $rc != 0 ]] && ! echo "$out" | grep -q "RESULT: PASS"; then + report 0 "refuses to start when firewall rules can't be applied" +else + report 1 "refuses to start when firewall rules can't be applied" + echo "$out" | sed 's/^/ /' +fi + +echo "==> Fail closed: iptables missing" +# Hide iptables by dropping /usr/sbin from PATH. The entrypoint should exit with +# its explicit 'iptables is unavailable' error, not launch Claude unprotected. +out="$(docker run --rm --cap-add=NET_ADMIN -e CLAUDEBOX_BLOCK_LAN=true -e PATH=/usr/bin:/bin \ + -v "$INCONTAINER:/root/.local/bin/claude:ro" "$IMAGE" 2>&1)" +rc=$? +if [[ $rc != 0 ]] && echo "$out" | grep -q "iptables is unavailable"; then + report 0 "refuses to start when iptables is unavailable" +else + report 1 "refuses to start when iptables is unavailable" + echo "$out" | sed 's/^/ /' +fi + +echo "==> IPv6 firewall rules (on an --ipv6 network)" +# The IPv6 blocks only engage when the box has global IPv6, so stand up a +# throwaway IPv6 network to exercise them. Skips cleanly where the host's Docker +# can't create one. +V6NET="claudebox-v6test-$$" +if docker network create --ipv6 --subnet fd00:cafe::/64 "$V6NET" >/dev/null 2>&1; then + v6rules="$(docker run --rm --cap-add=NET_ADMIN --network "$V6NET" --entrypoint bash "$IMAGE" -c ' + eval "$(sed -n "/^apply_lan_firewall()/,/^}/p" /usr/local/bin/claudebox-entrypoint.sh)" + apply_lan_firewall >/dev/null 2>&1 || true + ip6tables -S OUTPUT + ip6tables -S FORWARD' 2>/dev/null)" + docker network rm "$V6NET" >/dev/null 2>&1 || true + miss6=0 + for r in 'fc00::/7' 'fe80::/10'; do + echo "$v6rules" | grep -q -- "-A OUTPUT -d $r -j REJECT" || miss6=1 + echo "$v6rules" | grep -q -- "-A FORWARD -d $r -j REJECT" || miss6=1 + done + report $miss6 "IPv6 ULA + link-local REJECTed on OUTPUT + FORWARD" + [[ $miss6 == 0 ]] || echo "$v6rules" | sed 's/^/ /' +else + echo " SKIP IPv6 firewall (this Docker can't create an --ipv6 network)" +fi + +echo "==> Credential staged in tmpfs, not on the host bind mount (macOS cred path)" +# Materialize a fake token through the real entrypoint with the tmpfs staging dir +# the launcher adds on macOS. It must be readable inside the box (via the symlink +# into the tmpfs) but must NOT persist as a readable file on the host bind mount. +CREDDIR="$(mktemp -d)"; mkdir -p "$CREDDIR/.claude" +inside="$(docker run --rm \ + -e CLAUDEBOX_CREDENTIALS='FAKE-TOKEN-XYZ' \ + -e CLAUDEBOX_EXEC='printf INSIDE=; cat /root/.claude/.credentials.json' \ + --tmpfs /root/.claude/.cbox:mode=700 \ + -v "$CREDDIR/.claude:/root/.claude" "$IMAGE" 2>/dev/null)" +cred_ok=1 +[ -L "$CREDDIR/.claude/.credentials.json" ] || cred_ok=0 # host side is a symlink… +[ -e "$CREDDIR/.claude/.credentials.json" ] && cred_ok=0 # …whose target dangles on the host +grep -rq 'FAKE-TOKEN-XYZ' "$CREDDIR/.claude" 2>/dev/null && cred_ok=0 # token nowhere on the bind mount +echo "$inside" | grep -q 'INSIDE=FAKE-TOKEN-XYZ' || cred_ok=0 # but readable inside via the symlink +/bin/rm -rf "$CREDDIR" +report $((1 - cred_ok)) "token readable inside via tmpfs symlink, absent from host bind mount" + echo echo "=================================" printf " %d/%d checks passed\n" "$((TOTAL - FAILED))" "$TOTAL" diff --git a/test/unit.sh b/test/unit.sh new file mode 100755 index 0000000..5b4d771 --- /dev/null +++ b/test/unit.sh @@ -0,0 +1,206 @@ +#!/usr/bin/env bash +# Unit tests for the claudebox launcher's decision logic — runtime selection, +# the settings banner, and the runtime-specific run arguments. No real container +# runtime is needed: fake `container`/`docker` executables are placed on PATH. +# Each fake answers the probes claudebox makes (`system status`, `image inspect`) +# and, for `run`, prints the arguments it received so we can assert on them. +# +# Usage: test/unit.sh +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CB="$ROOT/claudebox" +TMPD="$ROOT/test/.unit-tmp" +/bin/rm -rf "$TMPD"; mkdir -p "$TMPD" +trap '/bin/rm -rf "$TMPD"' EXIT + +TOTAL=0; FAILED=0 +ok() { TOTAL=$((TOTAL+1)); printf ' \033[32mPASS\033[0m %s\n' "$1"; } +bad() { TOTAL=$((TOTAL+1)); FAILED=$((FAILED+1)); printf ' \033[31mFAIL\033[0m %s\n' "$1"; [[ -n "${2:-}" ]] && echo " $2"; } +# assert contains|absent +assert() { + local desc="$1" hay="$2" mode="$3" needle="$4" + if [[ "$mode" == contains ]]; then + [[ "$hay" == *"$needle"* ]] && ok "$desc" || bad "$desc" "expected to find: $needle" + else + [[ "$hay" != *"$needle"* ]] && ok "$desc" || bad "$desc" "expected NOT to find: $needle" + fi +} + +# Create a directory of fake runtime executables. Each records nothing but +# answers probes and echoes `run` args prefixed with its own name. +make_fake() { # + local dir="$1" name="$2" + mkdir -p "$dir" + cat > "$dir/$name" < services running + image) exit 0 ;; # 'image inspect X' -> image present (skip build) + build) exit 0 ;; + run) shift; printf 'RUN:%s %s\n' "$name" "\$*"; exit 0 ;; + *) exit 0 ;; +esac +EOF + chmod +x "$dir/$name" +} + +BOTH="$TMPD/both"; make_fake "$BOTH" container; make_fake "$BOTH" docker +DOCKERONLY="$TMPD/dk"; make_fake "$DOCKERONLY" docker +# Base PATH deliberately excludes /usr/local/bin so the *real* `container` on +# this host isn't picked up during auto-detection tests. +BASEPATH="/usr/bin:/bin" + +# Run claudebox with a controlled PATH (+ optional leading env assignments). +# Captures stdout in OUT, stderr in ERR. Extra args go to claudebox. +cb() { # [ENV=VAL ...] -- [claudebox args...] + local fakedir="$1"; shift + local -a envs=() + while [[ $# -gt 0 && "$1" != "--" ]]; do envs+=("$1"); shift; done + shift || true # drop the -- + OUT="$(env PATH="$fakedir:$BASEPATH" ${envs[@]+"${envs[@]}"} bash "$CB" "$@" 2>"$TMPD/err" runtime selection" +cb "$BOTH" -- ; assert "auto-selects container when present" "$OUT" contains "RUN:container" +cb "$DOCKERONLY" -- ; assert "falls back to docker when container absent" "$OUT" contains "RUN:docker" +cb "$BOTH" CLAUDEBOX_RUNTIME=docker -- ; assert "CLAUDEBOX_RUNTIME overrides to docker" "$OUT" contains "RUN:docker" +cb "$BOTH" -- --runtime docker ; assert "--runtime docker overrides" "$OUT" contains "RUN:docker" + +echo "==> --docker is runtime-aware" +cb "$BOTH" -- --docker +assert "container: grants --cap-add ALL" "$OUT" contains "--cap-add ALL" +assert "container: requests rootful daemon" "$OUT" contains "CLAUDEBOX_DIND_ROOTFUL=true" +assert "container: no docker-only security-opt" "$OUT" absent "seccomp=unconfined" +cb "$BOTH" -- --runtime docker --docker +assert "docker: uses unconfined seccomp/apparmor" "$OUT" contains "seccomp=unconfined" +assert "docker: does NOT request rootful" "$OUT" absent "CLAUDEBOX_DIND_ROOTFUL" +assert "docker: does NOT grant --cap-add ALL" "$OUT" absent "--cap-add ALL" + +echo "==> LAN firewall wiring" +cb "$BOTH" -- ; assert "default blocks LAN (CLAUDEBOX_BLOCK_LAN)" "$OUT" contains "CLAUDEBOX_BLOCK_LAN=true" +cb "$BOTH" -- ; assert "default adds NET_ADMIN capability" "$OUT" contains "--cap-add NET_ADMIN" +cb "$BOTH" -- --allow-lan ; assert "--allow-lan drops the block" "$OUT" absent "CLAUDEBOX_BLOCK_LAN=true" + +echo "==> port publishing binds loopback by default" +cb "$DOCKERONLY" -- -p 3000:3000 +assert "docker: hostPort:ctr gets 127.0.0.1 prepended" "$OUT" contains "127.0.0.1:3000:3000" +cb "$DOCKERONLY" -- -p 3000 +assert "docker: bare container port binds loopback" "$OUT" contains "127.0.0.1::3000" +cb "$DOCKERONLY" -- -p 127.0.0.1:8080:80 +assert "docker: explicit host IP respected" "$OUT" contains "127.0.0.1:8080:80" +assert "docker: explicit host IP not double-prefixed" "$OUT" absent "127.0.0.1:127.0.0.1" +cb "$DOCKERONLY" -- -p 0.0.0.0:8080:80 +assert "docker: explicit 0.0.0.0 (LAN opt-in) kept" "$OUT" contains "0.0.0.0:8080:80" +cb "$BOTH" -- -p 3000:3000 +assert "container: port left as-is (per-VM net)" "$OUT" contains " 3000:3000" +assert "container: no loopback rewrite" "$OUT" absent "127.0.0.1:3000:3000" + +echo "==> resource limits (--pids-limit, Docker only)" +cb "$BOTH" -- --runtime docker +assert "docker: sets a default --pids-limit" "$OUT" contains "--pids-limit 4096" +cb "$BOTH" CLAUDEBOX_PIDS_LIMIT=2048 -- --runtime docker +assert "docker: --pids-limit is overridable" "$OUT" contains "--pids-limit 2048" +cb "$BOTH" -- --runtime container +assert "container: no --pids-limit (own micro-VM)" "$OUT" absent "--pids-limit" + +echo "==> capability hardening (default path, Docker only)" +cb "$BOTH" -- --runtime docker +assert "docker default: drops NET_RAW" "$OUT" contains "--cap-drop NET_RAW" +assert "docker default: sets no-new-privileges" "$OUT" contains "no-new-privileges" +cb "$BOTH" -- --runtime docker --docker +assert "docker --docker: keeps NET_RAW" "$OUT" absent "--cap-drop NET_RAW" +assert "docker --docker: no no-new-privileges" "$OUT" absent "no-new-privileges" +cb "$BOTH" -- --runtime docker --host-docker +assert "host-docker: keeps NET_RAW" "$OUT" absent "--cap-drop NET_RAW" +cb "$BOTH" -- --runtime container +assert "container: no --cap-drop (own micro-VM)" "$OUT" absent "--cap-drop NET_RAW" + +echo "==> claude config: settings.json shared read-only" +# Controlled HOME so the assertions don't depend on the test runner's ~/.claude. +CFG_HOME="$TMPD/home-with-settings"; mkdir -p "$CFG_HOME/.claude"; printf '{}' > "$CFG_HOME/.claude/settings.json" +cb "$BOTH" HOME="$CFG_HOME" -- +assert "shares ~/.claude read-write" "$OUT" contains "$CFG_HOME/.claude:/root/.claude" +assert "re-mounts settings.json read-only on top" "$OUT" contains "$CFG_HOME/.claude/settings.json:/root/.claude/settings.json:ro" +# The other host-exec / injection surfaces get the same read-only treatment. +mkdir -p "$CFG_HOME/.claude/hooks" "$CFG_HOME/.claude/commands" "$CFG_HOME/.claude/agents" "$CFG_HOME/.claude/plugins" "$CFG_HOME/.claude/skills" +printf '{}' > "$CFG_HOME/.claude/settings.local.json"; printf '#' > "$CFG_HOME/.claude/CLAUDE.md"; printf '#' > "$CFG_HOME/.claude/statusline-command.sh" +cb "$BOTH" HOME="$CFG_HOME" -- +for s in settings.local.json CLAUDE.md statusline-command.sh hooks commands agents plugins skills; do + assert "$s mounted read-only" "$OUT" contains "$CFG_HOME/.claude/$s:/root/.claude/$s:ro" +done +NOCFG_HOME="$TMPD/home-no-settings"; mkdir -p "$NOCFG_HOME/.claude" +cb "$BOTH" HOME="$NOCFG_HOME" -- +assert "no settings.json mount when file absent" "$OUT" absent "/root/.claude/settings.json:ro" + +echo "==> config deny-list stays in sync across backends" +# The held-back config surfaces are security-critical (guest->host code-exec +# escape if one is missed) and are hand-maintained in both launchers, which are +# deliberately standalone files (no shared lib). Extract both lists from the +# sources and fail if they drift. claudebox-vm's list has one extra entry by +# design: .credentials.json (its auth is seeded via the guest Keychain). +CB_LIST="$(sed -n 's/.*for ro in \(.*\); do$/\1/p' "$CB")" +VM_LIST="$(sed -n 's/^ *HOLD="\(.*\)"$/\1/p' "$ROOT/claudebox-vm")" +VM_LIST="${VM_LIST% .credentials.json}" +if [[ -n "$CB_LIST" && "$CB_LIST" == "$VM_LIST" ]]; then + ok "claudebox and claudebox-vm hold back the same config surfaces" +else + bad "claudebox and claudebox-vm hold back the same config surfaces" "container: '$CB_LIST' vs vm: '$VM_LIST'" +fi + +echo "==> --no-history keeps host ~/.claude out of the box" +printf '{}' > "$CFG_HOME/.claude.json" +cb "$BOTH" HOME="$CFG_HOME" -- --no-history +assert "no read-write ~/.claude mount" "$OUT" absent "$CFG_HOME/.claude:/root/.claude" +assert "config still mounted read-only" "$OUT" contains "$CFG_HOME/.claude/settings.json:/root/.claude/settings.json:ro" +assert ".claude.json staged read-only for the entrypoint" "$OUT" contains "$CFG_HOME/.claude.json:/root/.claude.json.host:ro" +assert "no read-write .claude.json mount" "$OUT" absent "$CFG_HOME/.claude.json:/root/.claude.json " +assert "banner shows container-local history" "$ERR" contains "history : container-local" +cb "$BOTH" HOME="$CFG_HOME" -- +assert "default shares ~/.claude read-write" "$OUT" contains "$CFG_HOME/.claude:/root/.claude" +assert "default mounts .claude.json read-write" "$OUT" contains "$CFG_HOME/.claude.json:/root/.claude.json " +assert "banner shows shared history by default" "$ERR" contains "history : shared from host" + +echo "==> settings banner (stderr)" +cb "$BOTH" -- --docker -p 8080:80 +assert "banner header present" "$ERR" contains "claudebox settings:" +assert "banner shows runtime" "$ERR" contains "runtime : container" +assert "banner shows LAN blocked" "$ERR" contains "LAN egress : blocked" +assert "banner shows rootful docker" "$ERR" contains "in-sandbox daemon (rootful" +assert "banner shows published port" "$ERR" contains "8080:80" + +echo "==> --exec command override" +cb "$BOTH" -- --exec 'bash scripts/ralph/ralph.sh --tool claude 20' +assert "forwards CLAUDEBOX_EXEC to the container" "$OUT" contains "CLAUDEBOX_EXEC=bash scripts/ralph/ralph.sh --tool claude 20" +assert "banner shows the exec command" "$ERR" contains "command : bash scripts/ralph/ralph.sh" +assert "still blocks LAN under --exec" "$OUT" contains "CLAUDEBOX_BLOCK_LAN=true" +assert "still drops NET_ADMIN under --exec" "$OUT" contains "--cap-add NET_ADMIN" +cb "$BOTH" -- ; assert "no CLAUDEBOX_EXEC without --exec" "$OUT" absent "CLAUDEBOX_EXEC" +cb "$BOTH" -- ; assert "banner shows default command" "$ERR" contains "command : claude (default)" +cb "$BOTH" -- --exec 'echo hi' --resume +assert "warns that claude args are ignored under --exec" "$ERR" contains "passthrough claude args are ignored" + +echo "==> --host-docker" +cb "$BOTH" DOCKER_SOCK_OVERRIDE=1 -- --host-docker +# On a host with no /var/run/docker.sock this warns; on one with it, it mounts. +if [[ -S /var/run/docker.sock ]]; then + assert "host socket mounted at /var/run/docker.sock" "$OUT" contains ":/var/run/docker.sock" +else + assert "warns when no host Docker socket present" "$ERR" contains "no Docker socket found" +fi +assert "banner flags host-docker mode" "$ERR" contains "host socket (effective host root)" + +echo "==> Apple Silicon hint (only meaningful on arm64 macOS)" +if [[ "$(uname)" == "Darwin" && "$(uname -m)" == "arm64" ]]; then + cb "$DOCKERONLY" -- ; assert "hints to install container when absent" "$ERR" contains "Apple Silicon" + cb "$BOTH" -- ; assert "no hint when container is available" "$ERR" absent "Apple Silicon" +else + ok "Apple Silicon hint (skipped: not arm64 macOS)" +fi + +echo +echo "=================================" +printf " %d/%d checks passed\n" "$((TOTAL - FAILED))" "$TOTAL" +echo "=================================" +[[ $FAILED == 0 ]]