From c6ca18037e59e8cb817edcd8f3680d987ac46df5 Mon Sep 17 00:00:00 2001 From: Daniel Rapp Date: Tue, 11 Aug 2026 16:50:31 +0000 Subject: [PATCH] chore(base): Go 1.24.1 -> 1.26 (build-time patch resolve), Node 22 -> 24 Go 1.24 left Go's two-release support window, so both pins were on an EOL toolchain receiving no security fixes: - Base image pinned ARG GO_VERSION=1.24.1 -- a Mar-2025 patch, untouched for 16 months across 20+ releases. The tarball URL needs a full x.y.z, so the base now pins the MINOR line and resolves the newest patch on it at build time from go.dev/dl/?mode=json, with the ARG as offline fallback (the same build-time-latest semantics NodeSource/rustup/Bun/uv already use here). Hardened: sort -rV picks the max patch explicitly rather than inheriting go.dev's array order, and a non-numeric resolved value falls back to the pin instead of 404-ing the build. Verified under dash: both the happy path and total network failure resolve 1.26.5 with chain exit 0. - Egress proxy built FROM golang:1.24-bookworm. That floating minor tag did deliver 12 patches (through 1.24.13, pushed 2026-02-04), but once 1.24 left support the documented monthly-freshness + --pull mechanism (HF-incident Issue 3) re-resolved to an unchanged digest -- refreshing only Debian, not the Go stdlib that implements the proxy's TLS/HTTP/CONNECT I/O. Moving to golang:1.26-trixie restores actual stdlib patch delivery. CLAUDE.md's description is corrected to match this timeline. - proxy/go.mod said go 1.24.1 and CI consumes it via go-version-file, so the proxy's vet/race/fuzz/govulncheck jobs were running on an EOL toolchain. - Node 22 is Maintenance LTS (EOL Apr 2027); 24 is Active LTS (Apr 2028). NodeSource node_24.x ships the distro-agnostic nodistro suite (amd64 + arm64), so this is independent of the pending trixie base migration. New run-tests.sh guards cover the base Go block, which had no test coverage: the pin is asserted to be a supported minor (rejects a regression to 1.24), plus build-time resolution, offline fallback, max-patch selection, and the non-numeric guard. Docs synced: README, CLAUDE.md, SPECIFICATION.md (Appendix A verbatim copy + Appendix B version table). Debian bookworm -> trixie and Python 3.11 -> 3.13 are deliberately NOT here; they follow as their own change. The proxy build stage moves to trixie ahead of the base image intentionally -- it compiles a static binary into FROM scratch, so its distro never reaches the runtime image. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 6 +++--- README.md | 4 ++-- SPECIFICATION.md | 28 +++++++++++++++++++--------- proxy/go.mod | 2 +- sandy | 26 ++++++++++++++++++-------- test/run-tests.sh | 19 ++++++++++++++++++- 6 files changed, 61 insertions(+), 24 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3e56c31..bdef785 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -304,7 +304,7 @@ The post-mortem's executive guidance is that every agent needs a named owner *"w ## Architecture -- **Three-phase Docker build**: A `sandy-base` image contains the OS, toolchains (Node.js 22, Go 1.24, Rust stable, Python 3, C/C++), and system tools. A `sandy-claude-code` image layers Claude Code on top. An optional per-project image (from `.sandy/Dockerfile`) layers project-specific tools on top of that. Each phase only rebuilds when its inputs change. The per-project `.sandy/Dockerfile` build is **approval-gated** (`_sandy_project_dockerfile_approved`): its `RUN` commands execute on the host daemon with unfiltered network, so an unapproved/edited Dockerfile prompts on an interactive TTY and **fails closed** (skips the build, uses the base image) when non-interactive — a committed or agent-written Dockerfile can't build unattended. Approval is a per-workspace sha256 in `$SANDY_HOME/approvals/dockerfile-.list`; `.sandy/` is also in the protected-dirs list so an existing one is `:ro` in-session (HF-incident Issue 7). +- **Three-phase Docker build**: A `sandy-base` image contains the OS, toolchains (Node.js 24, Go 1.26, Rust stable, Python 3, C/C++), and system tools. A `sandy-claude-code` image layers Claude Code on top. An optional per-project image (from `.sandy/Dockerfile`) layers project-specific tools on top of that. Each phase only rebuilds when its inputs change. The per-project `.sandy/Dockerfile` build is **approval-gated** (`_sandy_project_dockerfile_approved`): its `RUN` commands execute on the host daemon with unfiltered network, so an unapproved/edited Dockerfile prompts on an interactive TTY and **fails closed** (skips the build, uses the base image) when non-interactive — a committed or agent-written Dockerfile can't build unattended. Approval is a per-workspace sha256 in `$SANDY_HOME/approvals/dockerfile-.list`; `.sandy/` is also in the protected-dirs list so an existing one is `:ro` in-session (HF-incident Issue 7). - `sandy` — Self-contained launcher (bash script) installed to `~/.local/bin/`. On first run, generates Dockerfile.base, Dockerfile, entrypoint.sh, and tmux.conf in `~/.sandy/`, builds both Docker images, creates per-project sandbox directories, applies network isolation, and launches the container via `docker run`. - `install.sh` — `curl | bash` installer that downloads `sandy` to `~/.local/bin/` and checks PATH setup. @@ -393,7 +393,7 @@ Sandy wraps four third-party agents (Claude Code, Gemini CLI, Codex, OpenCode), ### Proxy image freshness (HF-incident Issue 3) -The **agent** image auto-rebuilds on a new agent version (above), but the **proxy** image only rebuilt when its git ref changed — i.e. when `SANDY_VERSION` changed — so between sandy releases the proxy's `golang:1.24-bookworm` base and Go stdlib (the TLS/HTTP/CONNECT I/O of the security-critical component) froze while the agent next to it self-updated weekly: the wrong patch-latency ordering for the component the incident's attacker chose. Fix: `generate_dockerfile_proxy()` writes a **monthly freshness epoch** (`date -u +%Y-%m`) into `Dockerfile.proxy`, so its content hash moves once a month and triggers a rebuild without a sandy release; that rebuild adds **`--pull`** so `FROM golang:1.24-bookworm` re-resolves to the current digest and picks up Go stdlib / Debian security fixes (`--no-cache` already rebuilt the binary but never re-pulled the base). `sandy --rebuild` forces it immediately. `--print-state` full mode reports `proxy_image_created` (the image's build timestamp) so `sandy-ui`/the user can see staleness. Guarded by `run-tests.sh §49`. +The **agent** image auto-rebuilds on a new agent version (above), but the **proxy** image only rebuilt when its git ref changed — i.e. when `SANDY_VERSION` changed — so between sandy releases the proxy's `golang:1.26-trixie` base and Go stdlib (the TLS/HTTP/CONNECT I/O of the security-critical component) froze while the agent next to it self-updated weekly: the wrong patch-latency ordering for the component the incident's attacker chose. Fix: `generate_dockerfile_proxy()` writes a **monthly freshness epoch** (`date -u +%Y-%m`) into `Dockerfile.proxy`, so its content hash moves once a month and triggers a rebuild without a sandy release; that rebuild adds **`--pull`** so `FROM golang:1.26-trixie` re-resolves to the current digest and picks up Go stdlib / Debian security fixes (`--no-cache` already rebuilt the binary but never re-pulled the base). The pinned line matters as much as the mechanism: `golang:1.24-bookworm` is a *floating minor* tag, so it genuinely delivered 12 patch releases (through Go 1.24.13, pushed 2026-02-04) — but once Go 1.24 left Go's 2-release support window there were no further 1.24.x pushes, and from ~Feb 2026 the monthly `--pull` re-resolved to an unchanged digest, refreshing only the Debian layer. Moving to the 1.26 line restores actual stdlib patch delivery; keeping the pin on a *supported* Go minor is what makes this mechanism work at all. `sandy --rebuild` forces it immediately. `--print-state` full mode reports `proxy_image_created` (the image's build timestamp) so `sandy-ui`/the user can see staleness. Guarded by `run-tests.sh §49`. ### `sandy --gc` — unified Docker-resource reclaim (#36) @@ -508,7 +508,7 @@ Two modes controlled by `SANDY_SSH`: ## Language Environments -The base image ships with fixed versions of each toolchain: Python 3 (Debian bookworm's default), Node.js 22, Go 1.24, Rust stable, and C/C++ (build-essential). `uv` is also pre-installed for Python version management. +The base image ships with fixed versions of each toolchain: Python 3 (Debian bookworm's default), Node.js 24, Go 1.26, Rust stable, and C/C++ (build-essential). `uv` is also pre-installed for Python version management. ### Persistent Package Installs diff --git a/README.md b/README.md index e2ad684..62c3a18 100644 --- a/README.md +++ b/README.md @@ -455,8 +455,8 @@ Sandy's base image is a self-contained development environment. Everything below | Toolchain | Version | Notes | |---|---|---| | Python 3 | Debian bookworm default | System Python; use `uv` for other versions | -| Node.js | 22 LTS | Via NodeSource | -| Go | 1.24 | | +| Node.js | 24 LTS | Via NodeSource | +| Go | 1.26 | Latest 1.26.x patch resolved at image build | | Rust | stable | Via rustup | | C/C++ | build-essential | gcc, g++, make, libc-dev | diff --git a/SPECIFICATION.md b/SPECIFICATION.md index cc6dce2..fe3b723 100644 --- a/SPECIFICATION.md +++ b/SPECIFICATION.md @@ -373,8 +373,8 @@ Contents: - **OS**: Debian bookworm-slim - **System tools**: build-essential, git, git-lfs, jq, ripgrep, socat, tmux, curl, cmake, openssh-client, less, pkg-config, gosu - **GitHub CLI**: `gh` -- **Node.js 22 LTS**: Via NodeSource -- **Go 1.24**: Multi-arch binary from go.dev +- **Node.js 24 LTS**: Via NodeSource +- **Go 1.26**: Multi-arch binary from go.dev (latest 1.26.x resolved at build time) - **Rust stable**: Via rustup (installed to `/usr/local/rustup` and `/usr/local/cargo`) - **Bun**: Via `curl https://bun.sh/install` - **uv**: Via `curl https://astral.sh/uv/install.sh` (installed to `/usr/local/bin`) @@ -1348,15 +1348,25 @@ RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ && apt-get update && apt-get install -y gh \ && rm -rf /var/lib/apt/lists/* -# Node.js 22 LTS via NodeSource -RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ +# Node.js 24 LTS via NodeSource +RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \ && apt-get install -y nodejs \ && rm -rf /var/lib/apt/lists/* -# Go (arch-aware) -ARG GO_VERSION=1.24.1 +# Go (arch-aware). GO_VERSION pins the minor line and is the offline fallback; +# each rebuild resolves the newest patch on that line from go.dev so base +# rebuilds pick up Go security fixes (same build-time-latest semantics as the +# Node/Rust/Bun/uv installs above). When this line leaves Go's 2-release +# support window, dl/?mode=json stops listing it and the fallback pin is used -- +# bump GO_VERSION to the new supported minor at that point. +ARG GO_VERSION=1.26.5 RUN ARCH="$(dpkg --print-architecture)" \ - && curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-${ARCH}.tar.gz" \ + && GO_LATEST="$(curl -fsSL --max-time 10 'https://go.dev/dl/?mode=json' \ + | jq -r --arg m "go${GO_VERSION%.*}." \ + '.[].version | select(startswith($m))' \ + | sed 's/^go//' | sort -rV | head -1)" \ + && case "$GO_LATEST" in ''|*[!0-9.]*) GO_LATEST="" ;; esac \ + && curl -fsSL "https://go.dev/dl/go${GO_LATEST:-$GO_VERSION}.linux-${ARCH}.tar.gz" \ | tar -C /usr/local -xz # Rust stable (system-wide) @@ -1767,8 +1777,8 @@ Capabilities SETUID/SETGID are needed for `gosu` privilege drop. CHOWN/DAC_OVERR | Tool | Version | Install Method | |---|---|---| -| Go | 1.24.1 | Multi-arch binary from go.dev | -| Node.js | 22 LTS | NodeSource `setup_22.x` | +| Go | 1.26 (latest patch at build; fallback pin 1.26.5) | Multi-arch binary from go.dev | +| Node.js | 24 LTS | NodeSource `setup_24.x` | | Rust | stable (latest) | rustup | | Bun | latest | `curl https://bun.sh/install` | | uv | latest | `curl https://astral.sh/uv/install.sh` | diff --git a/proxy/go.mod b/proxy/go.mod index 3a89262..b54fe39 100644 --- a/proxy/go.mod +++ b/proxy/go.mod @@ -1,6 +1,6 @@ module github.com/rappdw/sandy/proxy -go 1.24.1 +go 1.26.0 require github.com/miekg/dns v1.1.72 diff --git a/sandy b/sandy index e3eb2c4..6e0fc86 100755 --- a/sandy +++ b/sandy @@ -2718,15 +2718,25 @@ RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ && apt-get update && apt-get install -y gh \ && rm -rf /var/lib/apt/lists/* -# Node.js 22 LTS via NodeSource -RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ +# Node.js 24 LTS via NodeSource +RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \ && apt-get install -y nodejs \ && rm -rf /var/lib/apt/lists/* -# Go (arch-aware) -ARG GO_VERSION=1.24.1 +# Go (arch-aware). GO_VERSION pins the minor line and is the offline fallback; +# each rebuild resolves the newest patch on that line from go.dev so base +# rebuilds pick up Go security fixes (same build-time-latest semantics as the +# Node/Rust/Bun/uv installs above). When this line leaves Go's 2-release +# support window, dl/?mode=json stops listing it and the fallback pin is used -- +# bump GO_VERSION to the new supported minor at that point. +ARG GO_VERSION=1.26.5 RUN ARCH="$(dpkg --print-architecture)" \ - && curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-${ARCH}.tar.gz" \ + && GO_LATEST="$(curl -fsSL --max-time 10 'https://go.dev/dl/?mode=json' \ + | jq -r --arg m "go${GO_VERSION%.*}." \ + '.[].version | select(startswith($m))' \ + | sed 's/^go//' | sort -rV | head -1)" \ + && case "$GO_LATEST" in ''|*[!0-9.]*) GO_LATEST="" ;; esac \ + && curl -fsSL "https://go.dev/dl/go${GO_LATEST:-$GO_VERSION}.linux-${ARCH}.tar.gz" \ | tar -C /usr/local -xz # Rust stable (system-wide) @@ -3093,13 +3103,13 @@ generate_dockerfile_proxy() { # the agent binary next to it self-updates weekly. A monthly freshness epoch # moves the Dockerfile hash once a month, triggering a rebuild without a sandy # release; paired with --pull on the build (below), that rebuild re-resolves - # golang:1.24-bookworm to the current digest and picks up Go stdlib / Debian + # golang:1.26-trixie to the current digest and picks up Go stdlib / Debian # security fixes. Bound to one refresh/month so it's not per-launch churn. epoch="$(date -u +%Y-%m 2>/dev/null || echo static)" cat > "$SANDY_HOME/Dockerfile.proxy.new" </dev/null; then info "Building egress proxy image (sandy-proxy, ref=$(_sandy_proxy_ref))..." _old_proxy_id="$(docker image inspect -f '{{.Id}}' "$PROXY_IMAGE_NAME" 2>/dev/null || true)" - # --pull: re-resolve golang:1.24-bookworm to the current digest on every + # --pull: re-resolve golang:1.26-trixie to the current digest on every # proxy (re)build so the base + Go stdlib get security updates — the # monthly freshness epoch (HF-incident Issue 3) makes this fire ~monthly # without a sandy release. --no-cache already rebuilds the binary itself. diff --git a/test/run-tests.sh b/test/run-tests.sh index 0b08a20..1450cbe 100755 --- a/test/run-tests.sh +++ b/test/run-tests.sh @@ -4229,7 +4229,7 @@ SANDY_HOME="$_PX_TMP" SANDY_VERSION=0.13.1 SANDY_PROXY_REF="" GITHUB_HEAD_REF= \ generate_dockerfile_proxy" 2>/dev/null _PX_DF="$_PX_TMP/Dockerfile.proxy.new" check "proxy Dockerfile: golang build stage" \ - grep -qE '^FROM golang:[0-9.]+-bookworm AS build' "$_PX_DF" + grep -qE '^FROM golang:[0-9.]+-trixie AS build' "$_PX_DF" check "proxy Dockerfile: scratch runtime stage" \ grep -qx 'FROM scratch' "$_PX_DF" check "proxy Dockerfile: pins ref via build-arg default to the version tag" \ @@ -4260,6 +4260,23 @@ check "proxy Dockerfile generation emits no stderr (no unescaped shell-specials generate_dockerfile_proxy" 2>&1 >/dev/null)"; rm -rf "$_h"; [ -z "$_e" ]' -- "$_PX_FNS" rm -rf "$_PX_TMP" +# Base-image Go pin: the tarball URL needs a full x.y.z, so the base resolves the +# newest patch on the pinned MINOR line at build time (go.dev/dl/?mode=json) with +# the ARG as offline fallback. Guard the three properties that make that safe -- +# without them the build either freezes on a stale patch (the 1.24.1 rot: pinned +# 2026-02, untouched for 16 months across 20+ releases) or hard-fails offline. +_GO_DFB="$(sed -n '/^generate_dockerfile_base()/,/^}$/p' "$_PX_SCRIPT")" +check "base Dockerfile: Go pin is a supported minor line (not EOL)" \ + bash -c 'printf "%s" "$1" | grep -qE "^ARG GO_VERSION=1\.(2[5-9]|[3-9][0-9])\."' -- "$_GO_DFB" +check "base Dockerfile: Go patch resolved at build time from go.dev" \ + bash -c 'printf "%s" "$1" | grep -qF "go.dev/dl/?mode=json"' -- "$_GO_DFB" +check "base Dockerfile: Go resolver falls back to the ARG pin when go.dev is unreachable" \ + bash -c 'printf "%s" "$1" | grep -qF "${GO_LATEST:-$GO_VERSION}"' -- "$_GO_DFB" +check "base Dockerfile: Go resolver picks the max patch, not array order" \ + bash -c 'printf "%s" "$1" | grep -qF "sort -rV"' -- "$_GO_DFB" +check "base Dockerfile: Go resolver rejects a non-numeric resolved value" \ + bash -c 'printf "%s" "$1" | grep -qF "*[!0-9.]*"' -- "$_GO_DFB" + # Build phase: gated on the normalized proxy predicate, uses .build_hash_proxy, # cleared by --rebuild. check "proxy build phase gated on _SANDY_PROXY_ON" \