Skip to content
Open
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
9 changes: 9 additions & 0 deletions .dockleignore
Original file line number Diff line number Diff line change
@@ -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
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down Expand Up @@ -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.
Expand Down
33 changes: 33 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
@@ -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,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 }}
204 changes: 142 additions & 62 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,27 @@
# 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
# it alone and only reminds the operator to check it.
# NODE_VERSION format is NodeSource's: <upstream>-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
# 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/<tool>.env — NOT an ARG. Each install RUN below COPYs and sources its
Expand Down Expand Up @@ -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 \
Expand All @@ -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 \
Expand All @@ -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 \
Expand All @@ -100,11 +101,10 @@ 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
# 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 \
Expand All @@ -118,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-<arch>-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 \
Expand All @@ -139,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 <pkg>@<ver>` 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
Expand All @@ -167,27 +159,114 @@ 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
&& rm /tmp/tfenv.tar.gz /tmp/tfenv.env /opt/tfenv/Dockerfile


# ── 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

# 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).
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 \
"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/*

# 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

# 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"
Expand All @@ -200,15 +279,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
Expand Down
Loading
Loading