A harness for running a fleet of coding agents against a real repository without letting any of them touch it directly.
One orchestrator queues tasks. Each task goes to an agent running inside a container, in its own git worktree, and nothing reaches your trunk without passing a review stage the agent cannot influence.
The automated build gate is Go-only. For a target without a go.mod, or with
verify_gate = false, it is skipped entirely and the review stage is the only thing
between the agent and your trunk. The PR bundle says so explicitly in that case, and states
whether the gate ran, passed, or failed — those are three distinct outcomes, not two.
This is a clean public build of a harness that runs daily against private repositories. It ships with a stub adapter, so the whole pipeline runs end to end with no vendor account and no API key.
Read Known limits before you rely on any of this. That section is not boilerplate: every entry in it is something an audit caught this README claiming and the code not doing.
go build -o fleet ./cmd/fleet
go build -o egress-proxy ./cmd/egress-proxy # only needed for the real adapter
podman build -f pod/Containerfile -t fleet-box .
mkdir -p .fleet
cp config.example.toml .fleet/config.toml # note: .fleet/, not the repo root
# The harness folds finished tasks into a trunk branch that must already exist, and it
# never creates it for you. config.example.toml calls it "integration"; set trunk_branch
# to whatever you use, or create it:
git switch -c integration
# dispatch queues AND runs. `fleet add` queues without running, so do not use both for
# the same task or you get two.
./fleet dispatch "make the parser handle empty input"
./fleet status
./fleet dashboard :8787The default adapter is stub, which exercises the full queue, sandbox, gate, review and
merge path against a scripted agent. Start there and confirm the pipeline behaves before
pointing it at anything that costs money.
The design assumption is not that agents are malicious. It is that a capable agent will eventually generate something destructive by accident, and the harness should be what makes that survivable.
So the boundaries are enforced outside the agent:
| Boundary | Enforced by | What it actually stops |
|---|---|---|
| Filesystem | per-task git worktree clone, mounted writable |
an agent's edits reaching your working tree or trunk directly |
| Container | --read-only rootfs, --cap-drop ALL, no-new-privileges, unprivileged uid, --pids-limit, --cpus, --memory |
a fork bomb or runaway process taking the host down, and writes anywhere outside the mounts listed below |
| Wall clock | timeout then podman rm -f against the container's own name |
a hung agent holding a slot forever |
| Verify gate | runs inside the jail with --network none |
agent-authored code executing on your host when the gate compiles and runs it |
| Merge | host-side review, post-rebase build gate, and UntrackDebris |
unreviewed work, build debris, or your task specs reaching trunk |
What actually confines the agent is the mount and the merge path, not a verb whitelist: the only writable project path is its own worktree, and every commit and merge happens host-side after the agent's container has exited. It has no push path because it never runs the merge.
The gate runs go build, go vet and go test over code an agent just wrote. go test
compiles and executes that code. Running it on the host would hand an agent arbitrary
host execution and make every other boundary decorative, so by default it runs in the same
jail the agent ran in, with no network at all.
That needs a Go toolchain in the image whose version is at least your target module's go
directive, because the jail runs GOTOOLCHAIN=local and cannot download one:
podman build -f pod/Containerfile -t fleet-box \
--build-arg GO_IMAGE=docker.io/library/golang:1.26-alpine .Your host module cache is mounted read-only into the gate so an offline jail can still
resolve imports. gate_jail = false moves the gate back to the host; do not set it without
reading the first entry under Known limits.
There is no credential path in this repository and no default to fall back to. The real
adapter mounts the jail's HOME from FLEET_CREDS_HOME:
export FLEET_CREDS_HOME="$(your-secret-store materialise agent-cli-home)"Unset, it returns ErrNoCredsHome and refuses to run. A harness that ships a working
default credential path teaches you to leave one lying around.
The agent CLI binary is not vendored. Supply your own with
--build-arg AGENT_CLI=./path/to/cli.
egress-proxy is a separate process with its own allowlist, configured by the
EGRESS_ALLOW environment variable:
EGRESS_ALLOW=".vendor.example,api.vendor.example" ./egress-proxy # binds 127.0.0.1:8889A leading dot matches subdomains only, not the apex; list the apex separately if you need
it. Run one instance per role rather than widening a single allowlist. There is deliberately no
egress_allow key in the harness config: the harness does not launch the proxy, and a
config key nothing reads is worse than no key, because it looks like a control.
The agent container is not network-isolated, and it can reach your host's loopback. It
runs a routed slirp4netns stack with allow_host_loopback=true (podman's default is
false; the harness opts in, so the jail can reach the proxy). HTTPS_PROXY/HTTP_PROXY
point at the allowlisting proxy, which constrains a cooperating client only: an agent that
ignores those variables reaches anything slirp4netns routes, including services you
bound to 127.0.0.1, via the gateway address. That covers this harness's own dashboard and
the local summariser, and on a real machine whatever else you run on loopback. Treat
loopback-only as "not on the LAN", not as "out of the agent's reach". The proxy is an allowlist, not a jail. Making it the only route is
open work. The gate container, which is where agent code actually executes, does run
--network none.
go mod tidy runs on the host. It needs network and a writable module cache, which the
jail deliberately lacks. Unlike build/vet/test it resolves modules without compiling or
executing any of the agent's code. It does, however, resolve module paths an agent wrote
into go.mod, and what it downloads lands in the host module cache the gate then mounts.
Checksum-database verification is left ON for that reason; if you need private modules, set
GOPRIVATE/GONOSUMDB in your own environment rather than disabling verification wholesale.
fleet coverage runs on the host, deliberately. It measures your own repository when
you ask it to, which is the same act as typing go test yourself. The path that measures an
agent's fresh, unreviewed worktree is fleet campaign, and that one goes through the jail.
An earlier version of this README claimed nothing but go mod tidy ran on the host while
the campaign path executed agent test code on it, so the distinction is stated explicitly
rather than implied.
An agent can edit anything inside its own worktree, including tests. The filesystem boundary stops it escaping the worktree; it does not stop it touching a file its task never mentioned. Review is what catches that.
Whitelisted directories are host directories. fleet allow <dir> is read-only and
--rw is an explicit opt-in, but an --rw entry is genuinely writable by every agent.
.fleet/allowed_paths is plain text you can hand-edit; the parser fails closed, so only an
explicit rw grants write and any other mode token is treated as read-only.
The review stage is another model. It catches a great deal and it is not a proof. The build gate is deterministic; the review is not.
The harness runs git on the host, inside the agent's worktree. git executes programs
named by config: core.hooksPath, core.fsmonitor, filter.<name>.clean, gpg.program
and more. A linked worktree's .git is a writable pointer file in the agent's mount, so
redirecting it at an agent-authored repository would hand over that config.
Two layers address this. Every host-side git that touches a worktree goes through
sandbox.Manager — worktreeGit inside that package, WorktreeOut from outside it — which
restores the pointer first, so config comes from <repo>/.git/worktrees/<name>, which is not
mounted into the jail. internal/gitsafe additionally pins the execution-related keys and ignores system and
global config. The pin list is explicitly NOT complete — -c cannot wildcard a config
subsection, so an attacker-named filter.<x>.clean cannot be pinned by name — which is why
the pointer restore is the primary control and not the fallback.
If you add a host-side git call that touches a worktree, route it through
sandbox.Manager.WorktreeOut (or worktreeGit within the sandbox package) — NOT gitsafe
directly. gitsafe alone hardens config keys but still follows the agent's pointer, which is
how the PR bundle was once built from a repository the agent chose. A raw
exec.Command("git", ...) there is a host code-execution primitive.
rm -rf inside the jail can still destroy host data. --read-only covers the image
rootfs, not bind mounts. Everything mounted rw — the task worktree, the credentials
directory, any --rw allow entry, the scratchpad — is a real host path and the agent runs
as a uid that can write it. What the container stops is damage outside those mounts.
The worktree is not the only writable mount. The agent container also gets the
credentials directory (FLEET_CREDS_HOME) mounted read-write at the agent's HOME, shared
across every concurrent agent, because the CLI writes its own session state there. An agent
can therefore read and modify that directory. Point FLEET_CREDS_HOME at a directory that
holds nothing but that CLI's credentials.
scripts/fleet-agent is not on the live path. It defines a three-verb agent contract
(claim, note, submit) and is invoked only by the Podman adapter, which nothing
constructs. On the real adapter the agent gets a shell in the jail, and the instruction to
stay within those verbs is prompt text, not enforcement. The containment that does hold is
the worktree mount and host-side merge described above. The MAX_FILES / MAX_DIFF_LINES
caps live inside that script, so they are not enforced either.
Pause is enforced. Pause blocks every entry point that starts agent work
(Dispatch, DispatchSync, Dispatch16, Dispatch16Async, RunSync, Rerun,
Campaign, CampaignAsync), returning ErrPaused. Queueing with add still works, since
queuing starts nothing. This is listed because it previously did not: the marker file was
written and never read, while the MCP tool reported that dispatch was stopped.
Several config keys are inert. grok_bin, exec_flag, prompt_mode, max_writers,
writer_agents, max_diff_lines and max_files are not read by any production path.
They are documented here rather than deleted because the fix is to wire them, but today
changing them does nothing.
No remote PR is ever opened. fleet pr assembles a local markdown bundle. Nothing in
this repository calls the GitHub API; pushing is left to the operator deliberately.
RestoreForgedSums is not wired in. It exists to repair agent-fabricated go.sum
checksums and currently has no production caller, so per this repo's own rule in AGENTS.md
it is not a control you have. It is retained because the fix is to call it, not delete it.
cmd/
fleet/ CLI: add, dispatch, status, get, feedback, merge, allow, campaign
fleet-dashboard/ read-only web board; never mutates state
egress-proxy/ CONNECT proxy with a host allowlist
grokbuild-mcp/ MCP surface for driving the fleet from an agent session
claudecode-mcp/ MCP surface for the review side
internal/
ledger/ file-based task state; the source of truth
sandbox/ worktree clones, debris stripping, trunk folding
adapter/ stub and real agent-CLI adapters
whitelist/ host dirs an agent may consult
review/ review pipeline, verify gate, gate jail
coverage/ coverage measurement for campaign gates
dashboard/ read model for the board
pod/
Containerfile the jail image, Go toolchain included
fleet-box-run.sh standalone hardened podman wrapper
State lives in .fleet/ as plain files. There is no database; you can read the whole state
of a run with cat.
go build ./... && go vet ./... && go test ./... && go test -race ./...Tests are the specification. If you change a boundary, change the test that proves it
first. TestMergeStripsHarnessScratchFromTrunk and TestGrokRefusesWithoutCredsHome both
exist because the behaviour they assert was once claimed in prose and absent from the code.
It is not a general agent framework and it does not try to make agents smarter. It assumes they are capable and occasionally wrong, and spends its complexity on containment and review.
MIT. Issues and forks welcome.