Design details¶
Proxy egress policy¶
mitmproxy intercepts only HTTP, HTTPS, and DNS (and applies the allowlist and token redaction). The system denies every other protocol by default. The proxy's iptables FORWARD chain policy is DROP. The system permits the llama-server API explicitly. The egress policy is per-project. To let the agent use another protocol (uninspected, plain NAT), opt it in under egress.allow in this workspace's .pi-container/config.yaml. The system seeds this file with deny-all on first run:
# .pi-container/config.yaml
egress:
allow:
ssh: false # TCP 22 (e.g. git over SSH)
smtp: false # TCP 25, 465, 587
git: false # TCP 9418 (git://)
ntp: false # UDP 123
tcp_ports: [] # arbitrary extra TCP ports, e.g. [2222, 8443]
udp_ports: [] # arbitrary extra UDP ports, e.g. [51820]
run.py reads this file. It passes the corresponding PROXY_ALLOW_* values into
that project's proxy container. The container's entrypoint opens the matching FORWARD rules.
Note: protocols opted in here are forwarded uninspected. mitmproxy and the allowlist do not see them.
Nested containers¶
The agent container is deliberately a dead end. None of the tooling to
start a container is normally present. Any task needing one (run Postgres for a
test, build and smoke-test an image, bring up a dev stack) fails outright.
Setting nested_containers.enabled: true in .pi-container/config.yaml gives the
agent its own rootless podman inside its own container.
flowchart TB
subgraph agent["<b>pi-coding-agent</b> (user pi, rootless)"]
direction TB
pi["pi agent process"]
pod["podman (rootless, uid 1000)<br/>subuid 1:999 + 1001:64535"]
subgraph inner["nested containers"]
direction LR
c1["postgres"]
c2["compose svc"]
end
store["nested image store<br/>volume: pi-nested-<project-hash><br/>driver: overlay"]
end
style agent fill:none,text-align:left
style inner fill:none,text-align:left
eth0["agent eth0<br/>isolated-net (no gateway)"]
proxy["<b>pi-coding-agent-proxy</b><br/>REDIRECT 80/443 → mitmproxy :8080<br/>REDIRECT 53 → DNS :5353<br/>FORWARD policy DROP"]
host["host browser<br/>127.0.0.1:3000"]
pi --> pod
pod --> inner
pod -.-> store
inner -->|userspace NAT<br/>into the agent's netns| eth0
eth0 --> proxy
proxy -->|verified: no bypass| internet["internet"]
host -.->|inbound only<br/>nested_containers.ports| inner
Containers the agent starts are children, not siblings. They live in the agent's
mount and network namespaces. They bind-mount the agent's view of /workspace
directly and inherit its routing. Rootless podman NATs their traffic into the
parent namespace's stack. Nested traffic leaves from the agent's own
interface with the agent's source address. It follows the agent's routes. The
proxy's existing -i eth1 REDIRECT rules match nested traffic with no
proxy-side changes. A nested container on the --internal network cannot
reach a raw IP at all. The kernel's routing layer in
the agent's netns returns Network unreachable.
Traffic in the other direction needs plumbing. A browser on the host opens a UI that a nested
container serves. A nested
container's own -p publishes into the agent's namespace. The agent container
publishes nothing to the host. nested_containers.ports.publish adds that outer
hop as -p flags on the agent container. You declare the ports in config. A container's published ports are fixed at start. This works inbound only. It adds no
egress. The proxy is still the only route out.
The system never mounts the host runtime socket. /var/run/docker.sock is a
full-privilege API to the host runtime. Mounting it would break this security model. Nesting adds no --privileged, no seccomp=unconfined, and no
--userns override. It does relax three things in the agent container: SELinux
type confinement (label=disable), podman's masked or read-only /proc paths
(unmask=ALL), and the capability set (--cap-add SYS_ADMIN, which is
namespaced, so container uid 0 is still an unprivileged host user). The last two
were hard requirements of the inner runtime. Nesting is opt-in and off by default. See
Nested containers for the configuration
surface and the full trade-off. See docs/design/nested-containers.md for the
design and its verification log.
Toolchain builder image¶
The system compiles three of the agent image's components from source in a separate image,
pi-coding-agent-builder:local. It then copies them into the prebuilt image files:
| Component | Otherwise | Built here | Why |
|---|---|---|---|
| Node.js | the node:<ver>-trixie-slim base image |
26.6.0 (official tarball, or NODE_SOURCE=build compiles it) |
The base image tag chose the version, not this repo. And v26.5.1 was a security release that the pinned tag predated. |
CPython + uv + podman-compose |
Debian has no python3 in this image |
3.14.6 (PGO) | The workspace targets 3.14. Debian's would be a second interpreter tree at 3.13. The proxy image takes this tree too (see below) |
podman |
Debian trixie: 5.4.2 (Mar 2025) | 6.0.2 | Build tags, then version (see below) |
netavark, aardvark-dns |
Debian trixie: 1.14.0 | 2.0.0 | podman 6.0.0 "must be used with Netavark and Aardvark v2.0.0" |
The agent image's base is plain debian:trixie-slim because it builds Node here. The
node: images are debian:trixie-slim plus a Node tarball. This image uses the same
Debian with the tarball replaced by a build. Nothing else rides along on that tag.
The build-tag argument is the substantive one. podman's Makefile derives its
BUILDTAGS by probing the build host for installed -dev headers. The feature
set of a packaged binary is an accident of the packager's environment. You set them
explicitly to make it a decision. The decision for a rootless podman running
inside an unprivileged container differs from a distro's:
seccomp— kept and load-bearing. Every nested container would run with unfiltered syscalls without it.containers_image_openpgp— Go-native OpenPGP instead of cgogpgmeplus the separatepodman-sequoialibrary. This drops two runtime dependencies for signature policy this image does not use.exclude_graphdriver_btrfs— no btrfs driver and nolibbtrfs. Nested storage isoverlay, orvfswhen overlay-on-overlay is unavailable.- no
systemd— a journald-capable podman also defaults to journald. There is no journal in the agent container. Without the tag the default isk8s-file. - no
libsubid— subuid and subgid are read from/etc/subuid. The image writes this file itself. This path works for nested user namespaces.
The result links only libsqlite3, libseccomp, libc and libm.
Each component builds in its own stage from a shared deps stage. The shipped
builder image is a FROM scratch that holds nothing but the four staged trees. Stage
independence matters most for Node. NODE_SOURCE=build takes ~65 minutes to compile Node.
Everything else takes a few minutes combined. A linear chain would make a podman bump
cost a full Node recompile.
Node is the one component with two modes. NODE_SOURCE=prebuilt (the default) stages
the official nodejs.org tarball. The node: base image installs this tarball too. This mode
is not a downgrade. It uses the same Node with the version pinned here instead of in a
tag. NODE_SOURCE=build compiles from source. This gives a trixie-native build rather than
the generic-glibc one. Node has no equivalent of podman's build tags. Both modes run the same parity
checks (node, nodejs, npm, npx present, version matches, full ICU, Temporal).
The builder stages each component at its own root: /python/,
/node/, /podman/, /network/. Every one of them already mirrors the layout it
lands in. A consumer copies the trees it wants straight onto /. The agent image
takes all four:
COPY --from=pi-coding-agent-builder:local /python/ /
COPY --from=pi-coding-agent-builder:local /node/ /
COPY --from=pi-coding-agent-builder:local /podman/ /
COPY --from=pi-coding-agent-builder:local /network/ /
Keep separate roots instead of one merged /out. COPY --from=<image> can address
paths but not stages. Whatever this image's final stage lays down is all a consumer can
choose from. Keeping the components apart lets the proxy image take
/python/ alone. It gets the same CPython and the same uv the agent runs. It gets no Node,
podman, or netavark in the TLS-terminating chokepoint. See
Uniform Python across both images.
Two further consequences worth knowing:
- Project-specific image rebuilds never compile anything. Before the split, a cache miss on the Python layer turned a workspace's rebuild into a ~10-minute PGO compile. Now it is a file copy.
- A rebuilt builder invalidates project images, the same way a rebuilt proxy
does.
run.pycompares each project image'spi-container.build.timeagainst both (see_newest_shared_image_time()).
Uniform Python across both images¶
The proxy image used to be python:<ver>-slim-trixie with a uv binary pulled
separately from ghcr.io/astral-sh/uv by digest. That setup used a second interpreter and a
second uv pin next to the agent's. A different file bumped them by hand, and they drifted apart. The proxy could run a CPython patch release, an OpenSSL build, or a uv binary that the
agent had never seen. The one process that terminates TLS on every request the
agent makes needs a single answer to the question "which OpenSSL is that, exactly?"
Both images now take Python from the same build, and both refuse to start a build without it:
- The proxy's base is plain
debian:trixie-slimplus the runtime shared libraries the staged CPython's extension modules link against. A missing one causes a build failure.python -c 'import bz2, ctypes, curses, lzma, readline, sqlite3, ssl, zlib'runs right after theCOPY. This catches the failure early instead of lettingimport sslfail at interception time. UV_PYTHON=/usr/local/bin/python3andUV_PYTHON_DOWNLOADS=nevermakeuv syncbuild the mitmproxy venv on the interpreter that you copied in. Without them a managed CPython download quietly reintroduces the drift.
Both images now carry the same CPython install. Every megabyte of it costs twice. build-python.sh trims the staged tree of what neither image can
reach. It takes it from 474 MB to 175 MB:
| Removed | Size | Why it is dead here |
|---|---|---|
lib/python3.14/test/ |
157 MB | CPython's own regression suite. Only python -m test reaches it |
libpython3.14.a (two copies, not hardlinks) |
134 MB | Needed only to embed CPython in a C program. Building a native wheel links nothing. An extension resolves its Python symbols from the interpreter that loads it |
idlelib, tkinter, turtledemo |
10 MB | tk-dev is not installed. _tkinter was never built. import tkinter already fails |
This is the trim the official python: images make as well, minus their *.pyc
deletion. /usr/local is not writable by the agent's pi user. A removed .pyc
would never be regenerated. Every import would re-parse source for the life of the
image.
What it gives up is embedding CPython in a C/C++ program. python-config --ldflags
--embed still advertises -lpython3.14 (sysconfig reports what the build configured,
not what is on disk). An embed link fails at cannot find -lpython3.14. Plain
--ldflags (the extension-module case) does not name it and is unaffected.
There is no shared libpython here either, and never was. ./configure runs
without --enable-shared. Py_ENABLE_SHARED is 0. The build links
the interpreter core into the python3.14 binary. Tools that require one could not work in this image before the trim and
cannot now. These tools include PyInstaller, Nuitka, and dlopen-based embedding. Restoring the static archive would not change that. Making them work needs an
--enable-shared rebuild. That costs every workload a slightly slower interpreter to
serve a case that neither shipped image has.
The claim that matters is proven at build time, not asserted. The claim is that dropping libpython*.a does not break pip install or
uv sync building a wheel from source in a workspace. The build compiles a small C extension against the staged headers. It imports it with the staged interpreter. It creates a python -m venv to confirm
ensurepip and the stdlib came through. A failure there fails the build.
Net effect on the images: proxy 417 MB → 509 MB, agent 1.6 GB → 1.3 GB, builder 790 MB → 486 MB. The proxy gained a full interpreter and lost the slim base.
Build order is therefore a dependency, not a preference: builder → proxy → agent.
build.py builds them in that order. The proxy COPYs from the builder and the
agent COPYs from both.
Sources are pinned by content, not by tag. The build checks each git tag against an
expected commit (tags are mutable; the peeled commit sha is the real pin). The build checks every
tarball (CPython, Node, Go, Rust, the Rust vendor trees) against a SHA-256.
build.sh builds this image. Nothing else does.
Every pin lives in pi-coding-agent-builder/Containerfile, as an ARG on the stage
that consumes it (versions, git commits, tarball SHA-256s, and the pip hash lists alike).
The build scripts read them from the environment (podman exports build args to RUN). No script defaults any of them. require_env in common.sh fails the build and names
the missing ARG. The build does not fall back to a stale value baked into a script. So
you bump a component with one edit. git log -p pi-coding-agent-builder/Containerfile
is the toolchain's version history. Any pin can be overridden for a one-off build:
podman build --build-arg PODMAN_VERSION=v6.1.0 --build-arg PODMAN_COMMIT=<sha> \
-f pi-coding-agent-builder/Containerfile .
The Rust pin is the one exception to per-stage declaration. It is declared before the
first FROM because two stages need it (netavark and aardvark-dns are Rust, and Node 26
implements Temporal in Rust). Those stages re-declare ARG RUST_VERSION with no
default. This is how a pre-FROM ARG is inherited. It gives one pin rather than two that can
drift apart.