The question that needed a sandbox
We had a question we couldn’t answer by staring at our own docs: is our agent-authoring tooling actually any good? Concretely — can a coding agent that has never seen PromptArena go from “I have an agent idea” to a valid, green, runnable test kit, using only the skill file and briefing we ship?
You can’t mock your way to that answer. To test it honestly, the agent has to really write files, really run packc compile, really run promptarena run --ci, read the actual errors, and iterate — exactly like a developer would on their first afternoon.
Which collapses to one uncomfortable sentence: to test our tooling, we had to let a language model execute arbitrary code on a machine we cared about.
That is the whole problem. Everything below is how we made it safe enough to do on purpose, in CI, on every change.
We built it wrong the first time
The first attempt was a thing called codegen-mcp. It worked, sort of, and then we threw the code away.
The idea underneath it was right — separate the model from the machine — but we over-engineered the transport into a small distributed system: a gRPC coordinator and workers, a hand-rolled task queue, a custom worker registry, and a growing pile of “untestable infrastructure” carve-outs in the test suite. When your carve-outs have carve-outs, the design is telling you something. We kept exactly one sentence of it and rebuilt the rest on MCP plus Docker. No code was ported.
The lesson worth keeping: the split was right, the plumbing was vanity.
The one idea that survived: brain and hands
The agent (the brain) never directly touches a filesystem, runs a process, or makes a network call. All execution happens inside a Docker container (the hands). Brain and hands talk over MCP. This is a trust boundary. Prompt injection or model error can corrupt the sandbox; it cannot corrupt the agent runtime or the host.
That is the entire security model, and it starts from an unsentimental premise: the LLM is the least trustworthy component in the system. It’s the part an attacker can steer with a paragraph of text and the part that hallucinates rm -rf with total confidence. So it gets zero execution privileges. It gets tools instead — passed to it, one MCP round-trip at a time, into a box it can’t climb out of.
The container ships the sandbox MCP server as PID 1, pinned toolchains (Go, Node, Python, ripgrep, linters), a read-write /workspace volume, and everything else read-only. Nothing on the host is reachable. One sandbox per session, ephemeral by default; the container is destroyed and the volume reclaimed when the agent finishes. There’s a warm-volume mode for tight iteration loops, but you opt into it.
The boundary came before the tools
You can read a project’s real priorities in its git log. In this one, before Read, before Write, before a single tool the agent could actually call, the first feature to land — ahead of every one of them — was:
feat(workspace): path containment with symlink and traversal rejection
You build the wall before you build the doors. Only then do the tools show up — and they deliberately mirror the surface a coding agent already knows (Read, Edit, Write, Glob, Grep, Bash, run_tests/run_lint/run_typecheck) so the model’s instincts transfer. But each one carries a safety property the familiar version doesn’t:
Writerequires a priorReadif the file exists, and renames atomically — no half-written files if the model changes its mind mid-call.Editenforces a unique match and rejects an emptyold_stringoutright, because an empty match is how you silently corrupt a file.Globalways returns workspace-relative paths regardless of what the model passed in — containment holds even in the output, so a leaked absolute path can’t teach the model where the walls are.- Every tool’s output is scrubbed for well-known secret shapes before it leaves the container. The sandbox doesn’t just contain what runs; it sanitizes what comes back.
None of these are exotic. They’re the boring, specific decisions that separate “a container” from “a sandbox you’d point a jailbreak at on purpose.”
Two decisions with teeth
Web tools are not in the sandbox — and the honest reason is division of labor, not isolation math. Fetching a page or running a search is the brain’s job: it’s research, part of how the agent decides what to build. The hands only ever touch the workspace — files and processes — and they have no reason to make a web request, so web tools were never theirs to hold. WebFetch and WebSearch connect as a separate vendor MCP (Brave, Exa, Tavily, a generic fetch server) that the agent calls directly, alongside the sandbox. It’s also true that proxying a stateless GET through a container buys no isolation the network layer wasn’t already giving you — but that’s the bonus, not the reason.
Post-edit lint feedback, inline. The Edit tool runs the linter internally and returns the errors in the same round-trip. It was, unglamorously, the single biggest quality win in the whole thing. The agent sees it broke the build the instant it broke it, and fixes it before it moves on — no separate “now verify your work” step that the model forgets to do. A tight feedback loop beats a smart reviewer.
Testing the tooling — with no LLM judge
Now the recursive part, and my favorite decision on the project.
The eval (test-a-codegen-agent) drops a coding agent into the sandbox, hands it nothing but our shipped skill and briefing (promptarena agent-brief /workspace), and tells it to build a test kit for an agent idea it’s never seen. Then it grades the result with five deterministic gates, all of which must pass:
promptarena validate— the kit is schema-valid.packc compile+packc validate— it compiles to a pack.promptarena run --ci— the scenarios actually execute green.unused-files.sh— no orphaned files left lying around.kit-quality.sh— at least one scenario, each with a non-trivial assertion.
They run inside the live sandbox against the real files, and the harness only counts a pass when a __GATE_OK__ sentinel appears in the output. There is no LLM-as-judge anywhere in it.
The call here isn’t that LLM judges are bad — it’s match the grader to the artifact. A structured YAML kit’s quality is mostly a compiler question, not a taste question: validity, compilation, a green run, a non-trivial assertion are all checkable by code that returns the same answer every time. Reaching for a model to grade what a compiler can grade just buys you a flaky eval, a nondeterministic pass rate, and a bill.
For a subtler question — is the agent’s behaviour actually good, is the tone right under pressure, did it handle the awkward edge case gracefully — we’d put a strong model in the judge seat, or a human expert reviewer, without hesitation. You just don’t spend either on something exit 0 already answers. For this eval, exit 0 answers almost all of it.
And because a test harness that always says “pass” is worse than no harness, we test the gates themselves. A mock run — no API key, no live model — fires the gates at a known-good kit (refund-assistant) and a deliberately-broken one (refund-broken). If the broken kit ever passes, the harness is lying to us, and we find out before an agent’s output does.
Stated in one line: an agent, inside a sandbox, building a test that tests agents — graded by code, not by another model.
The scar: Gemini and $ref
The reward for running the real thing instead of a mock is that you collect real scars. Here’s one: Gemini’s function-calling API rejects tool results that contain JSON-Schema $ref/$defs, with a flat 400 INVALID_ARGUMENT. PromptArena’s discovery commands emit exactly those references. Claude doesn’t care; Gemini falls over. The fix is to flatten the refs before they reach Gemini — but you only find that bug by putting a real model, calling real tools, inside a real sandbox, and watching it faceplant on a schema. Which is, again, the entire argument for not mocking it.
The takeaway
The pattern generalizes past our particular tools. Any time you let a model write and run code — codegen, agentic test authoring, an autonomous fix loop — the model is the least trustworthy thing in the room. So: give it hands, not root. Build the boundary before you build the tools. Keep the isolation for the operations that actually need it and stop proxying the ones that don’t. And when it’s time to grade the output, reach for a compiler before you reach for a judge.
We built the elaborate version first and threw it away. The version that shipped is mostly a Docker container, an MCP wire, and a short list of boring, specific refusals. That’s usually what “secure” looks like up close.
Further reading
- CodeGen-Sandbox — the sandbox MCP server, tools, and docs.
- The brain/hands proposal — the trust boundary and tool surface in full.
test-a-codegen-agent— the five gates, the sentinel, and the mock-run self-check.- Model Context Protocol — the wire the brain and hands talk over.