From ab218b6dd819126d2cafcb3708394be46956dcb1 Mon Sep 17 00:00:00 2001 From: sbp-bvanb Date: Mon, 3 Aug 2026 21:02:46 +0200 Subject: [PATCH 01/13] build: push image to project ghcr registry --- .github/workflows/docker.yml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/docker.yml diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..f990cf8 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,33 @@ +--- +name: Docker +"on": + # required by gomod-go-version-updater to trigger this action once pr has + # been reviewed + pull_request_review: + types: [submitted] + push: +permissions: + contents: read + packages: write +jobs: + mcvs-docker-action: + strategy: + matrix: + args: + - build-args: claude-docker + image-suffix: "" + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # yamllint disable rule:line-length + - uses: schubergphilis/mcvs-docker-action@1de6c6ce1dd24cdea3b1964a13ac902a7cd57128 # v0.11.6 + with: + build-args: ${{ matrix.args.build-args }} + images: |- + ghcr.io/${{ github.repository }}${{ matrix.args.image-suffix }} + # yamllint disable rule:line-length + dockle-accept-key: libcrypto3>,libssl3>,PATH,APPLICATION,HOME,APPLICATION_PERMISSIONS,APPLICATION_USER_ID,curl + # yamllint enable rule:line-length + token: ${{ secrets.GITHUB_TOKEN }} From e57f7c53c4f158b2469344071d4560393f6cba0f Mon Sep 17 00:00:00 2001 From: sbp-bvanb Date: Mon, 3 Aug 2026 21:31:45 +0200 Subject: [PATCH 02/13] fix(dockle): suppress false-positive ENV key warnings and clear apt cache - Accept NODE_VERSION, --chmod, and CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS keys in dockle to suppress CIS-DI-0010 false positives - Add rm -rf /var/lib/apt/lists/* after glab apt-get install to fix DKL-DI-0005 Co-Authored-By: Claude Opus 4.6 --- .github/workflows/docker.yml | 2 +- Dockerfile | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index f990cf8..94dd828 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -28,6 +28,6 @@ jobs: images: |- ghcr.io/${{ github.repository }}${{ matrix.args.image-suffix }} # yamllint disable rule:line-length - dockle-accept-key: libcrypto3>,libssl3>,PATH,APPLICATION,HOME,APPLICATION_PERMISSIONS,APPLICATION_USER_ID,curl + dockle-accept-key: libcrypto3>,libssl3>,PATH,APPLICATION,HOME,APPLICATION_PERMISSIONS,APPLICATION_USER_ID,curl,NODE_VERSION,--chmod,CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS # yamllint enable rule:line-length token: ${{ secrets.GITHUB_TOKEN }} diff --git a/Dockerfile b/Dockerfile index de596d4..ace5084 100644 --- a/Dockerfile +++ b/Dockerfile @@ -100,7 +100,8 @@ RUN . /tmp/glab.env; set -e; ARCH=$(dpkg --print-architecture); \ curl -fsSL "$URL" -o /tmp/glab.deb \ && echo "${SHA} /tmp/glab.deb" | sha256sum -c - \ && apt-get install -y --no-install-recommends /tmp/glab.deb \ - && rm /tmp/glab.deb /tmp/glab.env + && rm /tmp/glab.deb /tmp/glab.env \ + && rm -rf /var/lib/apt/lists/* # AWS CLI v2 — version + download URL + sha256 from the generated pins/awscli.env. # URL sourced from the fragment so the pinned sha256 covers exactly what is From fd121e5eb38cf2663f8198ba823f30f8a783d7b2 Mon Sep 17 00:00:00 2001 From: sbp-bvanb Date: Mon, 3 Aug 2026 21:40:41 +0200 Subject: [PATCH 03/13] fix(dockle): suppress additional false-positive CIS-DI-0010 ENV key warnings Add --strip-components, DISABLE_AUTOUPDATER, ARCH, and nodejs to the dockle-accept-key list; all are build-time shell variables or package names misidentified as credentials. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/docker.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 94dd828..3b09878 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -28,6 +28,6 @@ jobs: images: |- ghcr.io/${{ github.repository }}${{ matrix.args.image-suffix }} # yamllint disable rule:line-length - dockle-accept-key: libcrypto3>,libssl3>,PATH,APPLICATION,HOME,APPLICATION_PERMISSIONS,APPLICATION_USER_ID,curl,NODE_VERSION,--chmod,CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS + dockle-accept-key: libcrypto3>,libssl3>,PATH,APPLICATION,HOME,APPLICATION_PERMISSIONS,APPLICATION_USER_ID,curl,NODE_VERSION,--chmod,CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS,--strip-components,DISABLE_AUTOUPDATER,ARCH,nodejs # yamllint enable rule:line-length token: ${{ secrets.GITHUB_TOKEN }} From 1decd3bc20464a98a62599d173fdab6367d9d38c Mon Sep 17 00:00:00 2001 From: sbp-bvanb Date: Mon, 3 Aug 2026 21:43:20 +0200 Subject: [PATCH 04/13] fix(dockle): suppress URL false-positive CIS-DI-0010 warning Co-Authored-By: Claude Opus 4.6 --- .github/workflows/docker.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 3b09878..68ae950 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -28,6 +28,6 @@ jobs: images: |- ghcr.io/${{ github.repository }}${{ matrix.args.image-suffix }} # yamllint disable rule:line-length - dockle-accept-key: libcrypto3>,libssl3>,PATH,APPLICATION,HOME,APPLICATION_PERMISSIONS,APPLICATION_USER_ID,curl,NODE_VERSION,--chmod,CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS,--strip-components,DISABLE_AUTOUPDATER,ARCH,nodejs + dockle-accept-key: libcrypto3>,libssl3>,PATH,APPLICATION,HOME,APPLICATION_PERMISSIONS,APPLICATION_USER_ID,curl,NODE_VERSION,--chmod,CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS,--strip-components,DISABLE_AUTOUPDATER,ARCH,nodejs,URL # yamllint enable rule:line-length token: ${{ secrets.GITHUB_TOKEN }} From b87b71fe3f8ec42146081a3f53686d4e04acd3af Mon Sep 17 00:00:00 2001 From: sbp-bvanb Date: Mon, 3 Aug 2026 21:43:47 +0200 Subject: [PATCH 05/13] fix(dockle): suppress IS_SANDBOX false-positive CIS-DI-0010 warning Co-Authored-By: Claude Opus 4.6 --- .github/workflows/docker.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 68ae950..5a43f9e 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -28,6 +28,6 @@ jobs: images: |- ghcr.io/${{ github.repository }}${{ matrix.args.image-suffix }} # yamllint disable rule:line-length - dockle-accept-key: libcrypto3>,libssl3>,PATH,APPLICATION,HOME,APPLICATION_PERMISSIONS,APPLICATION_USER_ID,curl,NODE_VERSION,--chmod,CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS,--strip-components,DISABLE_AUTOUPDATER,ARCH,nodejs,URL + dockle-accept-key: libcrypto3>,libssl3>,PATH,APPLICATION,HOME,APPLICATION_PERMISSIONS,APPLICATION_USER_ID,curl,NODE_VERSION,--chmod,CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS,--strip-components,DISABLE_AUTOUPDATER,ARCH,nodejs,URL,IS_SANDBOX # yamllint enable rule:line-length token: ${{ secrets.GITHUB_TOKEN }} From b70f78845cbfb33e09660b95e217c524348274d4 Mon Sep 17 00:00:00 2001 From: sbp-bvanb Date: Mon, 3 Aug 2026 21:46:12 +0200 Subject: [PATCH 06/13] fix(dockle): suppress SHA false-positive CIS-DI-0010 warning Co-Authored-By: Claude Opus 4.6 --- .github/workflows/docker.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 5a43f9e..c3e4ac1 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -28,6 +28,6 @@ jobs: images: |- ghcr.io/${{ github.repository }}${{ matrix.args.image-suffix }} # yamllint disable rule:line-length - dockle-accept-key: libcrypto3>,libssl3>,PATH,APPLICATION,HOME,APPLICATION_PERMISSIONS,APPLICATION_USER_ID,curl,NODE_VERSION,--chmod,CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS,--strip-components,DISABLE_AUTOUPDATER,ARCH,nodejs,URL,IS_SANDBOX + dockle-accept-key: libcrypto3>,libssl3>,PATH,APPLICATION,HOME,APPLICATION_PERMISSIONS,APPLICATION_USER_ID,curl,NODE_VERSION,--chmod,CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS,--strip-components,DISABLE_AUTOUPDATER,ARCH,nodejs,URL,IS_SANDBOX,SHA # yamllint enable rule:line-length token: ${{ secrets.GITHUB_TOKEN }} From 3b3696026cce9193d484c3f0abbca39bb0879970 Mon Sep 17 00:00:00 2001 From: sbp-bvanb Date: Mon, 3 Aug 2026 21:48:17 +0200 Subject: [PATCH 07/13] fix(dockle): suppress LANG false-positive CIS-DI-0010 warning Co-Authored-By: Claude Opus 4.6 --- .github/workflows/docker.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index c3e4ac1..bc5ed31 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -28,6 +28,6 @@ jobs: images: |- ghcr.io/${{ github.repository }}${{ matrix.args.image-suffix }} # yamllint disable rule:line-length - dockle-accept-key: libcrypto3>,libssl3>,PATH,APPLICATION,HOME,APPLICATION_PERMISSIONS,APPLICATION_USER_ID,curl,NODE_VERSION,--chmod,CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS,--strip-components,DISABLE_AUTOUPDATER,ARCH,nodejs,URL,IS_SANDBOX,SHA + dockle-accept-key: libcrypto3>,libssl3>,PATH,APPLICATION,HOME,APPLICATION_PERMISSIONS,APPLICATION_USER_ID,curl,NODE_VERSION,--chmod,CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS,--strip-components,DISABLE_AUTOUPDATER,ARCH,nodejs,URL,IS_SANDBOX,SHA,LANG # yamllint enable rule:line-length token: ${{ secrets.GITHUB_TOKEN }} From 9048c2293bfdcff6ed3f3d90e1f9a91700b22588 Mon Sep 17 00:00:00 2001 From: sbp-bvanb Date: Mon, 3 Aug 2026 21:51:52 +0200 Subject: [PATCH 08/13] fix(dockle): suppress LC_ALL false-positive CIS-DI-0010 warning Co-Authored-By: Claude Opus 4.6 --- .github/workflows/docker.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index bc5ed31..e74a132 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -28,6 +28,6 @@ jobs: images: |- ghcr.io/${{ github.repository }}${{ matrix.args.image-suffix }} # yamllint disable rule:line-length - dockle-accept-key: libcrypto3>,libssl3>,PATH,APPLICATION,HOME,APPLICATION_PERMISSIONS,APPLICATION_USER_ID,curl,NODE_VERSION,--chmod,CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS,--strip-components,DISABLE_AUTOUPDATER,ARCH,nodejs,URL,IS_SANDBOX,SHA,LANG + dockle-accept-key: libcrypto3>,libssl3>,PATH,APPLICATION,HOME,APPLICATION_PERMISSIONS,APPLICATION_USER_ID,curl,NODE_VERSION,--chmod,CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS,--strip-components,DISABLE_AUTOUPDATER,ARCH,nodejs,URL,IS_SANDBOX,SHA,LANG,LC_ALL # yamllint enable rule:line-length token: ${{ secrets.GITHUB_TOKEN }} From 806b843488d4ac39365e039cdad58da8a8b88e99 Mon Sep 17 00:00:00 2001 From: sbp-bvanb Date: Tue, 4 Aug 2026 07:37:00 +0200 Subject: [PATCH 09/13] feat(dockerfile): split into installer/runtime stages; default to non-root user Separate build-time tooling (curl, gnupg, unzip, apt repo infrastructure) from the runtime image. The installer stage downloads and verifies all binaries; the runtime stage starts fresh and only installs runtime apt packages, copying binaries from the installer stage. npm globals use an explicit /opt/npm-global prefix for predictable COPY paths. Bake a `claude` user at UID/GID 999 in the runtime image and update the entrypoint fallback (HOST_UID=0) to drop to that user instead of running as root, so the container workload is never root by default. Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 180 +++++++++++++++++++++++++++++++++----------------- entrypoint.sh | 6 +- 2 files changed, 124 insertions(+), 62 deletions(-) diff --git a/Dockerfile b/Dockerfile index ace5084..08dbc46 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,11 +6,9 @@ # Ubuntu (not Debian): only ubuntu:26.04 ships git ≥ 2.48 in its main archive, # needed for the `extensions.relativeWorktrees` repo extension. Rationale and # alternatives in openspec/changes/worktree-relative-paths/design.md. -FROM ubuntu:26.04@sha256:5e275723f82c67e387ba9e3c24baa0abdcb268917f276a0561c97bef9450d0b4 - -# pipefail propagates failures in RUN ... | ... — without this, a failed curl -# into tee/sha256sum silently succeeds and the build continues with bad data. -SHELL ["/bin/bash", "-eo", "pipefail", "-c"] +# +# NODE_VERSION is declared before the first FROM so both stages can inherit it +# with a bare `ARG NODE_VERSION` redeclaration (Docker's global-ARG pattern). # nodejs stays a MANUAL pin: NodeSource's apt repo is signed, and its publish # dates aren't cleanly machine-readable for the soak, so update_pins.py leaves @@ -19,6 +17,17 @@ SHELL ["/bin/bash", "-eo", "pipefail", "-c"] # Bump with: curl -fsSL https://deb.nodesource.com/node_24.x/dists/nodistro/main/binary-amd64/Packages.gz | gunzip | grep -E '^(Package|Version):' | head -4 ARG NODE_VERSION=24.17.0-1nodesource1 +# ── installer ──────────────────────────────────────────────────────────────── +# Has curl, gnupg, unzip, and all apt repository infrastructure needed to +# download and verify every tool. None of this leaks into the runtime image. +FROM ubuntu:26.04@sha256:5e275723f82c67e387ba9e3c24baa0abdcb268917f276a0561c97bef9450d0b4 AS installer + +ARG NODE_VERSION + +# pipefail propagates failures in RUN ... | ... — without this, a failed curl +# into tee/sha256sum silently succeeds and the build continues with bad data. +SHELL ["/bin/bash", "-eo", "pipefail", "-c"] + # Every other tool's version (and per-arch sha256) is a GENERATED pin under # pins/.env — NOT an ARG. Each install RUN below COPYs and sources its # fragment, so `docker build .` is reproducible from the committed lockfile with @@ -49,11 +58,11 @@ RUN echo 'APT::Sandbox::User "root";' > /etc/apt/apt.conf.d/10no-sandbox \ RUN if getent passwd ubuntu >/dev/null; then userdel -r ubuntu; fi \ && if getent group ubuntu >/dev/null; then groupdel ubuntu; fi -# NodeSource ships Node 24 LTS pinned to upstream releases — Ubuntu's archive -# `nodejs` tracks an older minor and isn't LTS-pinned. `nodistro` is -# NodeSource's distro-independent codename (works on any Debian/Ubuntu). +# Install build-time deps and set up NodeSource; nodejs is needed in the +# installer stage for `npm install -g` and for running install.cjs. +# unzip is used only for the AWS CLI installer and does not reach the runtime image. RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates curl gnupg \ + ca-certificates curl gnupg unzip \ && install -d -m 0755 /etc/apt/keyrings \ && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ @@ -63,18 +72,14 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && apt-get update \ && apt-get install -y --no-install-recommends \ "nodejs=${NODE_VERSION}" \ - git \ git-lfs \ - tmux \ - ncurses-term \ - jq \ - less \ - openssh-client \ - unzip \ && git lfs install --system --skip-repo \ && rm -rf /var/lib/apt/lists/* -# GitHub CLI (keyring fetched at build; TODO: commit the keyring to the repo) +# GitHub CLI — binary installed here; the dearmored keyring is copied to the +# runtime stage so it can install nodejs from NodeSource without needing gnupg. +# The GitHub CLI apt repo is not reproduced in the runtime stage: gh is a +# self-contained Go binary and is copied directly. RUN install -d -m 0755 /etc/apt/keyrings \ && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ | tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \ @@ -85,11 +90,7 @@ RUN install -d -m 0755 /etc/apt/keyrings \ && apt-get install -y --no-install-recommends gh \ && rm -rf /var/lib/apt/lists/* -# GitLab CLI (glab) — version + download URL + sha256 from the generated -# pins/glab.env. The URL is sourced from the fragment (not rebuilt here), so the -# pinned sha256 provably covers the exact .deb update_pins.py hashed — the two -# can't drift. COPY sits immediately before its RUN so a glab pin bump only -# rebuilds this layer and those after it, not the apt/gh layers above. +# GitLab CLI (glab) — self-contained Go binary; copied to runtime directly. COPY pins/glab.env /tmp/glab.env RUN . /tmp/glab.env; set -e; ARCH=$(dpkg --print-architecture); \ case "$ARCH" in \ @@ -103,9 +104,7 @@ RUN . /tmp/glab.env; set -e; ARCH=$(dpkg --print-architecture); \ && rm /tmp/glab.deb /tmp/glab.env \ && rm -rf /var/lib/apt/lists/* -# AWS CLI v2 — version + download URL + sha256 from the generated pins/awscli.env. -# URL sourced from the fragment so the pinned sha256 covers exactly what is -# fetched (the URL is single-sourced in update_pins.py, not rebuilt here). +# AWS CLI v2 — installs to /usr/local/aws-cli with symlinks in /usr/local/bin. COPY pins/awscli.env /tmp/awscli.env RUN . /tmp/awscli.env; set -e; ARCH=$(uname -m); \ case "$ARCH" in \ @@ -119,12 +118,7 @@ RUN . /tmp/awscli.env; set -e; ARCH=$(uname -m); \ && /tmp/aws/install \ && rm -rf /tmp/aws /tmp/awscli.zip /tmp/awscli.env -# uv (Astral) — pinned version + sha256 verify; uvx ships in the same archive. -# gnu variant: ubuntu is glibc; musl would silently fail at runtime. -# URL + hash pinned in pins/uv.env (not fetched from a .sha256 sidecar) so a CDN -# swap is caught at build time, and the hash provably covers the sourced URL — -# same trust model as the AWS CLI block above. ARCH still drives the path *inside* -# the archive (uv--unknown-linux-gnu/), which is not a download URL. +# uv (Astral) — gnu variant; binaries installed to /usr/local/bin. COPY pins/uv.env /tmp/uv.env RUN . /tmp/uv.env; set -e; ARCH=$(uname -m); \ case "$ARCH" in \ @@ -140,25 +134,22 @@ RUN . /tmp/uv.env; set -e; ARCH=$(uname -m); \ && install -m 0755 "/tmp/uv/uv-${ARCH}-unknown-linux-gnu/uvx" /usr/local/bin/uvx \ && rm -rf /tmp/uv /tmp/uv.tar.gz /tmp/uv.env -# npm-backed CLIs — pinned versions. Trust = npm's signed dist.integrity; -# run `npm audit signatures @` when bumping. -# --ignore-scripts blocks lifecycle hooks for every package + transitive dep -# (hard security boundary, kept on). claude-code 2.1.x ships its real binary -# in a per-arch optional-dep package; the launcher's postinstall (install.cjs) -# copies it over bin/claude.exe. Without it `claude` is a stub that errors at -# exec. We invoke that one script ourselves — platform-detect + file copy, -# no network/exec, audit-verified for 2.1.131; re-read on each bump. -# `npm root -g` over a hardcoded path so we don't break on a different prefix. -# npm tools carry version-only pins (no sha256): npm install verifies the -# registry-advertised dist.integrity (registry-integrity, not provenance; CI -# runs `npm audit signatures`). All three share this layer, so they share a COPY. +# npm-backed CLIs — installed to a dedicated prefix (/opt/npm-global) so the +# COPY into the runtime stage is a single predictable directory, independent of +# the NodeSource npm prefix convention. The runtime stage adds the bin dir to +# PATH. install.cjs is invoked manually (--ignore-scripts blocks it) to copy +# the arch-specific claude binary; it reads its own location via npm root -g +# so it works correctly with the custom prefix. COPY pins/claude-code.env pins/openspec.env pins/pnpm.env /tmp/ -RUN . /tmp/claude-code.env && . /tmp/openspec.env && . /tmp/pnpm.env \ - && npm install -g --ignore-scripts \ +RUN mkdir -p /opt/npm-global \ + && NPM_CONFIG_PREFIX=/opt/npm-global \ + . /tmp/claude-code.env && . /tmp/openspec.env && . /tmp/pnpm.env \ + && NPM_CONFIG_PREFIX=/opt/npm-global npm install -g --ignore-scripts \ "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \ "@fission-ai/openspec@${OPENSPEC_VERSION}" \ "pnpm@${PNPM_VERSION}" \ - && node "$(npm root -g)/@anthropic-ai/claude-code/install.cjs" \ + && NPM_CONFIG_PREFIX=/opt/npm-global node \ + "$(NPM_CONFIG_PREFIX=/opt/npm-global npm root -g)/@anthropic-ai/claude-code/install.cjs" \ && rm /tmp/claude-code.env /tmp/openspec.env /tmp/pnpm.env # tfenv — pure-bash terraform version manager. Arch-independent (just @@ -168,27 +159,97 @@ RUN . /tmp/claude-code.env && . /tmp/openspec.env && . /tmp/pnpm.env \ # releases.hashicorp.com at runtime, in the same runtime-fetch class as # `pnpm dlx`/`uvx`. Installed under /opt (not /root) so image-level # version bumps aren't shadowed by the claude-code-root named volume. -# Placed after the heavier npm install so a tfenv version bump doesn't -# invalidate that layer's cache (tfenv pins move far less often than the -# claude-code/openspec/pnpm pins above). COPY pins/tfenv.env /tmp/tfenv.env RUN . /tmp/tfenv.env \ && curl -fsSL "$TFENV_URL" -o /tmp/tfenv.tar.gz \ && echo "${TFENV_SHA256} /tmp/tfenv.tar.gz" | sha256sum -c - \ && mkdir -p /opt/tfenv \ && tar -xzf /tmp/tfenv.tar.gz -C /opt/tfenv --strip-components=1 \ - && ln -s /opt/tfenv/bin/tfenv /usr/local/bin/tfenv \ - && ln -s /opt/tfenv/bin/terraform /usr/local/bin/terraform \ && rm /tmp/tfenv.tar.gz /tmp/tfenv.env + +# ── runtime ─────────────────────────────────────────────────────────────────── +# Fresh base: only runtime-needed packages installed via apt. No gnupg, no +# unzip, no GitHub CLI apt repo. NodeSource keyring is copied from the +# installer stage — this stage never needs gnupg. gh and glab are copied as +# self-contained binaries. All other tools are copied from the installer stage. +FROM ubuntu:26.04@sha256:5e275723f82c67e387ba9e3c24baa0abdcb268917f276a0561c97bef9450d0b4 + +ARG NODE_VERSION + +SHELL ["/bin/bash", "-eo", "pipefail", "-c"] + +RUN echo 'APT::Sandbox::User "root";' > /etc/apt/apt.conf.d/10no-sandbox \ + && chown root:root /var/cache/apt/archives/partial + +RUN if getent passwd ubuntu >/dev/null; then userdel -r ubuntu; fi \ + && if getent group ubuntu >/dev/null; then groupdel ubuntu; fi + +# Bake a non-root `claude` user at a fixed UID/GID (999) so the entrypoint +# has a safe default to fall back to when HOST_UID is unset or 0 (e.g. plain +# `docker run` without -e HOST_UID). HOME=/root is deliberate: the persistent +# named volumes for claude-code state are mounted there and must stay at that +# path. UID 999 is below Ubuntu's default UID_MIN (1000) so it won't collide +# with a host user forwarded via HOST_UID on typical Linux or macOS systems. +RUN groupadd -g 999 claude \ + && useradd -u 999 -g 999 -d /root -s /bin/bash -M -N claude + +# NodeSource keyring was dearmored in the installer stage — copy it here to +# avoid needing gnupg in the runtime image. No GitHub CLI repo: gh is copied +# as a binary below. +COPY --from=installer /etc/apt/keyrings/nodesource.gpg /etc/apt/keyrings/nodesource.gpg + +# NodeSource ships Node 24 LTS pinned to upstream releases — Ubuntu's archive +# `nodejs` tracks an older minor and isn't LTS-pinned. `nodistro` is +# NodeSource's distro-independent codename (works on any Debian/Ubuntu). +RUN chmod go+r /etc/apt/keyrings/nodesource.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_24.x nodistro main" \ + > /etc/apt/sources.list.d/nodesource.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + "nodejs=${NODE_VERSION}" \ + git \ + git-lfs \ + tmux \ + ncurses-term \ + jq \ + less \ + openssh-client \ + && git lfs install --system --skip-repo \ + && rm -rf /var/lib/apt/lists/* + +# gh and glab: self-contained Go binaries — no apt repo infrastructure needed. +COPY --from=installer /usr/bin/gh /usr/bin/gh +COPY --from=installer /usr/bin/glab /usr/bin/glab + +# AWS CLI +COPY --from=installer /usr/local/aws-cli /usr/local/aws-cli +COPY --from=installer /usr/local/bin/aws /usr/local/bin/aws +COPY --from=installer /usr/local/bin/aws_completer /usr/local/bin/aws_completer + +# uv / uvx +COPY --from=installer /usr/local/bin/uv /usr/local/bin/uv +COPY --from=installer /usr/local/bin/uvx /usr/local/bin/uvx + +# npm globals (claude-code, openspec, pnpm) — the whole prefix directory, +# which contains lib/node_modules/ and bin/. PATH is extended below. +COPY --from=installer /opt/npm-global /opt/npm-global + +# tfenv — installed under /opt (not /root) so image-level version bumps aren't +# shadowed by the claude-code-root named volume at runtime. +COPY --from=installer /opt/tfenv /opt/tfenv +RUN ln -s /opt/tfenv/bin/tfenv /usr/local/bin/tfenv \ + && ln -s /opt/tfenv/bin/terraform /usr/local/bin/terraform + # Plain `tmux` mode swallows Shift+Enter so Claude's prompt sees only Enter, # forcing users to type `\` for a literal newline. `always` is required # (not `on`) because Claude does not send the kitty activation request that # `on` waits for — see claude-code#26629. /etc/tmux.conf, not # /root/.tmux.conf, because /root is masked by the claude-code-root named # volume at runtime. Harmless under tmux -CC: iTerm2 control mode bypasses -# tmux's input layer. Placed after npm install so edits don't invalidate -# the heavy AWS CLI / uv / glab / npm download layers above. +# tmux's input layer. RUN cat > /etc/tmux.conf <<'EOF' set -s extended-keys always set -as terminal-features "*:extkeys" @@ -201,15 +262,16 @@ ENV CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 \ DISABLE_AUTOUPDATER=1 \ IS_SANDBOX=1 \ LANG=C.UTF-8 \ - LC_ALL=C.UTF-8 + LC_ALL=C.UTF-8 \ + PATH="/opt/npm-global/bin:${PATH}" # Container starts as root so the entrypoint can chown /root to the host # UID, then drops privileges via runuser. Steady-state, claude runs as the -# host user with no effective / permitted / ambient capabilities — the -# kernel clears those on the UID→non-zero transition; the bounding set -# retains the setup caps but is inert under `no-new-privileges`. Do not -# add a `USER` directive here: the entrypoint expects to start as root so -# it can perform the chown. +# host user (or the baked-in UID 999 `claude` user when HOST_UID is unset) +# with no effective / permitted / ambient capabilities — the kernel clears +# those on the UID→non-zero transition; the bounding set retains the setup +# caps but is inert under `no-new-privileges`. No USER directive: the +# entrypoint must start as root to perform the chown and useradd steps. # See entrypoint.sh and run.sh's --cap-add lines for the full picture. WORKDIR /workspaces diff --git a/entrypoint.sh b/entrypoint.sh index 4181a3e..302f9ec 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -4,8 +4,8 @@ # # Drop from container root to the host user's UID/GID before exec'ing claude # so that files written through bind-mounts match host ownership. With -# HOST_UID unset or 0, falls through to the legacy "run as root" behavior so -# the image still works in environments that don't forward the host UID. +# HOST_UID unset or 0, falls back to the baked-in non-root `claude` user +# (UID 999) so the container never runs the workload as root. set -euo pipefail HOST_UID="${HOST_UID:-0}" @@ -44,7 +44,7 @@ update-ca-certificates >/dev/null 2>&1 \ fi if [ "$HOST_UID" = 0 ]; then -exec "$@" +exec runuser -u claude -- "$@" fi # Synthesize a passwd entry so getpwuid / $HOME / shell expansions resolve From f2698ad8811a317063dc5b4769bd9f74646508e5 Mon Sep 17 00:00:00 2001 From: sbp-bvanb Date: Tue, 4 Aug 2026 08:10:32 +0200 Subject: [PATCH 10/13] fix(dockerfile): bootstrap ca-certificates before NodeSource repo; bump node to 24.18.1 Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 08dbc46..f5afae7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,7 +15,7 @@ # it alone and only reminds the operator to check it. # NODE_VERSION format is NodeSource's: -1nodesource1. # Bump with: curl -fsSL https://deb.nodesource.com/node_24.x/dists/nodistro/main/binary-amd64/Packages.gz | gunzip | grep -E '^(Package|Version):' | head -4 -ARG NODE_VERSION=24.17.0-1nodesource1 +ARG NODE_VERSION=24.18.1-1nodesource1 # ── installer ──────────────────────────────────────────────────────────────── # Has curl, gnupg, unzip, and all apt repository infrastructure needed to @@ -199,6 +199,13 @@ RUN groupadd -g 999 claude \ # as a binary below. COPY --from=installer /etc/apt/keyrings/nodesource.gpg /etc/apt/keyrings/nodesource.gpg +# ca-certificates must be installed before apt-get update can verify the +# NodeSource HTTPS repo — installing it in the same RUN that adds the repo +# is too late, so we bootstrap it first. +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* + # NodeSource ships Node 24 LTS pinned to upstream releases — Ubuntu's archive # `nodejs` tracks an older minor and isn't LTS-pinned. `nodistro` is # NodeSource's distro-independent codename (works on any Debian/Ubuntu). @@ -207,8 +214,6 @@ RUN chmod go+r /etc/apt/keyrings/nodesource.gpg \ > /etc/apt/sources.list.d/nodesource.list \ && apt-get update \ && apt-get install -y --no-install-recommends \ - ca-certificates \ - curl \ "nodejs=${NODE_VERSION}" \ git \ git-lfs \ From 39e8105f6e43c3fb63c34969160d9deddd61d5b8 Mon Sep 17 00:00:00 2001 From: sbp-bvanb Date: Tue, 4 Aug 2026 08:24:39 +0200 Subject: [PATCH 11/13] fix(dockerfile): drop unused setuid/setgid bits; strip tfenv's bundled Dockerfile - chmod -s account-management, mount, and host-based SSH auth binaries that this container never exercises (entrypoint drops privilege via runuser, not su), closing off privilege-escalation paths for the claude user (dockle CIS-DI-0008) - remove /opt/tfenv/Dockerfile, an unnecessary file bundled in tfenv's release tarball (dockle DKL-LI-0003) Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index f5afae7..7622966 100644 --- a/Dockerfile +++ b/Dockerfile @@ -165,7 +165,7 @@ RUN . /tmp/tfenv.env \ && echo "${TFENV_SHA256} /tmp/tfenv.tar.gz" | sha256sum -c - \ && mkdir -p /opt/tfenv \ && tar -xzf /tmp/tfenv.tar.gz -C /opt/tfenv --strip-components=1 \ - && rm /tmp/tfenv.tar.gz /tmp/tfenv.env + && rm /tmp/tfenv.tar.gz /tmp/tfenv.env /opt/tfenv/Dockerfile # ── runtime ─────────────────────────────────────────────────────────────────── @@ -225,6 +225,18 @@ RUN chmod go+r /etc/apt/keyrings/nodesource.gpg \ && git lfs install --system --skip-repo \ && rm -rf /var/lib/apt/lists/* +# Strip setuid/setgid bits from base-image and openssh-client binaries whose +# privileged function (password/account management, mount, host-based SSH +# auth) is never exercised by this container — entrypoint uses `runuser`, not +# `su`, and there's no local password store or fstab to manage. Removing the +# bit closes off privilege-escalation paths for the dropped-privilege +# `claude` user without touching the binaries' non-privileged behavior. +RUN chmod -s \ + /usr/bin/chage /usr/bin/chfn /usr/bin/chsh /usr/bin/expiry \ + /usr/bin/gpasswd /usr/bin/mount /usr/bin/passwd /usr/bin/su \ + /usr/bin/umount /usr/bin/ssh-agent /usr/lib/openssh/ssh-keysign \ + /usr/sbin/unix_chkpwd /usr/sbin/pam_extrausers_chkpwd + # gh and glab: self-contained Go binaries — no apt repo infrastructure needed. COPY --from=installer /usr/bin/gh /usr/bin/gh COPY --from=installer /usr/bin/glab /usr/bin/glab From b9e5cfdbc6fcb8a03bb4e7614698f6be0a64163b Mon Sep 17 00:00:00 2001 From: sbp-bvanb Date: Tue, 4 Aug 2026 09:02:03 +0200 Subject: [PATCH 12/13] docs(dockle): add .dockleignore for CIS-DI-0001 with rationale Root-start-then-drop is load-bearing, not a fixable oversight: the entrypoint needs root to chown /root to the host UID and useradd a matching passwd entry before dropping via runuser, and no-new-privileges rules out a file-capability workaround. Document why alongside the existing Dockerfile comment instead of letting dockle flag it every run. Co-Authored-By: Claude Opus 4.6 --- .dockleignore | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .dockleignore diff --git a/.dockleignore b/.dockleignore new file mode 100644 index 0000000..567a31b --- /dev/null +++ b/.dockleignore @@ -0,0 +1,9 @@ +# No USER directive by design: the container starts as root so the +# entrypoint can chown /root to the host UID and useradd a matching +# passwd entry, then drops privileges via runuser. Steady-state, claude +# runs as the host user (or the baked-in UID 999 `claude` user when +# HOST_UID is unset) with no effective/permitted/ambient capabilities — +# the kernel clears those on the UID→non-zero transition; the bounding +# set retains the setup caps but is inert under `no-new-privileges`. +# See entrypoint.sh and run.sh's --cap-add lines for the full picture. +CIS-DI-0001 From c4451abd59a3562c7dbfa679f0ac159494a57557 Mon Sep 17 00:00:00 2001 From: sbp-bvanb Date: Tue, 4 Aug 2026 09:53:01 +0200 Subject: [PATCH 13/13] fix(entrypoint): stop colliding with the baked-in claude account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 806b843 baked a static claude user/group (999:999) into the image but left the entrypoint's runtime UID-mapping untouched, causing two regressions: - Non-zero HOST_UID: useradd/groupadd tried to create a SECOND account named `claude`, which always fails with "already exists" once the name (not just the UID/GID) collides — i.e. on every real host UID. Fixed by usermod/groupmod-ing the existing baked account instead. - HOST_UID=0 (unset): the entrypoint dropped to the baked-in claude account but skipped the chown walk (early exit), leaving /root root-owned 0700 — claude couldn't even traverse into its own HOME, breaking settings seeding and any persisted state. Fixed by folding this path into the same chown-then-drop flow, targeting 999:999. Updates smoke.sh/assert-in-container.sh/ci.yml, which still encoded the pre-806b843 assumption that HOST_UID=0 stays root. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 6 +++--- entrypoint.sh | 36 ++++++++++++++++++++++++------------ smoke/assert-in-container.sh | 23 ++++++----------------- smoke/smoke.sh | 16 +++++++++++++--- 4 files changed, 46 insertions(+), 35 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d6cd01..49251a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,7 +130,7 @@ jobs: # 4. runner-uid/cold/aws+glab+tfe — all three opt-ins combined # 5. runner-uid/warm — warm volume (HOST_UID-owned /root traversal) # 6. uid-501/cold/no-optins — macOS-style UID (<1000, below Ubuntu floor) - # 7. uid-0/cold/no-optins — root-legacy path (HOST_UID=0 → exec directly) + # 7. uid-0/cold/no-optins — HOST_UID unset/0 fallback (entrypoint drops to the baked-in claude/999) # 8. runner-uid/ephemeral — --ephemeral mode (no named volumes) # 9. runner-uid/ro/no-optins — read-only workspace (EROFS robustness) run: | @@ -162,8 +162,8 @@ jobs: # 6. macOS-style UID 501 (below Ubuntu's default UID_MIN=1000). run_cell --uid=501 --volstate=cold --ephemeral=0 - # 7. Root-legacy path: HOST_UID=0 → entrypoint skips useradd+runuser and - # exec's claude directly as container root. + # 7. HOST_UID unset/0 fallback: the entrypoint drops to the baked-in + # claude account (999:999) instead of leaving the workload as root. run_cell --uid=0 --volstate=cold --ephemeral=0 # 8. Ephemeral mode: no named volumes. diff --git a/entrypoint.sh b/entrypoint.sh index 302f9ec..b8dc860 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -43,21 +43,33 @@ update-ca-certificates >/dev/null 2>&1 \ || printf 'entrypoint: WARN update-ca-certificates failed for the gh-auth-proxy CA\n' >&2 fi +# TARGET_UID/GID resolve HOST_UID=0 (unset) to the baked-in claude account +# (999:999, see Dockerfile) so the fallback path never runs the workload as +# root. Used below for both the account remap and the /root chown walk: the +# fallback needs the exact same "claude can read its own HOME" treatment as +# a real HOST_UID, since /root ships root-owned 0700 and claude otherwise +# can't even traverse into it. if [ "$HOST_UID" = 0 ]; then -exec runuser -u claude -- "$@" -fi +TARGET_UID=999 +TARGET_GID=999 +else +TARGET_UID="$HOST_UID" +TARGET_GID="$HOST_GID" -# Synthesize a passwd entry so getpwuid / $HOME / shell expansions resolve -# cleanly inside the container. -o (--non-unique) tolerates a HOST_UID that -# happens to collide with a baked-in Ubuntu system user. HOME=/root is -# deliberate — keeps the existing /root/.claude, /root/.aws, /root/.config -# mount paths intact instead of forcing a layout migration. -# -K UID_MIN=1 overrides the login.defs floor per-call so macOS UIDs (≥501, -# below Ubuntu's default 1000) don't trigger a warning. +# Remap the baked-in `claude` account (UID/GID 999) to the host's UID/GID +# rather than useradd/groupadd-ing a second account: the image already has +# a passwd/group entry named `claude`, so creating another one under that +# same name fails with "already exists" the moment HOST_UID/GID isn't +# already 999 (i.e. almost always). usermod/groupmod -o (--non-unique) +# tolerates a HOST_UID/GID that happens to collide with a baked-in Ubuntu +# system account. HOME stays /root — keeps the existing /root/.claude, +# /root/.aws, /root/.config mount paths intact instead of forcing a layout +# migration. if ! getent passwd "$HOST_UID" >/dev/null 2>&1; then getent group "$HOST_GID" >/dev/null 2>&1 \ - || groupadd -o -g "$HOST_GID" claude -useradd -o -K UID_MIN=1 -u "$HOST_UID" -g "$HOST_GID" -d /root -s /bin/bash -M -N claude + || groupmod -o -g "$HOST_GID" claude +usermod -o -u "$HOST_UID" -g "$HOST_GID" claude +fi fi # Chown the persistent /root volumes (claude-code-root, claude-code-home) @@ -75,7 +87,7 @@ fi # container root can traverse HOST_UID-owned, mode-0700 directories # under /root. chown_errs="$(find /root /root/.claude -xdev -print0 \ - | xargs -0 --no-run-if-empty chown -h "$HOST_UID:$HOST_GID" 2>&1 >/dev/null || true)" + | xargs -0 --no-run-if-empty chown -h "$TARGET_UID:$TARGET_GID" 2>&1 >/dev/null || true)" chown_errs="$(grep -v 'Read-only file system' <<<"$chown_errs" || true)" [ -n "$chown_errs" ] && printf 'entrypoint: WARN chown: %s\n' "$chown_errs" >&2 || true diff --git a/smoke/assert-in-container.sh b/smoke/assert-in-container.sh index 2336fb2..c1a3e35 100644 --- a/smoke/assert-in-container.sh +++ b/smoke/assert-in-container.sh @@ -107,11 +107,8 @@ check_identity() { fi # And it must be the EXPECTED primary GID — a regression assigning claude to - # gid 0 (root) as its single group would pass the count check above. Skip for - # the root-legacy cell, where the process keeps gid 0 (not HOST_GID). - if [ "${EXPECT_UID:-0}" != "0" ]; then - assert_eq "primary-GID=EXPECT_GID" "$(id -g)" "${EXPECT_GID:-0}" - fi + # gid 0 (root) as its single group would pass the count check above. + assert_eq "primary-GID=EXPECT_GID" "$(id -g)" "${EXPECT_GID:-0}" # HOME must be /root regardless of HOST_UID (kept for mount-path compatibility). home_val="${HOME:-}" @@ -137,8 +134,7 @@ check_security() { cap_bnd=$(grep '^CapBnd:' "$status_file" | awk '{print $2}') nnp=$(grep '^NoNewPrivs:' "$status_file" | awk '{print $2}') - # Universal invariants — these hold regardless of HOST_UID (including the - # root-legacy path), so assert them BEFORE the uid=0 branch below. + # Universal invariants — these hold regardless of HOST_UID. # no-new-privileges and the absence of setuid-root binaries are exactly the # controls that bound blast radius if the root path is ever entered, so the # root cell is the one place we most want them confirmed. @@ -161,17 +157,10 @@ check_security() { pass "no-sudo: sudo not installed (no escalation path beyond the inert base setuid set)" fi - # Root-legacy path: with HOST_UID=0 the entrypoint execs directly as root - # (entrypoint.sh :11-13) and never drops privileges, so root legitimately - # retains effective caps. The dropped-privilege posture below does NOT apply; - # asserting CapEff==0 here would wrongly fail a correct image. - if [ "${EXPECT_UID:-0}" = "0" ]; then - pass "security: HOST_UID=0 root-legacy path — cap-drop posture N/A (runs as root by design)" - return - fi - # Dropped-privilege posture: CapEff/CapPrm/CapAmb must all be zero after - # runuser drops UID 0. CapBnd retains the --cap-add set but is inert under + # runuser drops UID 0 — including the HOST_UID=0 fallback, which now drops + # to the baked-in claude account (999) exactly like any other cell, never + # staying root. CapBnd retains the --cap-add set but is inert under # NoNewPrivs. Bit decomposition of the expected 0xc5 bounding set: # CAP_CHOWN (0) → bit 0 → 0x001 # CAP_DAC_READ_SEARCH(2) → bit 2 → 0x004 diff --git a/smoke/smoke.sh b/smoke/smoke.sh index 6529b64..66402e7 100644 --- a/smoke/smoke.sh +++ b/smoke/smoke.sh @@ -133,11 +133,20 @@ SECURITY_ARGS=( # Core env. EXPECT_SETTINGS / EXPECT_SETTINGS_SENTINEL are injected per pass # in run_container — the warm cell varies them between passes. +# EXPECT_UID/GID mirror HOST_UID/GID, except HOST_UID=0 (unset): the +# entrypoint resolves that to the baked-in claude account (999:999, see +# entrypoint.sh), so the fallback cell must expect 999, not 0. +EXPECT_UID_ARG="${HOST_UID_ARG}" +EXPECT_GID_ARG="${HOST_GID_ARG}" +if [ "${HOST_UID_ARG}" = "0" ]; then + EXPECT_UID_ARG=999 + EXPECT_GID_ARG=999 +fi ENV_ARGS=( "-e" "HOST_UID=${HOST_UID_ARG}" "-e" "HOST_GID=${HOST_GID_ARG}" - "-e" "EXPECT_UID=${HOST_UID_ARG}" - "-e" "EXPECT_GID=${HOST_GID_ARG}" + "-e" "EXPECT_UID=${EXPECT_UID_ARG}" + "-e" "EXPECT_GID=${EXPECT_GID_ARG}" "-e" "EXPECT_OPTINS=${OPTINS}" "-e" "EXPECT_RO=${RO}" "-e" "WORKSPACE=${CONTAINER_WORKSPACE}" @@ -346,7 +355,8 @@ if [ "${RO}" = "0" ]; then # numerically, so a file the container wrote as HOST_UID is owned by that # same UID on the host — `stat` reads it back regardless of the runner's own # UID (so this covers the uid=501 cell too). Skipped only for HOST_UID=0, - # where the file is root-owned and ownership round-trip is not the point. + # where the entrypoint falls back to the baked-in claude account (999) and + # there's no host-side account to round-trip against. if [ "${HOST_UID_ARG}" != "0" ]; then PROBE_OWNER=$(stat -c '%u' "${PROBE_FILE}" 2>/dev/null || stat -f '%u' "${PROBE_FILE}" 2>/dev/null) if [ "${PROBE_OWNER}" = "${HOST_UID_ARG}" ]; then