diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ebd7d5..7f79cc5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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() { # + 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.