Companion to The Complete Guide to Agent Harnesses (With Code).
Every demo here runs on stock Python with nothing installed. The model is a scripted stand-in, so the five differences are reproducible rather than sampled.
A coding agent loop is six lines, twenty with error handling. Everything else is the harness: the code that decides what the model is allowed to do, what it can reach, what it remembers, who checks its work, and what it gets to see.
This repo ships five layers. For each one you can run the layer with its guard on, and the same agent with it off. Same agent, same script, same system prompt, both times. The only thing that changes is where the rule lives.
Every demo runs with no API key. The model is replaced by a scripted stand-in, because a difference you can run beats a claim you have to trust.
git clone https://github.com/paolo-perrone/agent-harness
cd agent-harness
python3 failures/01_no_execution_boundary.py # guard off: deletes the client file
python3 layers/01_execution_boundary.py # same agent, nothing deleted| # | Layer | The question it answers | Status |
|---|---|---|---|
| 1 | Execution boundary | What is the agent allowed to do? | deletes the wrong file → denied |
| 2 | Sandboxing | What can it reach? | leaks a key via ../ → unreachable |
| 3 | Memory persistence | What survives a context reset? | forgets a decision → survives |
| 4 | Verification loops | Who checks the work, and can it silently fix things? | reviewer rewrites code → read-only |
| 5 | Context pipelines | What does the model see, and what does that cost? | 9,602 tokens → 2 |
python3 run_all.py # all five layers, guard off then on, side by side
python3 run_all.py --layer 3 # one layer on its own
python3 run_all.py --list # what the five layers are
python3 test_harness.py # asserts every difference aboveNo dependencies and no API key. shared/fake_agent.py replaces the model with a
scripted stand-in that emits a fixed sequence of tool calls, so the only thing
that changes between the two columns is the harness. Lift that file on its own if
you want deterministic harness tests without paying a provider for them; it is
about forty lines and it is the reason this repo's CI can assert behaviour rather
than smoke-test it.
test_harness.py checks both directions: each failure script must still fail, and
each layer script must still prevent it. Remove a guard and the suite goes red.
The scripted stand-in makes the difference reproducible. n8n/ shows
what a real model does with the same setup: failure 1 rebuilt in n8n Agents and
run 60 times on gpt-4.1, with the same instructions, the same three files and a
message that pushes.
The model never asked first. With the rule only in the prompt, it deleted the junk and kept the client file in 10 runs out of 10. Add one stale README to the data that calls the client file an old export, and it deleted the client file 10 times out of 10. A workflow that only deletes files marked junk refused that call every time, and an approval gate on the delete tool stopped it every time.
The folder has the workflow, the three agent configs, the data and the raw run log, so you can run it in your own n8n.
One file, no dependencies. Copy harness.py into your project and wrap the execute your loop already calls. Sync or async — boundary() detects
a coroutine function, because handing an async loop an un-awaited coroutine is a
guard that silently does nothing.
from harness import boundary, allowlist, Store, read_only, dry_run, Distiller
execute = boundary([("delete-needs-approval",
lambda c: c.name == "delete_file"
and not c.args.get("approved_by_human"),
"deletion requires an explicit human approval flag")])(execute)
result = execute(call)
if isinstance(result, Denied): # a type, not a string you match on
log.warning("refused by %s", result.rule)
execute.denials # {"delete-needs-approval": 3}Denied subclasses str, so it drops into the same slot a tool result goes and
existing loops keep working unchanged.
allowlist(["work"]) guards paths. host_allowlist(["api.openai.com"]) guards
the network, comparing the parsed hostname rather than the string, so
api.openai.com.evil.com and evil.com/?x=api.openai.com are both refused.
The demos teach each layer. This is the part you keep.
examples/real_loop.py is all five wrapped around a real provider loop. It runs
with no API key: without one it prints the request it would have sent and uses a
canned tool call, which is the same dry-run-by-default idea Layer 4 teaches.
python3 examples/real_loop.py # canned, runs anywhere
OPENAI_API_KEY=sk-... python3 examples/real_loop.pySix things that catch people, in the order they hit:
- These guards only see calls that go through the
executeyou wrapped. A shell tool, a subprocess, or a second client reaches the same file without ever touching them. That is Layer 2's job, and it is why a check inside your own program is workflow control rather than a security boundary. See SECURITY.md. - Your loop must be able to read a refusal.
boundaryreturns aDeniedin the same slot a tool result goes. If your loop treats any string as success, the agent sails past the guard believing it worked. - A rule that matches too broadly deadlocks. Same call, same deterministic
refusal, forever, at full token price.
boundary(rules, max_repeats=3)detects identical consecutive denials and changes the message so the loop has something new to react to. Tune it; do not remove it. allowlistreturnsNone, not a path. That is the refusal. Handle it before it reachesopen(), or you trade a security bug for aTypeError.dry_runconsumes adry_runkwarg. If your real function already takes a parameter by that name, rename one of them.Store.reset()clears only the conversation. That is the point, and it will surprise you the first time disk state outlives a reset you meant as a wipe.Distillercounts words, not tokens. The ratio is honest, the absolute is not. Swap in your provider's tokenizer before quoting a number.
Move enforcement out of the prompt and into deterministic code.
A rule in a system prompt is a bet that the model remembers it, interprets it the way you meant, and follows it under context pressure. The model wins that bet most of the time, which is the reason you stop checking.
Layer 1 is the generalised form of a real gate. The rule "never apply an edit the human has not approved" lived in a skill file as prose for two weeks and was violated eleven times in a single session. Rewritten as a pre-execution hook that denies the call, it has been wrong twice since, both times because the rule was written too narrowly. Both times there was a line to open and point at.
For each layer, ask: is this enforced by code, or by a sentence in a prompt?
- Every irreversible action passes a check that runs before the tool does
- The agent can only reach paths and services you listed, and the list is code
- A context reset loses the conversation and keeps the committed work
- Something reviews the output that is not the thing that produced it
- The reviewer is read-only, so its critique cannot quietly become a fix
- You know what a turn costs, and what fraction of it is context you chose to send
Any box you cannot tick with a file path is a rule you are hoping for.
If this saved you an afternoon, star it. It is how other people find it before their agent deletes the wrong file.