Skip to content
Merged
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
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<hash>.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-<hash>.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.

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
28 changes: 19 additions & 9 deletions SPECIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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` |
Expand Down
2 changes: 1 addition & 1 deletion proxy/go.mod
Original file line number Diff line number Diff line change
@@ -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

Expand Down
26 changes: 18 additions & 8 deletions sandy
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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" <<DOCKERFILE
# syntax pinned implicitly by the golang base tag
# freshness-epoch: ${epoch} (monthly proxy base/stdlib refresh — HF-incident Issue 3)
FROM golang:1.24-bookworm AS build
FROM golang:1.26-trixie AS build
ARG SANDY_PROXY_REF=${ref}
RUN git clone https://github.com/rappdw/sandy /src \\
&& git -C /src checkout "\${SANDY_PROXY_REF}" \\
Expand Down Expand Up @@ -6008,7 +6018,7 @@ if [ "$_SANDY_PROXY_ON" = true ]; then
if [ ! -f "$PROXY_HASH_FILE" ] || [ "$(cat "$PROXY_HASH_FILE")" != "$PROXY_HASH" ] || ! docker image inspect "$PROXY_IMAGE_NAME" &>/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.
Expand Down
19 changes: 18 additions & 1 deletion test/run-tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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" \
Expand Down Expand Up @@ -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" \
Expand Down
Loading