Status: partly live. The Mac path (
agent-image, agent-cli) is in use.
The pool’s firewall profile and its egress proxy guest are declared in
infrastructure code, and the Ansible role that converges a pool guest is
written; the first end-to-end canary run has not happened yet. See the
roadmap.Build the sandbox once, with Nix. Declare the pool once, in infrastructure code. Then stop provisioning anything per task.An agent runs in one of two places:
Both run the same agent CLIs under the same
autonomous profile. They differ in
how the sandbox is created and how strong the network boundary is.
The Mac path: one image, one container per run
Thenix-agent-sandbox repo owns this surface:
Image contents:
- The agent CLIs,
git,gh,nix,cacert,jq,ripgrep. - The baked
autonomousprofile configs rendered by nix-ai’s formatter layer — the only place these configs exist. - A non-root
agentuser (uid 1000), no sudo. - An entrypoint that refuses uid 0 or a missing
AGENT_SANDBOX=1, then runs the lifecycle: clone →nix develop -c <agent>→ push branch →gh pr create→ exit.
nix develop — the image stays
generic and the repo stays the source of truth for its own environment.
The Proxmox path: a static pool, pull dispatch
The pool is N identical long-lived LXC guests, declared once in infrastructure code. Growing the pool is one more entry with the next id; the guests are interchangeable, so pool size is a capacity knob and not a design change. Guests do not receive work. They pull it. A poller on each guest claims a job from the shared task queue, and one job runs as one instance of a systemd template unit —agent-task@<job-id>. The unit carries the isolation:
RuntimeMaxSec— a per-job wall clock. A wedged CLI dies with its whole control group instead of holding its queue claim forever.PrivateTmp— the unit gets its own memory-backed/tmp. The job’s checkout lives there and cannot outlive the unit.NoNewPrivileges,ProtectSystem, and a narrow set of writable paths.
ExecStart is the secrets agent in process-supervisor mode, not the
job script. It authenticates, renders the job’s credentials into the child
environment, then execs the runner — so there is no code path where a job runs
with credentials that were not scoped and leased for it. See
Secrets.
A guest is converged by one Ansible role (agent_guest, in the
ansible-proxmox-ai repo) on a stock Debian template. Nothing about the guest is hand-built, and rebuilding one is a
converge, not a restore.
Why a pool and not a guest per run
The earlier design provisioned a fresh sandbox for each task. Two costs made that the wrong shape here: Provisioning is not free, and it is paid on every task. Creating a guest, waiting for it to boot, and converging it costs minutes of wall clock before any work starts — on every job, forever. A static pool pays that cost once. The guest is the slow thing; the job is the fast thing, and only the job should be per-task. Per-run provisioning puts the queue inside the state file. If a task creates and destroys a guest, then infrastructure state churns at task rate, and an ordinary run of the infrastructure plan has to reason about resources that exist only because something is briefly busy. Pull dispatch keeps the two separate: infrastructure state describes the pool, which changes rarely, and the queue describes the work, which changes constantly. The plan stays quiet, and starting a task needs no infrastructure change at all. The isolation lost by not rebuilding the guest is bought back at the job level by the template unit — private tmpfs, a wall clock, and a cgroup kill — which is where the untrusted thing actually lives. The prior generation of this path ran agents in Docker on a shared VM. It still exists and still runs its legacy guests. Retiring it is gated on the LXC pool carrying real load through its canary; two runtimes must never converge the same guest, so the swap happens after the canary, not before it.Run lifecycle
1
Claim
Mac:
agent run <repo> <prompt> wraps container run --rm with a tmpfs
workspace and injected env. Pool: the poller claims a queue job and starts
agent-task@<job-id>.2
Clone
The job clones the target repo into its private tmpfs using a short-lived
scoped token (see GitHub access).
3
Work
nix develop -c <agent> enters the repo’s own devShell and runs the agent
headless with the autonomous profile. Zero prompts by construction.4
Ship
Push a branch, scan for leaked secrets,
gh pr create, exit. Nothing
persists. The PR is the only output, and PR review is the human gate.Operator interface
A Mac run is launched by theagent CLI:
The split is the point: a
--profile run with no --repo can read its secrets
but can never push, and a --repo run’s write reach is exactly one repository —
never an account. The token is minted launcher-side; the container can never
mint a write token itself. A caller-supplied token already in the environment
always wins over the mint.
A pool job names the same two axes in its queue entry instead of on a command
line. The mint path is identical.
Host targets, egress network names, and the apply-tier grant procedure are
operational detail that lives in the internal ops runbooks, not in this public
overview.
Network boundary
The Proxmox firewall filters addresses and ports. It cannot filter hostnames, so it cannot express “this agent may reach the model API and nothing else.” The allowlist therefore lives one layer up, in a forward proxy that terminatesCONNECT and matches on domain.
The pool’s firewall profile is built around that split. A pool guest gets
outbound access to internal services only — DNS, NTP, the secrets API, the log
receivers — plus the proxy. It has no rule permitting 443 to anywhere. Every
outbound web request the agent makes has exactly one path off the guest, and
that path is the proxy, where the domain allowlist is enforced. Per-agent
variations are allowlist groups keyed off the client address, not separate
firewall profiles: at the address-and-port layer they would be identical rule
sets, so duplicating them there would buy nothing and drift.
The proxy guest itself is the one member of the plane holding general outbound
web access, and its inbound reach is scoped to the agent network alone so
nothing else on the estate can borrow it as an open relay.
A representative allowlist:
Rejected alternatives
See also
Overview
Why the boundary inverted and what the profiles are.
Secrets
How credentials get into a job without living on the guest.
LXC vs Docker
The decision tree this runtime slots into.
Roadmap
What is shipped, and what the canary still gates.