Repository navigation
claude-docker: cross-platform CI smoke harness — Linux (#38) - #55
Conversation
Add a parameterized, runtime smoke test for the container's privilege-drop and credential-isolation, driven non-interactively in CI. Phase 2a (Linux); the macOS platform-delta cell is a follow-up gated on a reproducibility spike. - test/assert-in-container.sh: runs inside the container as the dropped user. Asserts identity (UID/GID, single primary group, HOME=/root), security posture (CapEff/Prm/Amb=0, CapBnd=0xc5 jointly with NoNewPrivs=1, no setuid-root binaries), file ownership, and credential plumbing. Credential checks verify the *intended* fixture is mounted (SMOKE-SENTINEL grep), that :ro mounts reject writes (EROFS substring match for uutils coreutils), that forwarded env vars are present for granted opt-ins (fail-closed), and that no token leaks for non-granted opt-ins. NoNewPrivs + setuid sweep run for the root-legacy cell too; cap-drop posture is correctly N/A there. - test/smoke.sh: host-side driver, one cell per invocation. Mirrors run.sh's exact security flags + mounts; bind-mounts the assert script as the workspace; supports cold/warm volumes, opt-ins, --ephemeral, and :ro. - ci.yml: a "Cross-platform smoke (Linux)" step in the existing docker-build job (reuses the already-loaded image; a separate job gets a blank daemon), driving 9 cells (UIDs 1000/501/0, opt-in combos, warm, ephemeral, ro). Reviewed by independent security and QA agents; their must-fixes are folded in (shellcheck disable placement, root-cell posture, sentinel content + env leak checks, GID-value assertion). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…#38) The base ubuntu:26.04 image ships the standard setuid-root binaries (su, mount, passwd, chsh, chfn, gpasswd, ssh-keysign), so the blanket "no setuid-root binaries" assertion failed on a correct image (caught by the first CI run). Under --security-opt no-new-privileges (asserted) the kernel ignores the setuid bit, so those binaries cannot escalate — NoNewPrivs is the real control. Assert the meaningful, stable invariant instead: sudo is not installed. The setuid inventory is logged for visibility. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…Ds (#38) The uid=501 cell ran the container as UID 501 while the host workspace dir was owned by the CI runner (UID 1001, 0755), so the in-container workspace-write probe failed (caught by CI). In production the workspace is the user's own repo owned by HOST_UID and writable; model that by making the test workspace world-writable. Also broaden the host-side ownership assertion to cover the uid=501 cell (bind mounts pass UIDs through numerically, so stat reads the written file's owner back regardless of the runner's UID); only HOST_UID=0 is skipped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
stefanwb
left a comment
There was a problem hiding this comment.
Reviewed. Verified the harness mirrors run.sh/entrypoint.sh: security flags match run.sh:391-393 exactly, cap bitmask 0xc5 decomposes correctly (CHOWN|DAC_READ_SEARCH|SETGID|SETUID), cred mount targets + tmpfs masks match run.sh. bash -n clean. (shellcheck/docker unavailable in my sandbox — trusting the validate job + this PR's CI run for those.) The assertions are well-chosen — SMOKE-SENTINEL content check, env-leak check, joint CapBnd+NoNewPrivs, and the uid=501-safe ownership round-trip are all good.
One substantive finding + minor nits:
1. The "EROFS robustness" cell doesn't exercise entrypoint's EROFS handling on Linux — and the path that does has no asserting cell.
entrypoint.sh:42 chowns only /root + /root/.claude. The RO cell's only :ro mount is the workspace (/workspaces/smoke), which is never chowned, so its no-WARN check passes trivially. The :ro mounts that actually sit under /root and could trip the chown→EROFS filter (entrypoint.sh:44) are the credential opt-in mounts in cells 2–4 — and those cells never check stderr for entrypoint: WARN. So the WARN-suppression filter has no assertion coverage intersecting a triggering condition. (Compounding: on Linux -xdev likely prunes those cred mounts via distinct st_dev — per the entrypoint comment at :30-31 — so the real path is a macOS/virtiofs Phase-2b concern; the cell comment "chown on :ro mounts must not abort the entrypoint" overstates Linux coverage.)
Suggested fix: lift the grep -q 'entrypoint: WARN' assertion out of the RO=1 guard so every cell asserts no spurious WARN — the opt-in cells then pin the filter. (Caveat: CONTAINER_STDERR is overwritten per run_container, so warm checks only the last pass's stderr — fine here, worth a note.)
2. (minor) test/ (new shell harness) sits beside tests/ (existing python tests) — differ only by a trailing s, a mis-path hazard. Consider smoke/ or folding into tests/.
3. (minor, optional) Matrix varies UID but never GID (always the runner's gid), so the -g $HOST_GID path on a non-matching GID is untested.
4. (Phase 2b note) realpath isn't on stock macOS (stat -c→stat -f fallback already handled). Not this PR.
Recommend addressing #1 (one-line move); rest optional. Otherwise LGTM — additive, gated behind docker-build, can't affect the shipped image.
…name, GID - Lift the "no spurious entrypoint: WARN" assertion out of the RO-only guard so EVERY cell checks stderr. The entrypoint chowns only /root + /root/.claude, so the :ro workspace never trips the chown→EROFS filter; the credential opt-in mounts (cells 2-4) are what sit under /root and actually pin the WARN-suppression filter. (Note: CONTAINER_STDERR is per-run, so a warm cell reflects the last pass — fine, same mounts both passes.) - Resolve the script dir portably (cd/pwd) instead of `realpath`, which is GNU coreutils and absent on stock macOS (where the Phase 2b job runs smoke.sh). - Rename test/ -> smoke/ to remove the test/ vs tests/ (python) mis-path hazard. - Add a --gid flag + a non-runner-GID cell (uid=501 gid=501) so the entrypoint's `useradd -g $HOST_GID` path is exercised with a GID != the runner's. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…name, GID - Lift the "no spurious entrypoint: WARN" assertion out of the RO-only guard so EVERY cell checks stderr. The entrypoint chowns only /root + /root/.claude, so the :ro workspace never trips the chown→EROFS filter; the credential opt-in mounts (cells 2-4) are what sit under /root and actually pin the WARN-suppression filter. (Note: CONTAINER_STDERR is per-run, so a warm cell reflects the last pass — fine, same mounts both passes.) - Resolve the script dir portably (cd/pwd) instead of `realpath`, which is GNU coreutils and absent on stock macOS (where the Phase 2b job runs smoke.sh). - Rename test/ -> smoke/ to remove the test/ vs tests/ (python) mis-path hazard. - Add a --gid flag + a non-runner-GID cell (uid=501 gid=501) so the entrypoint's `useradd -g $HOST_GID` path is exercised with a GID != the runner's. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
c7b6bc5 to
fb6f38c
Compare
|
Thanks — addressed all four in
CI green (all 10 cells). The fold-into- |
stefanwb
left a comment
There was a problem hiding this comment.
Re-reviewed fb6f38c — all four points resolved, verified locally:
- ✅ WARN coverage: the
entrypoint: WARNstderr assertion is now lifted out of theRO=1guard and runs for every cell, so the opt-in cells (the ones with:romounts under/root) actually pin the suppression filter. Comment cites entrypoint.sh:42/44 and notes the warm-cell stderr-overwrite caveat — correctly no-ops on the uid=0 and ephemeral cells. - ✅
test/→smoke/; no lingering references anywhere. - ✅
--gidflag + cell 10 (--uid=501 --gid=501);HOST_GID_ARGinit moved ahead of the arg loop so the flag overrides. - ✅
realpathreplaced withcd "$(dirname "$0")" && pwd(macOS-safe).
bash -n clean on both renamed scripts. Nothing further from me.
One residual (not actionable on Linux, already acknowledged in your comment): if -xdev prunes the :ro cred mounts via distinct st_dev, EROFS never fires even in the opt-in cells, so the filter is only truly exercised under macOS virtiofs in Phase 2b. Strictly better than before — no change needed. LGTM.
…#38) Phase 2b, docs-only. The automated macOS CI job is dropped: hosted macos-latest runners can't reliably provision Docker (the spike showed the Colima vz driver won't boot under the runner's nested-virt limits, and the qemu driver hits an upstream Lima panic), so a hosted job can't reach the assertions — and Colima's file-sharing may not match Docker Desktop's anyway. The Linux smoke matrix (merged in #55) covers the OS-independent behaviour. Instead, document the Linux CI coverage and a manual-fallback checklist for the one macOS-only behaviour (virtiofs collapsing st_dev across bind mounts, which changes the entrypoint's -xdev chown-prune over the :ro mounts under /root) to verify by hand on a real Mac before shipping entrypoint/run.sh/Dockerfile edits. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
Adds a runtime smoke-test harness for
claude-dockerthat exercises the container's privilege-drop and credential-isolation end-to-end in CI — the class of bugs that build cleanly and only fail at launch (UID collisions, cap-drop, chown/:roEROFS, credential leakage).Part of #38. This is Phase 2a (Linux); the macOS platform-delta cell is a follow-up (Phase 2b) gated on a reproducibility spike.
What changed
claude-docker/test/assert-in-container.sh— runs inside the container as the dropped user. Asserts:id -u==HOST_UID, single primary group (no inherited sudo/adm),id -g==HOST_GID,HOME=/root.CapEff/CapPrm/CapAmb == 0;CapBnd == 0x…c5(CHOWN|DAC_READ_SEARCH|SETUID|SETGID) asserted jointly withNoNewPrivs: 1; no unexpected setuid-root binaries.NoNewPrivs+ setuid sweep run for the root-legacy cell too; cap-drop posture is correctly N/A there.:ro(write→EROFS, substring-matched for uutils coreutils), the intended fixture is mounted (SMOKE-SENTINELcontent check, not just "a file exists"), and the forward env var is present (fail-closed); non-granted opt-ins are masked/absent and their env var must not leak.claude-docker/test/smoke.sh— host-side driver, one cell per invocation. Mirrorsrun.sh's exact security flags + mounts, bind-mounts the assert script as the workspace, drives the entrypoint non-interactively, and supports cold/warm volumes, opt-ins,--ephemeral,--ro..github/workflows/ci.yml— aCross-platform smoke (Linux)step in the existingdocker-buildjob (reuses the already-loadedclaude-docker:ciimage; a separate job would get a blank daemon). 9 cells: UID 1000/501/0, opt-in combos (none/aws/glab/combined), warm volume, ephemeral, read-only.Testing / review
bash -nclean on both scripts; the three# shellcheck disable=SC2086comments are correctly positioned (thevalidatejob runs shellcheck atseverity=warningoverclaude-docker/).0xc5) was independently confirmed correct.Notes
claude-docker/**orci.ymlchanges (thedocker-buildgate) — the image only builds then anyway.macos-latestcell for the virtiofsst_devdelta, gated on a spike + label; plus a manual-fallback checklist in the README.🤖 Generated with Claude Code