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]>
127 lines
6.6 KiB
Docker
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"]
|