Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
.git
*.md
.claudebox-credentials.json
44 changes: 44 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
12 changes: 6 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,24 +24,24 @@ 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 }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Derive image tags
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
with:
images: ${{ env.IMAGE }}
tags: |
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.claudebox-credentials.json
.DS_Store
test/build.log
test/build-container.log
scratch/
16 changes: 15 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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}"

Expand Down
69 changes: 65 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand All @@ -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 <host:container>` | 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 <command>` | Run `<command>` 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 <host:container>` | 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 <list>` | With `--build`, include only these optional languages: `go`, `python`, `rust` |
| `--help`, `-h` | Show usage and exit |
Expand Down Expand Up @@ -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.
Loading