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
33 changes: 27 additions & 6 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -261,20 +261,41 @@ set -s extended-keys always
set -as terminal-features "*:extkeys"
EOF

# Go environment. Spelled with a literal /root rather than ${HOME}: Docker does
# not define HOME during the build, so "${HOME}/go" would expand to "/go". /root
# is correct for both paths through the entrypoint — the legacy root fallback,
# and the dropped-privilege user, whose passwd entry is created with -d /root.
# GOROOT is already baked into the go.dev tarball; set explicitly so scripts that
# read the variable directly (rather than `go env GOROOT`) see it too.
# GOBIN is spelled out rather than written as ${GOPATH}/bin for the same class of
# reason: Docker resolves a variable against the value from *before* the current
# instruction, so ${GOPATH}/bin inside this ENV would yield "/bin".
ENV GOBIN=/root/go/bin \
GOPATH=/root/go \
GOROOT=/usr/local/go

# DISABLE_AUTOUPDATER=1 keeps the pinned CLAUDE_CODE_VERSION authoritative —
# without it, claude auto-replaces itself at runtime, defeating the
# --ignore-scripts supply-chain pinning above. Bump the image to upgrade.
# PATH: /usr/local/go/bin is the go.dev-prescribed entry for the toolchain.
# $HOME/go/bin (GOPATH default; HOME=/root for the dropped user) is appended
# LAST on purpose — `go install` targets it and /root persists in the
# claude-code-root volume, so anything a session drops there must never be able
# to shadow a system binary earlier in PATH.
# No TASK_X_REMOTE_TASKFILES here: go-task's `includes:` from a remote URL left
# experimental in 3.53.1, so the feature is on by default and setting the opt-in
# makes every `task` invocation warn that the experiment is released. It stays a
# runtime code-fetch primitive in the same class as `pnpm dlx`/`uvx`/`go install`;
# task still prompts before trusting a new remote Taskfile checksum.
# PATH: /usr/local/go/bin comes first — the go.dev-prescribed entry for the
# toolchain, ahead of the system paths so the image's pinned Go wins.
# /root/.local/bin (the pip --user / `uv tool install` prefix) and /root/go/bin
# (the GOPATH default, where `go install` writes) are appended LAST on purpose:
# both live in the persistent claude-code-root volume and are writable by the
# session, so a binary one session drops there must never be able to shadow a
# system binary (git, gh, aws, …) on a later run. Tools installed into either
# stay runnable by name; only deliberate overrides are given up.
ENV CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 \
DISABLE_AUTOUPDATER=1 \
IS_SANDBOX=1 \
LANG=C.UTF-8 \
LC_ALL=C.UTF-8 \
PATH="/usr/local/go/bin:${PATH}:/root/go/bin"
PATH="/usr/local/go/bin:${PATH}:/root/.local/bin:/root/go/bin"

# 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
Expand Down
104 changes: 104 additions & 0 deletions openspec/changes/archive/2026-08-29-export-go-env/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
## Context

`/root` is the persistent `claude-code-root` named volume (`run.sh:861`): it
survives `docker run --rm`, and it is shared across every session and every
workspace on the host. The agent runs as a dropped-privilege user whose passwd
entry is created with `-d /root`, so `/root` is both `HOME` and writable.

The image `PATH` reaches that agent verbatim. `entrypoint.sh:89` is
`exec runuser -u claude`, not `runuser -l` — no login shell, no
`/etc/profile`, no re-derivation of `PATH`. Whatever the `ENV` says is what a
session resolves commands against. That makes PATH ordering an image-level
security control, not a convenience setting, and it is why the existing
`go-toolchain` spec pins `/root/go/bin` last.

## Goals / Non-Goals

**Goals:**

- `GOROOT` / `GOPATH` / `GOBIN` readable from the process environment by
non-Go tooling, with the same values `go env` would compute.
- Session-installed tools under `/root/.local/bin` runnable by name.
- No new way for one session to influence a later session's command
resolution.
- The non-shadowing guarantee actually tested, not merely asserted in prose.

**Non-Goals:**

- No `GOCACHE` / `GOMODCACHE` / `GOFLAGS` / `GOPRIVATE` export — those have
working defaults under `/root` that the volume already persists, and pinning
them adds surface without a caller that needs it.
- No change to `GOTOOLCHAIN`, which stays at its default `auto`.
- No image-side installation into `/root/.local/bin`.

## Decisions

### Literal `/root`, not `${HOME}`

Docker does not define `HOME` during a build. `ENV GOPATH="${HOME}/go"` expands
against an empty string and bakes `GOPATH=/go` into the image — a directory
that does not exist, with no build-time error. `/root` is correct for both
paths through the entrypoint: the legacy `HOST_UID=0` fallback runs as root,
and the dropped-privilege user's passwd entry is created with `-d /root`.
`smoke/assert-in-container.sh` already asserts `HOME=/root` in both cells, so
the equivalence is enforced rather than assumed.

### Literal `/root/go/bin` for `GOBIN`, not `${GOPATH}/bin`

Docker resolves a variable reference in an `ENV` instruction against the value
from *before* that instruction, so `GOBIN="${GOPATH}/bin"` alongside
`GOPATH=/root/go` in the same `ENV` yields `GOBIN=/bin` — silently, and
pointing at a system directory.

An earlier draft worked around this with a separate `ENV GOHOME=/root/go`
instruction referenced by the next one. Rejected: `GOHOME` is not a Go variable,
`go env` ignores it, and it would ship in every session's environment inviting
`-e GOHOME=/other` overrides that do nothing (both dependants are baked at
build time). Spelling both paths out literally removes the workaround instead of
shipping it; the gotcha survives as a comment.

### `/root/.local/bin` goes behind every system path

The tempting framing is that a user-local prefix conventionally wins — that is
what it means on a normal workstation, where `~/.local/bin` is under the
control of the person typing the commands.

That does not transfer here. `/root/.local/bin` is written by an agent, not by
a person; nothing in the image ever writes to it, so by construction every byte
on that path arrives from a session. And because the directory is in the shared
volume, it is read by *later* sessions, in *other* workspaces, holding *other*
credentials. In first position it is a persistence primitive: one session writes
`/root/.local/bin/gh`, a later unrelated session runs `gh` and executes it.

There is nothing the image can check that distinguishes `/root/.local/bin` from
`/root/go/bin` — same volume, same writer, same lifetime. Treating them
differently would rest on the installer's intent, which the image cannot see.
So both go behind the system paths, `/root/.local/bin` first of the two on the
weak-but-harmless grounds that it is the more deliberate of the two
destinations. Tools installed either way stay runnable by name; the only
capability given up is overriding a system binary, which is precisely what the
requirement forbids.

### The requirement is renamed, not edited in place

The old title — "PATH ordering keeps GOPATH binaries non-shadowing" — and its
single `/root/go/bin` scenario would leave the new entry uncovered: a reader
greps the title, concludes the requirement is about `go install` output, and
misses that another writable directory is on PATH under the same guarantee. The
delta therefore removes the old requirement and adds a renamed one carrying
both directories and four scenarios, following the `REMOVED` + `ADDED` pattern
used by `2026-08-25-container-git-config-overlay`.

## Risks / Trade-offs

- **A tool that genuinely needs to override a system binary breaks.** Accepted:
that is the guarantee, and the workaround (invoke by absolute path, or shadow
it per-project) is available to a user who really wants it.
- **The smoke check pins `/usr/bin/git`.** If a future change moves `git` (e.g.
a newer git installed under `/usr/local/bin`), the assertion fails loudly
rather than silently stopping to test anything. That is the intended failure
mode; update the expectation with the move.

## Open Questions

None.
67 changes: 67 additions & 0 deletions openspec/changes/archive/2026-08-29-export-go-env/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
## Why

The image installs Go but exports none of its environment. `GOROOT`, `GOPATH`,
and `GOBIN` exist only as `go env` defaults, computed by the toolchain from
`HOME` at invocation time. Anything that is not the `go` binary — a Makefile
that references `$(GOBIN)`, a Taskfile, a CI helper script, a linter wrapper —
reads the process environment, finds the variables unset, and either guesses or
fails. The values are already fixed and known at build time, so leaving them
implicit buys nothing.

Separately, the `pip install --user` / `uv tool install` prefix
`/root/.local/bin` is not on PATH at all, so a tool a session installs that way
is not runnable by name. That directory sits in the persistent
`claude-code-root` volume and is writable by the agent — exactly the property
that already pins `/root/go/bin` to last position — so it can be added, but only
behind every system path.

## What Changes

- Export `GOROOT=/usr/local/go`, `GOPATH=/root/go`, `GOBIN=/root/go/bin` as
image `ENV`, so they hold for `docker run`, `docker exec`, and non-login
shells alike, spelled against a literal `/root`.
- Add `/root/.local/bin` to the default PATH in second-to-last position, ahead
of `/root/go/bin` and behind every system path.
- Broaden the PATH-ordering requirement from "GOPATH binaries" to
volume-persisted directories generally, so the guarantee covers both
agent-writable entries rather than naming only the one that existed first.
- Assert both in `smoke/assert-in-container.sh`. The existing non-shadowing
requirement has never had a test; the new check enforces the ordering, the
resolution of `git` to `/usr/bin/git`, and the three exported values.

Not in scope: setting `TASK_X_REMOTE_TASKFILES=1`. go-task's remote `includes:`
left experimental in 3.53.1, so the feature is on by default and setting the
opt-in only makes every `task` invocation warn that the experiment is released.
It stays a runtime code-fetch primitive in the same class as `pnpm dlx`, `uvx`,
and `go install`, and `task` still prompts before trusting a new remote
Taskfile checksum. The Dockerfile records this as a comment so the next reader
does not re-derive it.

Not in scope: making the image itself install anything into `/root/.local/bin`.
Nothing in the build writes there today; the entry is for session-installed
tools only.

## Capabilities

### New Capabilities

None.

### Modified Capabilities

- `go-toolchain`: adds a requirement that the Go environment is exported by the
image rather than left to `go env` defaults, and replaces the GOPATH-specific
PATH-ordering requirement with one covering every agent-writable,
volume-persisted PATH entry.

## Impact

- `Dockerfile` — one new `ENV` instruction for the Go variables and a reordered
`PATH`. No new layer content, no download, no size change beyond the
environment block.
- `smoke/assert-in-container.sh` — a `check_path_order()` function and two
helpers; adds six assertions to every smoke cell.
- No change to `run.sh`, the entrypoint, the capability set, or the credential
model. The privilege drop is unaffected: `entrypoint.sh` execs
`runuser -u claude` (not `-l`), so the image PATH reaches the agent unchanged
either way — which is why its ordering is the security-relevant artifact.
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
## ADDED Requirements

### Requirement: Go environment exported by the image

The image SHALL export `GOROOT=/usr/local/go`, `GOPATH=/root/go`, and
`GOBIN=/root/go/bin` as `ENV`, so the values are present in the process
environment rather than existing only as `go env` defaults computed by the
toolchain. Scripts and non-Go tooling that read the variables directly SHALL
therefore agree with the toolchain.

The values SHALL be spelled against a literal `/root`, not `${HOME}`: Docker
does not define `HOME` during a build, so `"${HOME}/go"` expands to `/go`.
`/root` is the home directory on both paths through the entrypoint — the
legacy root fallback and the dropped-privilege user, whose passwd entry is
created with `-d /root`.

#### Scenario: the variables are set in a non-login shell

- **WHEN** a command runs in the container without a login shell — the
entrypoint's `runuser -u`, a `docker exec`, or a plain `docker run` command
- **THEN** `GOROOT` is `/usr/local/go`, `GOPATH` is `/root/go`, and `GOBIN` is
`/root/go/bin` in that process's environment

#### Scenario: the exported values match what the toolchain computes

- **WHEN** `go env GOROOT`, `go env GOPATH`, and `go env GOBIN` are compared
against the exported `GOROOT`, `GOPATH`, and `GOBIN`
- **THEN** each pair agrees, so a script reading the variable and a script
shelling out to `go env` reach the same directory

### Requirement: PATH ordering keeps volume-persisted directories non-shadowing

The default PATH SHALL place `/usr/local/go/bin` ahead of every system path,
and SHALL place both agent-writable directories that live in the persistent
`claude-code-root` volume — `/root/.local/bin` (the `pip install --user` /
`uv tool install` prefix) and `/root/go/bin` (the GOPATH `bin` directory, where
`go install` writes) — after every system path.

Both directories are writable by the agent and survive the session: `/root` is
a named volume shared across sessions and workspaces, and the image PATH
reaches the agent unchanged because the entrypoint execs `runuser -u` rather
than `runuser -l`. A binary one session leaves in either directory MUST NOT be
able to shadow a system binary (`git`, `gh`, `aws`, …) on a later run. They MAY
be on the default PATH for convenience, but only after every system path.

Neither directory is distinguishable from the other by anything the image can
check — same volume, same writer, same lifetime — so the usual convention that
a user-local prefix precedes the system paths SHALL NOT be applied to
`/root/.local/bin`. `/usr/local/go/bin` precedes the system paths so the
image's pinned toolchain wins over anything installed into the volume.

#### Scenario: a persisted GOPATH binary cannot shadow a system tool

- **GIVEN** a previous session left an executable named `git` in `/root/go/bin`
in the persistent volume
- **WHEN** a new container starts and `git` is invoked
- **THEN** the system `git` runs, not the one from `/root/go/bin`

#### Scenario: a persisted user-prefix binary cannot shadow a system tool

- **GIVEN** a previous session left an executable named `gh` in
`/root/.local/bin` in the persistent volume
- **WHEN** a new container starts, in any workspace, and `gh` is invoked
- **THEN** the system `gh` runs, not the one from `/root/.local/bin`

#### Scenario: go install output is runnable without an absolute path

- **WHEN** the user runs `go install <module>@<version>` and then invokes the
installed command by name
- **THEN** the command resolves from `/root/go/bin` on PATH

#### Scenario: user-prefix installs are runnable without an absolute path

- **WHEN** the user installs a tool whose entry point lands in
`/root/.local/bin` (for example `uv tool install` or `pip install --user`)
and then invokes it by name
- **THEN** the command resolves from `/root/.local/bin` on PATH, provided no
system binary of that name exists

#### Scenario: the smoke suite enforces the ordering

- **WHEN** `smoke/smoke.sh` runs against a built image
- **THEN** the in-container assertions confirm `/usr/local/go/bin` precedes
`/usr/bin`, that `/usr/bin` precedes both `/root/.local/bin` and
`/root/go/bin`, and that `git` resolves to `/usr/bin/git`
- **AND** they confirm the exported `GOROOT`, `GOPATH`, and `GOBIN` values

## REMOVED Requirements

### Requirement: PATH ordering keeps GOPATH binaries non-shadowing

Superseded by "PATH ordering keeps volume-persisted directories
non-shadowing" above. The guarantee is unchanged and strengthened; the title
and its single `/root/go/bin` scenario described only one of the two
agent-writable, volume-persisted directories now on the default PATH, so a
reader greping the title would conclude the requirement was about `go install`
output alone.
52 changes: 52 additions & 0 deletions openspec/changes/archive/2026-08-29-export-go-env/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
## 1. Go environment

- [x] 1.1 Add an `ENV` instruction setting `GOBIN=/root/go/bin`,
`GOPATH=/root/go`, `GOROOT=/usr/local/go`, placed after the tmux layer and
before the existing runtime `ENV` block.
- [x] 1.2 Spell every path literally. Comment why `${HOME}` is wrong (Docker
does not define `HOME` at build time, so `"${HOME}/go"` bakes in `/go`) and
why `GOBIN` is not written as `${GOPATH}/bin` (Docker resolves the reference
against the value from before the instruction, yielding `/bin`).
- [x] 1.3 Introduce no helper variable to work around the self-reference rule —
nothing that `go env` does not recognise may ship in the session environment.

## 2. PATH

- [x] 2.1 Reorder the runtime `ENV` PATH to
`"/usr/local/go/bin:${PATH}:/root/.local/bin:/root/go/bin"`.
- [x] 2.2 Rewrite the PATH comment: both trailing entries are agent-writable
and volume-persisted, both sit after every system path, and neither may
shadow a system binary. Drop any framing that treats `/root/.local/bin` as
more trusted than `/root/go/bin`.
- [x] 2.3 Keep the `TASK_X_REMOTE_TASKFILES` comment recording why the opt-in
is declined.

## 3. Smoke assertions

- [x] 3.1 Add `path_index()` to `smoke/assert-in-container.sh` — the index of
an entry in the colon-separated PATH, `-1` when absent, matching whole
entries only.
- [x] 3.2 Add `assert_path_before()` reporting through the existing
`pass`/`fail` counters, failing distinctly when either entry is missing.
- [x] 3.3 Add `check_path_order()` asserting `/usr/local/go/bin` before
`/usr/bin`, `/usr/bin` before `/root/.local/bin` and before `/root/go/bin`,
and `command -v git` = `/usr/bin/git`.
- [x] 3.4 Assert `GOROOT`, `GOPATH`, `GOBIN` in the same function with the
existing `assert_eq`.
- [x] 3.5 Call `check_path_order` from the Main dispatch after `check_security`
and renumber the section banners that follow.

## 4. Verification

- [x] 4.1 `bash -n smoke/assert-in-container.sh` passes and the PATH helpers
behave correctly against a synthetic PATH (ordered pair, reversed pair,
missing entry).
- [ ] 4.2 `shellcheck run.sh entrypoint.sh smoke/*.sh` and
`hadolint --config .hadolint.yaml Dockerfile` pass.
- [ ] 4.3 `docker build -t claude-code:local .` succeeds and
`IMAGE=claude-code:local bash smoke/smoke.sh --uid="$(id -u)" --optins=aws,glab,tfe`
passes, including the new `path-order` and `GO*` assertions.
- [ ] 4.4 Shadowing probe: plant an executable `git` in `/root/.local/bin` in a
throwaway root volume, start a new container against it, and confirm
`command -v git` still reports `/usr/bin/git`.
- [x] 4.5 `openspec validate export-go-env --strict` passes.
Loading
Loading