Skip to content

claude-docker: cross-platform CI smoke harness — Linux (#38) - #55

Merged
stefanwb merged 4 commits into
mainfrom
phase2a-linux-smoke
Jun 25, 2026
Merged

stefanwb merged 4 commits into
mainfrom
phase2a-linux-smoke

Conversation

@stefanwb

Copy link
Copy Markdown
Owner

Summary

Adds a runtime smoke-test harness for claude-docker that 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/:ro EROFS, 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:

  • Identity: id -u==HOST_UID, single primary group (no inherited sudo/adm), id -g==HOST_GID, HOME=/root.
  • Security posture: CapEff/CapPrm/CapAmb == 0; CapBnd == 0x…c5 (CHOWN|DAC_READ_SEARCH|SETUID|SETGID) asserted jointly with NoNewPrivs: 1; no unexpected setuid-root binaries. NoNewPrivs + setuid sweep run for the root-legacy cell too; cap-drop posture is correctly N/A there.
  • File ownership: a file written to the bind-mounted workspace is owned by the host user (verified host-side), cold and warm.
  • Credential plumbing: granted opt-ins are mounted :ro (write→EROFS, substring-matched for uutils coreutils), the intended fixture is mounted (SMOKE-SENTINEL content 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. Mirrors run.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 — a Cross-platform smoke (Linux) step in the existing docker-build job (reuses the already-loaded claude-docker:ci image; 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 -n clean on both scripts; the three # shellcheck disable=SC2086 comments are correctly positioned (the validate job runs shellcheck at severity=warning over claude-docker/).
  • Reviewed by independent security and QA agents. Their must-fixes are incorporated: shellcheck-disable placement (CI-blocking), root-cell posture handling, sentinel content + env-leak checks, GID-value assertion. The capability bitmask (0xc5) was independently confirmed correct.
  • Docker isn't available in the authoring sandbox, so cell execution is validated by this PR's CI run.

Notes

  • The smoke step runs when claude-docker/** or ci.yml changes (the docker-build gate) — the image only builds then anyway.
  • Follow-up (Phase 2b): macOS macos-latest cell for the virtiofs st_dev delta, gated on a spike + label; plus a manual-fallback checklist in the README.

🤖 Generated with Claude Code

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>
stefanwb and others added 2 commits June 24, 2026 18:51
…#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 stefanwb left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

stefanwb added a commit that referenced this pull request Jun 25, 2026
…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>
@stefanwb
stefanwb force-pushed the phase2a-linux-smoke branch from c7b6bc5 to fb6f38c Compare June 25, 2026 15:38
@stefanwb

Copy link
Copy Markdown
Owner Author

Thanks — addressed all four in fb6f38c:

  1. EROFS/WARN coverage (substantive). Lifted the entrypoint: WARN assertion out of the RO=1 guard so every cell checks stderr. As you noted, the entrypoint only chowns /root + /root/.claude, so the :ro workspace never trips the filter — the credential opt-in cells (2–4) are the ones whose :ro mounts sit under /root, and they now pin the WARN-suppression filter. Added your CONTAINER_STDERR-is-last-pass caveat as a code comment, and corrected the RO cell comment so it no longer overstates Linux coverage.
  2. test/ → smoke/. Renamed to kill the test/ vs tests/ mis-path hazard (updated the ci.yml path + this is reflected in claude-docker: document smoke tests + macOS manual-fallback checklist (#38) #56 too).
  3. GID coverage. Added a --gid flag and a uid=501 gid=501 cell (now 10 cells) so the useradd -g $HOST_GID path runs with a GID ≠ the runner's; the existing primary-GID=EXPECT_GID assertion covers it.
  4. macOS realpath. Replaced realpath (GNU-only) with a portable cd/pwd resolve so smoke.sh runs on stock macOS for Phase 2b.

CI green (all 10 cells). The fold-into-tests/ alternative I skipped deliberately — keeping the shell harness separate from the Python unit tests reads more clearly than mixing them.

@stefanwb stefanwb left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed fb6f38c — all four points resolved, verified locally:

  1. ✅ WARN coverage: the entrypoint: WARN stderr assertion is now lifted out of the RO=1 guard and runs for every cell, so the opt-in cells (the ones with :ro mounts 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.
  2. ✅ test/ → smoke/; no lingering references anywhere.
  3. ✅ --gid flag + cell 10 (--uid=501 --gid=501); HOST_GID_ARG init moved ahead of the arg loop so the flag overrides.
  4. ✅ realpath replaced with cd "$(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.

@stefanwb
stefanwb merged commit e2aeef9 into main Jun 25, 2026
3 checks passed
stefanwb added a commit that referenced this pull request Jun 25, 2026
…#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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant