Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

evey-fleet

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.

Quick start

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 :8787

The 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.

Why it is shaped like this

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 verify gate

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.

Credentials

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

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:8889

A 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.

Known limits

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.ManagerworktreeGit 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.

Layout

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.

Verify

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.

What this is not

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.

About

Containment harness for a fleet of coding agents: per-task git worktrees, jailed verify gate, host-side review before anything reaches trunk.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages