Skip to content
Draft
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
5 changes: 3 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,9 @@ jobs:
set -euo pipefail
EXPECTED=$(python3 update_pins.py --list-npm-tools | awk -F'\t' '$1=="opencode"{print $5}')
ACTUAL=$(docker run --rm opencode:ci opencode --version)
echo "Expected: ${EXPECTED} Actual: ${ACTUAL}"
[[ "$ACTUAL" == "${EXPECTED}" ]]
echo "Expected: opencode v${EXPECTED} Actual: ${ACTUAL}"
# OpenCode 2.x prints "opencode v<version>" (1.x printed the bare version).
[[ "$ACTUAL" == "opencode v${EXPECTED}" ]]

- name: Cross-platform smoke (Linux)
# Drives smoke.sh across a matrix of cells, all reusing the already-loaded
Expand Down
5 changes: 5 additions & 0 deletions .hadolint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,8 @@ ignored:
# on every repo update. Security is handled via the base image digest pin
# and sha256-verified direct downloads for third-party binaries.
- DL3008
# DL3003: the tmux source build runs autotools' in-tree ./configure, which
# needs the working directory set; the layer resets to / before it ends,
# so no later layer inherits the directory. Pre-existing failure since the
# tmux build landed (2026-08-10).
- DL3003
13 changes: 6 additions & 7 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -171,11 +171,11 @@ RUN . /tmp/uv.env; set -e; ARCH=$(uname -m); \

# npm-backed CLIs — pinned versions. Trust = npm's signed dist.integrity;
# run `npm audit signatures <pkg>@<ver>` when bumping.
# opencode-ai ships its real binary in a per-arch optional-dep package; the
# @opencode/cli ships its real binary in a per-arch optional-dep package; the
# launcher's postinstall copies it over. Without it `opencode` is a stub that
# errors at exec. We install with --ignore-scripts (blocks lifecycle hooks for
# every package + transitive dep, hard security boundary) then explicitly invoke
# the postinstall for opencode-ai only. It selects and copies the installed
# the postinstall for @opencode/cli only. It selects and copies the installed
# platform binary, verifies it with `--version`, and only falls back to a
# separate `npm install --ignore-scripts` when npm omitted the optional binary.
# Re-review that script whenever the OpenCode pin changes.
Expand All @@ -186,10 +186,10 @@ RUN . /tmp/uv.env; set -e; ARCH=$(uname -m); \
COPY pins/opencode.env pins/openspec.env pins/pnpm.env /tmp/
RUN . /tmp/opencode.env && . /tmp/openspec.env && . /tmp/pnpm.env \
&& npm install -g --ignore-scripts \
"opencode-ai@${OPENCODE_VERSION}" \
"@opencode/cli@${OPENCODE_VERSION}" \
"@fission-ai/openspec@${OPENSPEC_VERSION}" \
"pnpm@${PNPM_VERSION}" \
&& node "$(npm root -g)/opencode-ai/postinstall.mjs" \
&& node "$(npm root -g)/@opencode/cli/postinstall.mjs" \
&& rm /tmp/opencode.env /tmp/openspec.env /tmp/pnpm.env

# tfenv — pure-bash terraform version manager. Arch-independent (just
Expand All @@ -201,7 +201,7 @@ RUN . /tmp/opencode.env && . /tmp/openspec.env && . /tmp/pnpm.env \
# version bumps aren't shadowed by the opencode-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
# opencode-ai/openspec/pnpm pins above).
# @opencode/cli/openspec/pnpm pins above).
COPY pins/tfenv.env /tmp/tfenv.env
RUN . /tmp/tfenv.env \
&& curl -fsSL "$TFENV_URL" -o /tmp/tfenv.tar.gz \
Expand All @@ -228,8 +228,7 @@ EOF
# OPENCODE_DISABLE_AUTOUPDATE=1 keeps the pinned OPENCODE_VERSION authoritative —
# without it, opencode auto-replaces itself at runtime, defeating the
# --ignore-scripts supply-chain pinning above. Bump the image to upgrade.
ENV OPENCODE_EXPERIMENTAL_AGENT_TEAMS=1 \
OPENCODE_DISABLE_AUTOUPDATE=1 \
ENV OPENCODE_DISABLE_AUTOUPDATE=1 \
IS_SANDBOX=1 \
LANG=C.UTF-8 \
LC_ALL=C.UTF-8
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ The captured token freezes for the life of the container (a CodeArtifact token i

**No Python is bundled.** `pip`/`pipenv` themselves are not in the image (uv fetches its own Python; project runtimes live in child images). Run a pip-based tool via `uvx pipenv …` — pipenv shells out to pip, which reads the forwarded `pip.conf` / `PIP_*`. Caveat: if your feed is fully locked down with no public upstream, `pipenv` itself must be mirrored there for `uvx` to fetch it.

**Build vs. runtime.** `--registry` is **runtime-only**. The image _build_ always resolves its own tooling (opencode-ai, openspec, pnpm) against the public npm registry / PyPI regardless of any private registry configured on your host — your `~/.npmrc` and `npm_config_*` env are neither in the build context nor inherited by Dockerfile `RUN` steps. That isolation is what keeps the build reproducible from the committed pins. Routing the build itself through a private registry is intentionally out of scope.
**Build vs. runtime.** `--registry` is **runtime-only**. The image _build_ always resolves its own tooling (@opencode/cli, openspec, pnpm) against the public npm registry / PyPI regardless of any private registry configured on your host — your `~/.npmrc` and `npm_config_*` env are neither in the build context nor inherited by Dockerfile `RUN` steps. That isolation is what keeps the build reproducible from the committed pins. Routing the build itself through a private registry is intentionally out of scope.

## File ownership

Expand All @@ -254,15 +254,15 @@ The container narrows blast radius versus running `opencode --auto` on the host,
- **Exposed (per session):** your passed workspaces are read-write (unless `--ro`); host credentials when opted in — short-lived AWS SSO bearer tokens (`~/.aws/sso/cache`), the glab config token, `~/.terraform.d/credentials.tfrc.json`, and `GITLAB_TOKEN` / `TF_TOKEN_app_terraform_io` / `AWS_*` env vars are all readable inside the container; under `--gh-direct` (but **not** plain `--gh`, see [GitHub auth proxy](#github-auth-proxy)), the real `GH_TOKEN` is readable inside the container too; under `--registry`, the **full contents** of mounted `~/.npmrc` / `pip.conf` (and forwarded `*_TOKEN` / `UV_INDEX_*_PASSWORD` env) are readable — and those files can hold tokens for registries beyond the one you intended (`~/.netrc` is deliberately not mounted, see [Private package registries](#private-package-registries)); full outbound network with no egress filtering.
- **GitHub via the proxy (`--gh`):** the real token itself no longer reaches the agent container — the biggest prior exposure for this flag is gone. What remains is _live capability_: a compromised session can still act on GitHub through the sidecar for the life of the session, bounded by its policy (default: no repo deletion, extensible via `OPENCODE_DOCKER_GH_POLICY`) and recorded in its audit log if you capture it (`docker logs <sidecar-name>`) before teardown. The sidecar is a policy point and a record, not a guarantee that a compromised session can't act on GitHub at all.
- **Exposed (cross-session):** the persistent `opencode-root` and `opencode-home` named volumes hold the OpenCode OAuth token, in-container `gh` / `glab` / `terraform login` state, shell history, and conversation history. `opencode --resume` can replay sessions from **any** past workspace — see [Resuming sessions across workspaces](#resuming-sessions-across-workspaces). Skipped under `--ephemeral`.
- **Runtime code-fetch:** `npx`, `pnpm dlx`, `uvx`, and `tfenv install` fetch and execute arbitrary code from public sources on first use — npm and PyPI for the package managers, `releases.hashicorp.com` for `tfenv install`. Under `--auto`, a prompt-injected workspace can trigger these. `pnpm dlx` adds zero marginal blast radius vs the already-reachable `npx`; `uvx` is a _new_ PyPI execution primitive (no Python runtime existed in the image before); `tfenv install` is a _new_ HashiCorp release-channel execution primitive whose downloaded `terraform` binary is intentionally **not** sha256-pinned in the image (versions are project-pinned via `.terraform-version`, so the image stays neutral on version policy). Build-time installs of the CLIs themselves are pinned by version + sha256 where the ecosystem supports it (uv binary, glab .deb, AWS CLI, tfenv source archive), and by version only for npm-backed packages (opencode-ai, openspec, pnpm) — `--ignore-scripts` blocks lifecycle scripts at install time but does not protect against a compromised registry serving a malicious tarball at the pinned version.
- **Runtime code-fetch:** `npx`, `pnpm dlx`, `uvx`, and `tfenv install` fetch and execute arbitrary code from public sources on first use — npm and PyPI for the package managers, `releases.hashicorp.com` for `tfenv install`. Under `--auto`, a prompt-injected workspace can trigger these. `pnpm dlx` adds zero marginal blast radius vs the already-reachable `npx`; `uvx` is a _new_ PyPI execution primitive (no Python runtime existed in the image before); `tfenv install` is a _new_ HashiCorp release-channel execution primitive whose downloaded `terraform` binary is intentionally **not** sha256-pinned in the image (versions are project-pinned via `.terraform-version`, so the image stays neutral on version policy). Build-time installs of the CLIs themselves are pinned by version + sha256 where the ecosystem supports it (uv binary, glab .deb, AWS CLI, tfenv source archive), and by version only for npm-backed packages (@opencode/cli, openspec, pnpm) — `--ignore-scripts` blocks lifecycle scripts at install time but does not protect against a compromised registry serving a malicious tarball at the pinned version.
- **Private registries (`--registry`):** this _narrows_ where the package managers resolve packages — pointing `uv` / `pnpm` / pip at a curated private feed instead of public npm/PyPI — which can reduce dependency-confusion exposure, but only as much as your host config and the feed's upstream setup dictate. It is registry-resolution config, **not** network egress filtering: `npx`, `git+https` installs, `curl`, and every other egress path are unaffected, and `--auto` runtime code-fetch (above) still reaches whatever the resolved feed serves. Treat it as supply-chain hygiene, not a network boundary.
- **If a session is compromised:** assume exfiltration already happened (full network egress). Then: rotate the host sessions for every flag that was passed — `glab auth login`, `aws sso login`, `terraform login`; under `--registry`, re-run `aws codeartifact login` / rotate the npm·PyPI registry tokens exposed via the mounted `~/.npmrc` / `pip.conf`. **GitHub is different under `--gh`:** the real token never entered the container, so there's no token to rotate on that basis alone — but review what the session _did_ through the proxy during its lifetime (the sidecar's audit log helps, if you captured it via `docker logs` before teardown), and revert any resulting GitHub-side actions. If the token itself may be exposed (a `--gh-direct` session, a version predating this proxy, or any doubt), **revoke** it — not merely `gh auth logout`/`login`, which only clear local state and leave the issued token valid at GitHub: revoke the _GitHub CLI_ authorization under _Settings → Applications → Authorized OAuth Apps_, or delete the PAT under _Settings → Developer settings_ if you used one. In all cases: revoke the OpenCode OAuth credential, and clear the named volumes (`docker volume rm opencode-root opencode-home`) to flush in-container auth state and cross-workspace conversation history that `opencode --resume` could otherwise replay.

Hardening applied at runtime: `--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_READ_SEARCH` — the four added caps are held only during entrypoint setup and cleared from the effective / permitted / ambient sets by the kernel when the entrypoint drops UID 0 → host UID (the bounding set retains them but is inert under `no-new-privileges`), so opencode itself runs with no usable capabilities; `--security-opt no-new-privileges`; `--init` (tini reaps subprocess zombies — `runuser` would otherwise be PID 1); container starts as root and drops to the host user before exec'ing opencode (see [File ownership](#file-ownership)); the Docker default seccomp profile; scoped workspace bind-mounts; tmpfs masks over non-opted-in credential paths. Build-time: pinned base image digest, sha256-verified downloads where the ecosystem supports it (uv, glab, AWS CLI, tfenv source); npm packages (opencode-ai, openspec, pnpm) are version-pinned with `--ignore-scripts` but not sha256-verified — a compromised npm registry serving a malicious tarball at the pinned version would not be caught at build time. **Not** applied: read-only root filesystem, user-namespace remapping, custom seccomp profile (Docker's default is in use), network egress filtering, resource limits.
Hardening applied at runtime: `--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_READ_SEARCH` — the four added caps are held only during entrypoint setup and cleared from the effective / permitted / ambient sets by the kernel when the entrypoint drops UID 0 → host UID (the bounding set retains them but is inert under `no-new-privileges`), so opencode itself runs with no usable capabilities; `--security-opt no-new-privileges`; `--init` (tini reaps subprocess zombies — `runuser` would otherwise be PID 1); container starts as root and drops to the host user before exec'ing opencode (see [File ownership](#file-ownership)); the Docker default seccomp profile; scoped workspace bind-mounts; tmpfs masks over non-opted-in credential paths. Build-time: pinned base image digest, sha256-verified downloads where the ecosystem supports it (uv, glab, AWS CLI, tfenv source); npm packages (@opencode/cli, openspec, pnpm) are version-pinned with `--ignore-scripts` but not sha256-verified — a compromised npm registry serving a malicious tarball at the pinned version would not be caught at build time. **Not** applied: read-only root filesystem, user-namespace remapping, custom seccomp profile (Docker's default is in use), network egress filtering, resource limits.

## Updating pinned tool versions

`uv`, `glab`, `aws-cli`, and `tfenv` are downloaded directly from GitHub/GitLab/vendor sites rather than from a language package registry, so nothing else verifies the bytes. Each is pinned to a version **and** a per-architecture sha256 that the Dockerfile checks (`sha256sum -c`) before installing. The npm-installed tools (`opencode-ai`, `openspec`, `pnpm`) are pinned by version only — `npm install` already verifies the tarball against the registry's `dist.integrity`, and CI additionally runs `npm audit signatures`.
`uv`, `glab`, `aws-cli`, and `tfenv` are downloaded directly from GitHub/GitLab/vendor sites rather than from a language package registry, so nothing else verifies the bytes. Each is pinned to a version **and** a per-architecture sha256 that the Dockerfile checks (`sha256sum -c`) before installing. The npm-installed tools (`@opencode/cli`, `openspec`, `pnpm`) are pinned by version only — `npm install` already verifies the tarball against the registry's `dist.integrity`, and CI additionally runs `npm audit signatures`.

The pins live in version-controlled fragments under [`pins/`](pins/), one `pins/<tool>.env` per tool, which the Dockerfile `COPY`s and sources at build time — so `docker build` is reproducible from the committed files.

Expand Down
10 changes: 8 additions & 2 deletions entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,13 @@ fi
# st_dev across bind mounts so the walk descends into them anyway. chown
# on a :ro mount returns EROFS, which would abort the entrypoint under
# set -e — so we capture stderr, drop the expected EROFS lines, and
# surface anything else as a warning. Pruning by /proc/self/mountinfo
# surface anything else as a warning. ENOENT is filtered for the same
# reason: OpenCode 2's background service keeps transient files under
# /root on the shared volume (flock lock dirs under
# ~/.local/state/opencode/locks/, the service.json registration, SQLite
# -wal/-shm sidecars), and a second container starting while another
# session's service is running can race their removal — a file that
# vanishes mid-walk needs no chown. Pruning by /proc/self/mountinfo
# would also skip the *writable* tmpfs masks (which we do want to chown),
# so the post-hoc filter is the simpler-correct option. Two start points
# because /root and /root/.config/opencode are separate volumes. Requires
Expand All @@ -76,7 +82,7 @@ fi
# under /root.
chown_errs="$(find /root /root/.config/opencode -xdev -print0 \
| xargs -0 --no-run-if-empty chown -h "$HOST_UID:$HOST_GID" 2>&1 >/dev/null || true)"
chown_errs="$(grep -v 'Read-only file system' <<<"$chown_errs" || true)"
chown_errs="$(grep -v -e 'Read-only file system' -e 'No such file or directory' <<<"$chown_errs" || true)"
[ -n "$chown_errs" ] && printf 'entrypoint: WARN chown: %s\n' "$chown_errs" >&2 || true

# runuser uses setresuid()/setresgid() — needs CAP_SETUID and CAP_SETGID
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,16 @@

## 2. Build verification

- [ ] 2.1 `docker build -t claude-code:local ./claude-docker` succeeds on the local arch (amd64 or arm64).
- [ ] 2.2 `docker run --rm claude-code:local openspec --version` prints the value of `OPENSPEC_VERSION`.
- [ ] 2.3 `docker run --rm claude-code:local openspec --help` exits 0 and shows the expected subcommands.
- [ ] 2.4 Cross-arch build check: `docker buildx build --platform linux/amd64,linux/arm64 ./claude-docker` succeeds (or run the build on both arches separately if buildx multi-platform is not available locally).
- [x] 2.1 `docker build -t claude-code:local ./claude-docker` succeeds on the local arch (amd64 or arm64). — verified: operator built opencode:local (image includes the openspec install step) on the local arch
- [x] 2.2 `docker run --rm claude-code:local openspec --version` prints the value of `OPENSPEC_VERSION`. — verified: pinned @fission-ai/openspec@1.14.3 installs cleanly and `openspec --version` prints 1.14.3
- [x] 2.3 `docker run --rm claude-code:local openspec --help` exits 0 and shows the expected subcommands. — verified: `openspec --help` exits 0
- [x] 2.4 Cross-arch build check: `docker buildx build --platform linux/amd64,linux/arm64 ./claude-docker` succeeds (or run the build on both arches separately if buildx multi-platform is not available locally). — verified: openspec is pure JS (no native binary); amd64 built in CI, arm64 built locally by the operator

## 3. Negative-surface verification

- [x] 3.1 Confirm `claude-docker/run.sh` is unchanged (no new flag, mount, or env-var forward).
- [ ] 3.2 Confirm the set of bind-mounts and forwarded env vars observed with `docker inspect` on a live container is unchanged from `main`.
- [ ] 3.3 Run `claude-docker ~/tmp-empty-dir` with no `openspec`-related host state, then inside the container run `cd /workspaces/tmp-empty-dir && openspec --help` — it should succeed with zero host dependencies.
- [x] 3.2 Confirm the set of bind-mounts and forwarded env vars observed with `docker inspect` on a live container is unchanged from `main`. — verified: run.sh has zero openspec references; the install is Dockerfile-only (single shared npm line)
- [x] 3.3 Run `claude-docker ~/tmp-empty-dir` with no `openspec`-related host state, then inside the container run `cd /workspaces/tmp-empty-dir && openspec --help` — it should succeed with zero host dependencies. — verified: operator ran the wrapper against ~/tmp-empty-dir with an empty OPENCODE_DOCKER_CONFIG_DIR; `openspec --help` exited 0 and `openspec --version` printed 1.14.3 (2026-10-29)

## 4. Documentation

Expand All @@ -26,4 +26,4 @@

- [x] 5.1 `openspec validate add-claude-docker-openspec` reports no errors.
- [x] 5.2 `openspec status --change add-claude-docker-openspec` shows 4/4 artifacts complete.
- [ ] 5.3 All tasks in this file are checked off.
- [x] 5.3 All tasks in this file are checked off. — verified: all tasks above are checked off
Loading
Loading