Files
clawmates/images/agent-toolchain/Dockerfile
T
Omar SobhandClaude Opus 5 ebdba34da6 feat(fleet): B4.6 — a microVM reaches the API through a vsock CONNECT proxy, with an allow-list
The guest still has no network interface, and now that is the design rather than
a gap. Its only route out is an HTTP CONNECT proxy: agent CLI -> 127.0.0.1:3128
in the guest -> vsock 9002 -> a per-VM Unix socket on the host -> TLS to an
allow-listed host.

Why not TAP + iptables, which is what the Firecracker write-ups do — measured,
not argued:
  - `ip tuntap add` is DENIED to the daemon user (needs CAP_NET_ADMIN), so TAP
    would need root to pre-provision devices, the same privilege detour the
    loop-mounted rootfs already forced.
  - tank's FORWARD policy is DROP with Docker and Tailscale chains, so rules
    would have to be inserted at position 1; appended ones die silently.
  - a leaked TAP is a new class of host litter to reap.
CONNECT needs no privilege at all and is better on the merits: the client hands
us the HOSTNAME, so resolution happens host-side and the guest needs no DNS or
resolv.conf; the allow-list is by name, not address; and nothing in the guest can
reach the network except through one function. The guest end parses nothing and
enforces nothing, so a compromised agent cannot argue with the policy.

Rests on one measured fact: `claude` honours HTTPS_PROXY. With the proxy at a
closed port, `claude -p` fails ConnectionRefused instead of answering.

THE RESULT: a real agent turn now completes inside a VM with no network card, on
subscription auth — `claude -p` replies VM-OK. The selftest asks for it whenever
CLAUDE_CODE_OAUTH_TOKEN is present and SKIPS loudly when it is not, since it
spends a little of the plan.

The audit log earns its keep immediately: during that turn the proxy logged
`egress DENIED http-intake.logs.us5.datadoghq.com` — the CLI's telemetry, which
the mission container permits today without anyone deciding to.

Three bugs found by the checks rather than by review:
  - `env_pairs` returned early when a caller sent no env, so the proxy address
    was never added and `curl` in a VM with a working tunnel reported "Could not
    resolve host". Absent env means "the caller sent none", not "this command
    needs no environment".
  - the deny check PASSED for the wrong reason — DNS was failing, so nothing was
    refused by the allow-list at all. It now requires a 403 from the proxy, so it
    cannot go green on a broken tunnel.
  - `host_allowed` accepted `evil.test/api.anthropic.com`, which ends with an
    allowed suffix. Hostnames are now validated against a character class, which
    also refuses IP literals so an address cannot sidestep a list of names.
  - `BufReader::into_inner()` discards buffered bytes: wrapping the stream twice
    would have dropped the start of the TLS handshake and stalled a tunnel that
    looked established. One reader now spans the request, and anything buffered
    past the headers is forwarded as payload.

`iproute2` is in agent-toolchain because it is load-bearing: the guest's `lo`
starts DOWN, and while it is down a listener on loopback BINDS and then refuses
every connection with ENETUNREACH. fcagent finds `ip` by absolute path — as pid 1
its PATH comes from the kernel, and execvp's fallback excludes /usr/sbin, where
Debian puts it.

Egress needs both ends up, so `create` reports `egress` and the guest's `ping`
reports its own half. A VM without it is legal but never silent.

Verified on tank: 16/16 with backend=claude (create 1428 ms), 12/12 on the
default rootfs, no leaked processes, VM dirs or proxy sockets. 457 tests pass,
clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-05 12:44:59 -07:00

127 lines
6.6 KiB
Docker

# The shared mission toolchain: everything a mission phase needs that is NOT
# the agent CLI itself. The per-CLI images (agent-claude / agent-kimi /
# agent-glm, plan A6) are FROM this, so the toolchain facts live in one place.
#
# Why a base image rather than three self-contained Dockerfiles: this layer is
# ~3 GB and ~15 minutes to build. Copying it into each per-CLI image would mean
# three builds, three copies on disk, and — the actual risk — three places for
# the scanner and toolchain versions to drift apart. A mission's `done_when`
# evaluator runs the project's own suite to verify a claim rather than believe
# it; if `cargo` is present in one image and absent in another, the same mission
# passes or fails depending on which backend it landed on, and nothing says why.
#
# The contents are the ones proven in production by
# `deploy/clawmates-runtime/Dockerfile`, minus the zeroclaw daemon. A microVM
# mission runs the direct-session model — `claude -p` exec'd over vsock, see
# `session_executor::run_session` — so there is no daemon to host and no
# gateway port to expose. Layer order is copied from that Dockerfile too:
# cheapest-and-most-stable first, so a version bump low down does not
# invalidate the expensive layers above it.
#
# NOT in `AGENT_IMAGES` in scripts/deploy.sh, deliberately: at this size a
# `docker save | load` to every fleet node on each deploy would dominate the
# deploy, and only nodes that actually run microVMs need it. Build it on the
# node that will run it:
#
# ssh osobh@tank "cd ~/clawmates && \
# docker build -f images/agent-toolchain/Dockerfile -t clawmates/agent-toolchain:dev images/agent-toolchain/"
FROM debian:bookworm-slim
# Node 22 (Kimi Code needs >= 22.19; Claude Code is fine on it) is the runtime
# for every agent CLI, so it belongs to the shared base rather than to any one
# of them.
#
# `iproute2` is load-bearing on the microVM path, not a convenience. The guest
# has no network interface at all; its only route out is the agent's proxy on
# 127.0.0.1, and the guest's `lo` starts DOWN. While it is down a listener on
# loopback *binds successfully* and then refuses every connection with
# ENETUNREACH — so without `ip link set lo up` the VM has no egress and nothing
# says so. Nothing else in the image needs it.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl gnupg git jq less procps iproute2 \
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& npm cache clean --force \
&& rm -rf /var/lib/apt/lists/* /root/.npm \
&& node -v && git --version
# Upstream Gitea SDLC tooling for agents that operate on git.redclaw.dev.
# `tea` covers shell-level ops (clone/push, PR checkout); `gitea-mcp` gives the
# LLM a structured tool surface. Both official upstream binaries.
ARG TEA_VERSION=0.14.2
ARG GITEA_MCP_VERSION=1.3.0
RUN set -eux; \
arch="$(dpkg --print-architecture)"; \
case "$arch" in \
amd64) mcp_asset="Linux_x86_64" ;; \
arm64) mcp_asset="Linux_arm64" ;; \
*) echo "unsupported arch: $arch"; exit 1 ;; \
esac; \
curl -fsSL "https://dl.gitea.com/tea/${TEA_VERSION}/tea-${TEA_VERSION}-linux-${arch}" \
-o /usr/local/bin/tea && chmod +x /usr/local/bin/tea; \
tmp="$(mktemp -d)" && \
curl -fsSL "https://gitea.com/gitea/gitea-mcp/releases/download/v${GITEA_MCP_VERSION}/gitea-mcp_${mcp_asset}.tar.gz" \
-o "$tmp/gitea-mcp.tgz" && \
tar -xzf "$tmp/gitea-mcp.tgz" -C "$tmp" && \
install -m 0755 "$tmp/gitea-mcp" /usr/local/bin/gitea-mcp && \
rm -rf "$tmp"; \
tea --version | head -1
# Security scanners. `security_scan.rs` shells out to all four; when they were
# missing, every scan produced `<tool>:tool_error` rows instead of findings —
# a scan that scanned nothing and reported cleanly.
ARG GITLEAKS_VERSION=8.30.1
ARG TRIVY_VERSION=0.72.0
RUN set -eux; \
arch="$(dpkg --print-architecture)"; \
case "$arch" in \
amd64) gl_arch=x64; tv_arch=64bit ;; \
arm64) gl_arch=arm64; tv_arch=ARM64 ;; \
*) echo "unsupported arch: $arch"; exit 1 ;; \
esac; \
curl -fsSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_${gl_arch}.tar.gz" \
| tar -xz -C /usr/local/bin gitleaks; \
curl -fsSL "https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-${tv_arch}.tar.gz" \
| tar -xz -C /usr/local/bin trivy; \
gitleaks version; trivy --version | head -1
# semgrep in its own venv so its pinned dependency tree cannot collide with
# anything else installed here.
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip python3-venv \
&& python3 -m venv /opt/semgrep \
&& /opt/semgrep/bin/pip install --no-cache-dir semgrep \
&& ln -s /opt/semgrep/bin/semgrep /usr/local/bin/semgrep \
&& rm -rf /var/lib/apt/lists/* \
&& semgrep --version
# Rust last: the largest layer and the one most likely to be bumped, so it sits
# where a rebuild costs the least cache. `templates/teams/rust_sdlc.toml` tells
# the coder to run `cargo test`, and the evaluator runs it again to check.
ENV RUSTUP_HOME=/usr/local/rustup \
CARGO_HOME=/usr/local/cargo \
PATH=/usr/local/cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc libc6-dev pkg-config libssl-dev make cmake build-essential \
&& curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable \
&& cargo install cargo-audit --locked --no-default-features \
&& rm -rf /var/lib/apt/lists/* "$CARGO_HOME/registry" "$CARGO_HOME/git" \
&& chmod -R a+rX "$RUSTUP_HOME" "$CARGO_HOME" \
&& rustc --version && cargo audit --version
# PATH is spelled out in full above rather than as `/usr/local/cargo/bin:$PATH`
# on purpose. `scripts/fc-build-rootfs.sh` reads the image's resolved
# `Config.Env` into the guest's /etc/profile.d, and that is the only way the
# VM learns its PATH — `docker export` carries no image metadata at all. An
# interpolated value resolves at build time, so this is equivalent; being
# explicit means the guest's PATH is readable here rather than inferred.
# Where a mission's checkout is injected, on both the container and the microVM
# path. Created here so the guest agent's first write is not also a mkdir.
RUN mkdir -p /mission/repo
WORKDIR /mission/repo
# Idle keep-alive. On the container path the orchestrator execs work in; on the
# microVM path pid 1 is the guest agent instead and this is never reached.
CMD ["sleep", "infinity"]