Skip to content
Merged
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
185 changes: 179 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,189 @@ name: CI

on:
push:
branches: [main]
pull_request:

jobs:
bash-syntax:
name: bash -n
static:
name: static checks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Syntax-check shell scripts
run: |
bash -n claude-box
bash -n entrypoint.sh
bash -n userns-probe.sh
for f in claude-box codex-box libcage.sh entrypoint-cage.sh \
userns-probe.sh payload-init-claude.sh; do
bash -n "$f"
done
echo "parse OK"
- name: Cage boundary (static)
run: |
set -euo pipefail
scripts="claude-box codex-box libcage.sh entrypoint-cage.sh userns-probe.sh payload-init-claude.sh"
# The engine block exists exactly once: among the shell sources, engine_args
# is built only in the cage library, never in a payload wrapper.
hits=$(grep -ln 'engine_args+=(' $scripts || true)
[ "$hits" = "libcage.sh" ] \
|| { echo "FAIL: engine_args built outside libcage.sh:"; echo "$hits"; exit 1; }
# Both wrappers compose the cage rather than reimplementing it.
for w in claude-box codex-box; do
grep -q 'source .*libcage.sh' "$w" \
|| { echo "FAIL: $w does not source libcage.sh"; exit 1; }
done
# Payload Dockerfiles are FROM the shared cage base.
for d in Dockerfile.claude Dockerfile.codex; do
grep -q '^FROM cage-base' "$d" \
|| { echo "FAIL: $d is not FROM cage-base"; exit 1; }
done
echo "static boundary OK"

acceptance:
name: cage acceptance (--engine none)
runs-on: ubuntu-latest
needs: static
steps:
- uses: actions/checkout@v5

# Build cage-base + both payload images through the real launchers, so the
# image build path itself is under test. --engine none skips the nested
# dockerd (the one part hosted runners can't reliably provide); every check
# below is independent of it.
- name: Warm images via the launchers
run: |
set -euo pipefail
mkdir -p /tmp/cbtest && cd /tmp/cbtest && git init -q
CLAUDE_BOX_EXEC=1 "$GITHUB_WORKSPACE/claude-box" --engine none -- true
CODEX_BOX_EXEC=1 "$GITHUB_WORKSPACE/codex-box" --engine none -- true

# Part 1 — the cage base carries no harness; each payload adds only its own.
- name: Image boundary
run: |
set -euo pipefail
if docker run --rm --entrypoint sh cage-base -c 'command -v claude || command -v codex'; then
echo "FAIL: cage-base contains an agent harness"; exit 1
fi
docker run --rm --entrypoint sh cage-base -c '
for b in git gh docker dockerd-rootless.sh gosu uv; do
command -v "$b" >/dev/null || { echo "MISSING $b"; exit 1; }
done'
docker run --rm --entrypoint sh claude-box -c 'command -v claude >/dev/null && ! command -v codex >/dev/null'
docker run --rm --entrypoint sh codex-box -c 'command -v codex >/dev/null && ! command -v claude >/dev/null'
echo "image boundary OK"

# Part 4 + headless runbook — the exit-status contract holds identically
# through each wrapper: harness status, signal deaths, and an in-box timeout
# all propagate verbatim (none of these are launcher faults).
- name: Exit-status contract
run: |
set -euo pipefail
cd /tmp/cbtest
check() { # <box> <exec-var> <want> <cmd...>
local box="$1" var="$2" want="$3"; shift 3
local got=0
env "$var=1" "$GITHUB_WORKSPACE/$box" --engine none -- "$@" >/dev/null 2>&1 || got=$?
[ "$got" = "$want" ] \
|| { echo "FAIL: $box '$*' exited $got, want $want"; exit 1; }
echo "OK: $box '$*' -> $got"
}
for pair in "claude-box:CLAUDE_BOX_EXEC" "codex-box:CODEX_BOX_EXEC"; do
box="${pair%%:*}"; var="${pair##*:}"
check "$box" "$var" 0 bash -c 'exit 0'
check "$box" "$var" 7 bash -c 'exit 7'
check "$box" "$var" 130 bash -c 'kill -INT $$' # SIGINT
check "$box" "$var" 143 bash -c 'kill -TERM $$' # SIGTERM
check "$box" "$var" 137 bash -c 'kill -KILL $$' # SIGKILL
check "$box" "$var" 124 timeout 1 sleep 5 # in-box timeout
done
# stdin round-trips through the cage (proves -i gating).
out=$(printf 'PING\n' | env CLAUDE_BOX_EXEC=1 "$GITHUB_WORKSPACE/claude-box" --engine none -- cat)
[ "$out" = "PING" ] || { echo "FAIL: stdin round-trip returned '$out'"; exit 1; }
echo "exit contract OK"

# Part 3 — a mounted host socket is refused before the harness ever runs.
# The mount array must be passed via .env.<label> (the launcher sources it
# in-process; bash cannot export an array across exec).
- name: Host socket refused
run: |
set -euo pipefail
cd /tmp/cbtest
for pair in "claude-box:CLAUDE_BOX_EXEC" "codex-box:CODEX_BOX_EXEC"; do
box="${pair%%:*}"; var="${pair##*:}"
printf '%s_EXTRA_MOUNTS=("/var/run/docker.sock:/var/run/docker.sock")\n' \
"${var%_EXEC}" > ".env.${box}"
rc=0
env "$var=1" "$GITHUB_WORKSPACE/$box" --engine none -- true >out.txt 2>err.txt || rc=$?
rm -f ".env.${box}"
grep -q 'FATAL: /var/run/docker.sock is mounted from the host' err.txt \
|| { echo "FAIL: $box did not refuse a mounted host socket"; cat err.txt; exit 1; }
[ "$rc" != 0 ] \
|| { echo "FAIL: $box exited 0 with a host socket mounted"; exit 1; }
echo "OK: $box refused host socket (exit $rc)"
done
echo "socket refusal OK"

# Headless runbook — a launcher fault emits a distinct exit code and a
# machine-readable stderr line, even with the nested engine disabled. A
# create-time failure (a mount docker rejects) is docker 125, remapped to 126.
- name: Launcher fault line
run: |
set -euo pipefail
cd /tmp/cbtest
printf 'CLAUDE_BOX_EXTRA_MOUNTS=("/tmp:/tmp:bogusmode")\n' > .env.claude-box
rc=0
CLAUDE_BOX_EXEC=1 "$GITHUB_WORKSPACE/claude-box" --engine none -- true >/dev/null 2>err.txt || rc=$?
rm -f .env.claude-box
[ "$rc" = 126 ] || { echo "FAIL: want exit 126, got $rc"; cat err.txt; exit 1; }
grep -q 'fault=engine-start-failed' err.txt \
|| { echo "FAIL: missing fault=engine-start-failed line"; cat err.txt; exit 1; }
echo "fault line OK (exit $rc)"

# Headless runbook — redirected stdout carries only the payload's output:
# no CR, no cage/launcher log lines (those go to stderr), no stderr leak.
- name: Stream cleanliness
run: |
set -euo pipefail
cd /tmp/cbtest
CLAUDE_BOX_EXEC=1 "$GITHUB_WORKSPACE/claude-box" --engine none \
-- bash -c 'printf "hello\n"' >out.txt 2>/dev/null
[ "$(cat out.txt)" = "hello" ] || { echo "FAIL: stdout not exactly hello:"; cat -A out.txt; exit 1; }
[ "$(wc -l < out.txt)" -eq 1 ] || { echo "FAIL: unexpected line count"; exit 1; }
if grep -q $'\r' out.txt; then echo "FAIL: CR in stdout"; exit 1; fi
if grep -qE '\[cage\]|\[claude-box\]' out.txt; then echo "FAIL: log line leaked to stdout"; exit 1; fi
echo "stream cleanliness OK"

# Headless runbook — the state dump is opt-in via CLAUDE_BOX_DEBUG (-> CAGE_DEBUG);
# off by default, and it lists the state mount but never cats secrets.
- name: Debug dump opt-in
run: |
set -euo pipefail
cd /tmp/cbtest
CLAUDE_BOX_EXEC=1 "$GITHUB_WORKSPACE/claude-box" --engine none -- true >/dev/null 2>off.txt
CLAUDE_BOX_DEBUG=1 CLAUDE_BOX_EXEC=1 "$GITHUB_WORKSPACE/claude-box" --engine none -- true >/dev/null 2>on.txt
[ "$(grep -c 'state mount' off.txt)" -eq 0 ] || { echo "FAIL: dump present without debug"; exit 1; }
[ "$(grep -c 'state mount' on.txt)" -ge 1 ] || { echo "FAIL: dump absent with debug"; cat on.txt; exit 1; }
echo "debug dump opt-in OK"

# Headless runbook — an invalid --name is rejected before anything starts,
# and a box started headless is addressable via the --name-file handle.
- name: Box name handle + validation
run: |
set -euo pipefail
cd /tmp/cbtest
rc=0
CLAUDE_BOX_EXEC=1 "$GITHUB_WORKSPACE/claude-box" --name 'a b/c' --engine none -- true >/dev/null 2>err.txt || rc=$?
[ "$rc" = 1 ] || { echo "FAIL: invalid --name exited $rc, want 1"; cat err.txt; exit 1; }
grep -q 'invalid container name' err.txt || { echo "FAIL: missing invalid-name message"; cat err.txt; exit 1; }
echo "OK: invalid --name rejected"
h="$(mktemp)"; name="cbci-$$"
CLAUDE_BOX_EXEC=1 "$GITHUB_WORKSPACE/claude-box" --engine none --name "$name" --name-file "$h" \
-- sleep 120 >/dev/null 2>&1 &
launcher=$!
for _ in $(seq 1 150); do [ -s "$h" ] && break; sleep 0.2; done
[ "$(cat "$h")" = "$name" ] || { echo "FAIL: name-file not published"; kill "$launcher" 2>/dev/null || true; exit 1; }
for _ in $(seq 1 150); do docker inspect "$name" >/dev/null 2>&1 && break; sleep 0.2; done
docker exec "$name" true || { echo "FAIL: cannot exec into named box"; docker stop "$name" 2>/dev/null || true; exit 1; }
docker stop "$name" >/dev/null
wait "$launcher" 2>/dev/null || true
[ ! -e "$h" ] || { echo "FAIL: name-file not cleaned on exit"; exit 1; }
echo "name handle + validation OK"
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@
.faff
.faffrc.local.yaml
.env.*

docs/superpowers
52 changes: 17 additions & 35 deletions Dockerfile → Dockerfile.cage
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
FROM node:22-bookworm-slim

# System deps + GitHub CLI + a full nested container engine (ADR-0041
# decision 3): the cage gets its OWN dockerd — rootless by default, rootful
# under sysbox / privileged dind — never a mounted host socket. docker-ce
# brings dockerd; docker-ce-rootless-extras brings dockerd-rootless.sh +
# rootlesskit; uidmap/slirp4netns/fuse-overlayfs/iproute2/iptables are the
# rootless engine's userns, networking, and storage tooling.
# cage-base — the payload-free box: host-isolation tooling and a full nested
# container engine (ADR-0041 decision 3), with NO agent harness baked in. A
# payload image (claude-box, codex-box) does `FROM cage-base` and adds its own
# harness. Nothing Claude-specific lives here: no claude-code, no ugrep
# grep-shadow, no bun — those moved to the Claude payload layer.
#
# The cage gets its OWN dockerd — rootless by default, rootful under sysbox /
# privileged dind — never a mounted host socket. docker-ce brings dockerd;
# docker-ce-rootless-extras brings dockerd-rootless.sh + rootlesskit;
# uidmap/slirp4netns/fuse-overlayfs/iproute2/iptables are the rootless engine's
# userns, networking, and storage tooling.
RUN apt-get update && apt-get install -y --no-install-recommends \
git curl ca-certificates gnupg unzip jq openssh-client socat \
python3 python3-pip python3-venv \
Expand All @@ -26,15 +31,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \

# Chromium runtime libs so Playwright (installed per-project in venvs) can launch
# its bundled browser without needing root at runtime. Browser binary itself is
# not baked in — `playwright install chromium` fetches it on demand.
# not baked in — `playwright install chromium` fetches it on demand. Generic
# capability, not Claude's, so it belongs in the cage.
RUN apt-get update && apt-get install -y --no-install-recommends \
libnss3 libnspr4 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 \
libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2 \
libpango-1.0-0 libcairo2 libxkbcommon0 \
&& rm -rf /var/lib/apt/lists/*

# AWS CLI v2 — official bundled installer (arch-aware). Netlify CLI ships via
# npm below; flyctl via its official install script.
# npm below; flyctl via its official install script. Generic deploy CLIs.
RUN case "$(dpkg --print-architecture)" in \
amd64) awscli_arch=x86_64 ;; \
arm64) awscli_arch=aarch64 ;; \
Expand All @@ -58,9 +64,6 @@ RUN npm install -g netlify-cli wrangler \
# the official Astral image so we don't curl-pipe-sh.
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/

# Bun (required for Claude Code plugins — claude-hud, etc.)
RUN curl -fsSL https://bun.sh/install | BUN_INSTALL=/usr/local bash

# Yarn Berry (for Node.js projects using Yarn 4)
RUN corepack enable && corepack prepare yarn@4.13.0 --activate

Expand All @@ -71,29 +74,8 @@ RUN curl -fsSL "https://github.com/tianon/gosu/releases/download/1.17/gosu-$(dpk
&& chmod +x /usr/local/bin/gosu \
&& gosu nobody true

# ugrep + shadow grep with it. Claude Code on the host transparently redirects
# `grep` to ugrep (via a shell function) so patterns can use ugrep extensions
# like `\t` in -E regex. Subprocesses spawned by Claude Code (e.g. statusLine
# commands from plugins like claude-hud) don't inherit that shell function, so
# in the container we install ugrep system-wide and put a `grep` symlink on
# /usr/local/bin (which precedes /usr/bin on PATH) — anything resolving `grep`
# via PATH gets ugrep, while scripts that hardcode /usr/bin/grep still hit the
# stock GNU grep.
RUN apt-get update && apt-get install -y --no-install-recommends ugrep \
&& ln -sf /usr/bin/ugrep /usr/local/bin/grep \
&& rm -rf /var/lib/apt/lists/*

# Claude Code
RUN npm install -g @anthropic-ai/claude-code \
&& npm cache clean --force

COPY entrypoint.sh /usr/local/bin/claude-box-entrypoint.sh
COPY entrypoint-cage.sh /usr/local/bin/cage-entrypoint.sh
COPY userns-probe.sh /usr/local/bin/claude-box-userns-probe
RUN chmod +x /usr/local/bin/claude-box-entrypoint.sh /usr/local/bin/claude-box-userns-probe

# Bake the claude-box theme into the image so it's available regardless of
# host mounts or virtiofs cache state. The entrypoint copies it into the
# user's themes dir at startup.
COPY claude-box-theme.json /usr/local/share/claude-box-theme.json
RUN chmod +x /usr/local/bin/cage-entrypoint.sh /usr/local/bin/claude-box-userns-probe

ENTRYPOINT ["/usr/local/bin/claude-box-entrypoint.sh"]
ENTRYPOINT ["/usr/local/bin/cage-entrypoint.sh"]
33 changes: 33 additions & 0 deletions Dockerfile.claude
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
FROM cage-base

# claude-box payload: Claude Code on top of the payload-free cage, plus the two
# Claude-specific tools that used to live in the monolith image.

# ugrep + shadow grep with it. Claude Code on the host transparently redirects
# `grep` to ugrep (via a shell function) so patterns can use ugrep extensions
# like `\t` in -E regex. Subprocesses spawned by Claude Code (e.g. statusLine
# commands from plugins like claude-hud) don't inherit that shell function, so
# in the container we install ugrep system-wide and put a `grep` symlink on
# /usr/local/bin (which precedes /usr/bin on PATH) — anything resolving `grep`
# via PATH gets ugrep, while scripts that hardcode /usr/bin/grep still hit the
# stock GNU grep. This is a Claude convention, so it lives here, not in cage-base.
RUN apt-get update && apt-get install -y --no-install-recommends ugrep \
&& ln -sf /usr/bin/ugrep /usr/local/bin/grep \
&& rm -rf /var/lib/apt/lists/*

# Bun (required for Claude Code plugins — claude-hud, etc.)
RUN curl -fsSL https://bun.sh/install | BUN_INSTALL=/usr/local bash

# Claude Code
RUN npm install -g @anthropic-ai/claude-code \
&& npm cache clean --force

# Bake the claude-box theme into the image so it's available regardless of host
# mounts or virtiofs cache state. The payload-init hook copies it into the user's
# themes dir at startup.
COPY claude-box-theme.json /usr/local/share/claude-box-theme.json

# Payload-init hook: the cage entrypoint runs this (as root, before the engine)
# for Claude-specific prep — theme install and stale-dir cleanup.
COPY payload-init-claude.sh /usr/local/share/cage/payload-init
RUN chmod +x /usr/local/share/cage/payload-init
9 changes: 9 additions & 0 deletions Dockerfile.codex
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
FROM cage-base

# codex-box payload: the OpenAI Codex CLI on top of the payload-free cage.
# Nothing else is added — the cage already carries git, gh, the nested engine,
# and the generic dev tooling. Auth is a persisted ~/.codex login synced by the
# launcher; the harness runs with --dangerously-bypass-approvals-and-sandbox
# because the cage is the isolation boundary.
RUN npm install -g @openai/codex \
&& npm cache clean --force
Loading