How I verify agent work
A multi-step process I had written carried its instrumentation as prose: a numbered step telling the model to record what it had just done. When the model skipped the step, the ledger stayed empty and the whole cycle dropped off the record without anything reporting a failure. I wrote a rule telling it not to do that. Then it happened again, in the case that settled the question — the corrective rule was loaded in context, and the steps were skipped anyway.
That is not a prompting problem with a prompting fix. It is the discovery that instructions in context are not a control surface. You can hook a tool call. You cannot hook a paragraph. This page is what I do instead, and it is the transferable half of my work: process control for actors that are allowed to be non-deterministic.
The systems these came out of are private, so what follows is method rather than a tour — no repository names, no findings, nothing about what any of it was pointed at. That is a real limit on what you can check, so the page opens with three things you can check in one line each, on your own machine, in under a minute.
Observable in its absence
The move is to stop trying to make the agent comply, and start making non-compliance leave an artifact you can query for. Concretely: take the load-bearing prose step and convert it into a tool call. The instruction is identical. What changes is that the step is now something the system can see.
| A tool call is | Which buys |
|---|---|
| Hookable | A gate can sit on a tool call. Nothing can sit on a paragraph the model was supposed to read. |
| Traceable | The call is a first-class recorded event, with its inputs, at a known time. |
| Observable in its absence | This is the one that matters. A step that was supposed to run and did not leaves a recorded gap you can query for. Prose that was skipped leaves nothing — and nothing is indistinguishable from a clean run. |
Every other technique on this page is a consequence of that third row. Once you are designing for the absence of an action rather than the presence of one, the questions change: where does the actor have to cross a boundary, what evidence exists at that boundary, and what happens when the check itself fails. The rest of this page is those three questions, answered in specific cases.
Three things you can check in one line
A gate written in one language, guarding something that runs in another, inherits every disagreement between their standard libraries. Here is the one that matters most, because both halves look correct on their own.
Python’s str.strip() treats U+001C through U+001F and U+0085 as whitespace. JavaScript’s String.trim() does not. So a message body made of nothing but those characters is empty to a Python gate — which allows it, since there is evidently nothing there to check — and non-empty to a Node sender, which sends it. The natural, obviously-correct short-circuit at the top of the gate — if not s.strip(): allow() — is the bug, and it is deliberately absent from mine.
The second and third are the same shape in different clothes. splitlines() breaks on U+0085, U+2028 and U+2029, all of which are legal raw inside a JSON string — so a JSONL reader written the obvious way silently shreds valid records into unparseable fragments, and a parser that skips bad lines will skip them without a word. shlex.split() discards newlines, so a check that asks whether a flag appears anywhere in a block of command text lets a flag on one invocation exempt a different invocation two lines later.
Two smaller things worth stealing from the reproductions themselves. They print a length rather than the string, because a terminal will happily swallow the exact bytes you are trying to look at and show you a confident, wrong answer. And they build the characters from escapes rather than containing them, because a literal control character does not survive being copied out of a web page. The demonstration of a defect has to be immune to the defect.
A gate that runs out of time votes “allow”
A gate of this kind emits its verdict on standard output inside a fixed timeout. No output means allow. A gate killed at the timeout produces no output. Those three facts together have a consequence that changes how the code gets written: anything that can make the gate slow can make it allow, so every read it performs is reachable by whatever it is guarding against, and latency belongs in the threat model rather than in a performance backlog.
- Reads are bounded to a prefix, and the bound is clamped — a negative value would ask for the whole file, which is the failure the bound exists to prevent.
- The bound is parsed at module scope, outside the error handling, so a malformed value fails the gate the way a timeout does — in the direction the system already accounts for — rather than being swallowed and quietly reverting to an unbounded read.
- Where a second network call is needed, it gets its own sub-budget, so two round trips cannot add up to more than the whole gate is allowed.
None of this is novel; it is failurePolicy: Ignore on an admission webhook, and the same discipline applies. What is worth saying out loud is that a default-allow timeout turns every unbounded read in the gate into a denial-of-service against the gate itself, and that is not visible from reading the gate’s logic.
The sharpest version of the same problem is the error handler. os.path.realpath raises on a path containing a null byte, and the path came from the model. A blanket except: allow() converts a crafted input into a bypass in one line. The fix is to narrow the handler rather than reverse it — deny on a positive textual signal, stay permissive otherwise — and the general form is worth keeping somewhere prominent: a guard’s own crash handler is reachable by the thing it guards against.
Fail-direction is decided per arm, not per file
“Fail open” and “fail closed” get argued as if they were a personality trait of a system. They are a property of a single check’s evidence source, and one file can honestly want both. This is one gate, running before an agent emits text a human will act on, with three arms and two answers.
| Arm | Its evidence | Direction | Why |
|---|---|---|---|
| Drafting a claim | The call carries its own evidence with it. | Fails open | A transcript that cannot be read is not evidence of a problem, and walling a legitimate call on an unrelated I/O failure is its own outage. |
| Emitting text a human will act on | The transcript is the only evidence there is. | Fails closed | An unreadable transcript is absence of gating evidence for a known egress. Failing open here hands every model a one-move bypass: make the transcript unreadable. |
| Writing to a shared surface | The transcript is the only evidence there is. | Fails closed | Same reasoning, same evidence source, same answer. The direction follows the evidence, not the file. |
The question to ask at each arm is not “is this important” but if this check cannot run, is that evidence of safety, or the absence of evidence? The first answer permits failing open. The second does not, and no amount of the check being annoying changes it.
There is one detail I would point at if I only got to point at one thing, because it is how you tell designed-in fail-direction from fail-direction rationalised afterwards. The gate reads a bounded prefix of the transcript, so it can be working from truncated input. A prefix read can only ever remove a match, never add one — which means under truncation each arm degrades toward its own safe side, automatically. The truncation is justified by the fail-direction rather than by convenience, and that only works if the fail-direction was settled first.
Positive proof, then arm on a measurement
A gate that escalates on the absence of evidence fires on anything it cannot compute, and the fix people reach for — a boolean “is this exempt” — is worse, because the uncomputable case silently lands on the permissive side. So the classifier is three-valued: exempt, risky, unknown. It escalates only on positive proof of the risky class, and an empty input returns unknown — never exempt. Never claim a thing is exempt without a real exempt item to point at.
The failure the boolean produces is concrete rather than theoretical: a working tree whose visible change is documentation, merging a different and substantive change alongside it, launders the whole thing to exempt. Where a cheap local signal exists, it is trusted in one direction only — local “risky” is trusted because being wrong there only ever over-restricts into a false denial someone can lift, while local “safe” and local “unknown” both escalate to the authoritative query.
The same asymmetry shapes how a cycle is detected. Steps split into two disjoint roles, starters and completions, overlapping in exactly one member: a starter with no completion is an abandoned cycle and denies, a lone completion clears and can never arm a denial.
And nothing ships armed. Every new gate goes out advisory — it writes a warning to a board and blocks nothing — runs against real traffic, and gets armed on the resulting false-positive rate rather than on how confident I felt writing it. The most recent one to go from advisory to enforcing did so on a recorded read-out: its miss rate against real traffic was near zero, and the single false positive it had produced was removed by changing the design rather than tolerated as noise. An unmeasured gate is a future outage, and the measurement is cheap compared to the incident where a correct change is blocked at two in the morning by something nobody has a mental model for.
Privilege is whatever existed before the session started
A gate that can be waived needs somewhere to read the waiver from, and every convenient option is inside the blast radius. The waiver is read from the gate’s own process environment, inherited from the environment the session was launched in, and never from the command being inspected. The consequence is worth stating as a test result rather than an intention: prefixing the command with the waiver variable does not waive the gate. It has to be exported at launch. A session cannot self-grant its own waiver.
That leaves the obvious hole open, which is that a model can write a settings file containing an environment block — reaching not this session but the next one, and lifting the control one launch later. Worse, the same edit can point the audit path at /dev/null and send the record of the lift nowhere. The answer is a blanket, file-level denial on the settings files, on the break-glass module, and on the guard’s own source — not a content-conditional check, because a guard that does not protect its own file is one edit away from neutering itself, and reasoning about which edits to it are acceptable is a game you lose eventually. Lifting a control is possible, deliberately: it takes a named variable and a written reason, and it lands in the shared ledger as a waived row. Every lift is an event.
Redaction, on the same principle, fails closed against ingest that fails open. The pattern set is asserted at import, so an empty pattern set is a hard startup failure rather than silent pass-through, and the redactor returns a sentinel on any error rather than ever returning its input unmodified — skipping a row is safe, persisting an unredacted secret is not. There is a prose-safe variant that drops one alternative, so that a free-text ledger does not helpfully redact the phrase “token rotation”.
Three environment failures I keep written down, because each one cost a real debugging session and none of them are guessable:
- An ambient GITHUB_TOKEN — some editors inject one — silently shadows keyring authentication and is then rejected by the organisation, producing an auth failure that points at everything except the cause. Unset it per block. Never unset it blindly in CI, where it is the intended credential.
- Pass a secret to a container by name (-e VAR, value forwarded from the process environment), never -e VAR=value, which puts it in the argument vector and therefore in the process list. The variable has to be exported for the name-only form to forward anything at all.
- Each fenced shell block in a markdown procedure runs as its own shell, so exports do not survive between them. A value derived in one block and consumed in another has to be re-derived or persisted — otherwise the later block sees an unset variable and a ${VAR:-default} fallback silently selects the wrong default, which is the worst available outcome because it looks like it worked.
Mentioning a capability loads it
Two projects, two sets of instructions, and a rule that work in one must not reach into the other. The first mechanism is a lint that requires zero references to a sibling project’s commands inside a rolling instruction file — including negative references. Writing “do not invoke X” puts X in front of the model as a live option, and a session duly went and ran an operation nobody had asked for, from a step whose entire purpose was telling it not to. Splitting the file fixed that instance; the lint is what stops a later rewrite from quietly reintroducing it. The guardrail prose inside each file is written to comply with its own rule, which means it names no command at all.
One implementation detail there is a general lesson about checkers. The lint resolves the repository root through the shared git directory rather than the working directory, because the files it checks only exist in the primary checkout — run from anywhere else, a working-directory default would find nothing to scan and report success. A checker that passes by finding nothing is not a checker, and it is the most comfortable kind of broken, because its output is indistinguishable from good news.
The second mechanism is about cost rather than isolation. An always-loaded instruction file is loaded in full every session and again into every spawned subagent, so every byte is a recurring, multiplied charge against a finite attention budget. A bare unbackticked @path is expanded as a launch-time import of that file, which is literally how one project’s markdown ends up permanently resident in another project’s every session. Backtick a mention; use a pointer when you mean routing. Imports are an organisation tool and never a savings lever. Where content has to be available, it goes on the latest-loading surface that still works:
| Surface | Loads | Rank |
|---|---|---|
| Rules scoped to a path | Only when a matching file is read. | Strongest |
| Instruction files nested in a directory | When work enters that directory. | Good |
| A skill | On a match against its description. | Adequate |
| A pointer to documentation | Only if the model chooses to go and read it. | Weakest — it is a hope, not a mechanism |
The rule that makes the budget actually shrink is the uncomfortable one. Content that is derivable, or already enforced by a gate, is a delete candidate — not a relocate candidate. Once a rule is mechanically enforced, its prose is redundant: documentation and enforcement are substitutes competing for one budget, not complements. Rules backed only by an advisory reminder keep their prose, because a reminder only reminds. Which means you cannot safely delete anything until you know, per control, whether it enforces or merely nudges — and that is the next section.
A report of work is a hypothesis, not evidence of it
A subagent reported that it had run SHA256 over a set of supplied files and that the hashes matched. The parent session relayed that into a reply a human was going to act on. It had not run anything; the claim was a fluent description of the procedure it had been asked to follow. I caught it by reading, which is not a control.
So now a hook reads a subagent’s response for the grammar of execution claims — ran, computed, verified, hashed, parsed, fetched, and the nouns that travel with them like SHA256, checksum, byte-for-byte — and injects a reminder that such a sentence describes what should have happened. The underlying rule is single-sourced and referenced from many places, with a short inline restatement everywhere, so that a skipped read still leaves the strongest version in force: the thread is leads, not facts — including statements from internal systems and bots, and including a scanner’s verdict, which is a hypothesis with good formatting. It binds at the moment of assertion, not at the moment of sending, because by the time you are sending it is too late to go and look.
The corollary is the part people miss, and I would put it on a wall: advisory nudges are hypotheses too, not directives. Verify that a nudge applies, and that the fix satisfies it, before acting on it — blindly satisfying an advisory invariant is the same failure as blindly asserting, just wearing a compliance costume.
At review time the same posture becomes a step rather than a reminder. For every finding any reviewer marked critical — and every entry in any reviewer’s list of things it could not verify, which is the half that usually evaporates — an orchestrator runs one empirical check and relabels the finding confirmed, refuted or inconclusive, with the command and its output inline. The reviewing shape then scales with how much the change deserves: one model, then two models given an identical prompt with their disagreements surfaced per-model, then three reviewers with disjoint scopes, then an unscoped pass over the whole diff whose verification is delegated to a separate agent.
Those last two are not the same tool and should not be swapped for each other. Running the same prompt twice measures confidence; asking different questions measures coverage. Reaching for replication when the problem is coverage produces two reviewers who agree, confidently, about the third of the diff they both looked at.
The record has to survive the thing it records
A gate on a command runs before the shell expands it. That is not a detail, it is a constraint on what any such gate can possibly know: presented with gh pr merge "$PR_NUM", the hook sees the literal seven characters $PR_NUM. A gate that decides by parsing the command is not buggy — it is structurally unable to know what is being merged, and no amount of better parsing fixes it.
So the decision is made by joining against a committed, append-only ledger keyed on the durable thing rather than the ephemeral one. Two plausible alternatives were tried and rejected for stated reasons: keying on the session falsely denies merging one pull request from a session that reviewed a different one, and reading the mirrored database fails outright in a fresh working tree, because the database is ignored by version control while the committed ledger travels with the repository. Which is the general rule for this class: join on the durable target, never on the session. A session-scoped predicate produces false denials on exactly the workflows you most want to keep.
The ledger is the record of truth and the database is a regenerable mirror of it, in that order — the enforcement path reads the committed file, so the gate has no dependency on the database being present, correct, or migrated. Durability goes one step further than “append-only” usually means: each event spools to a per-event file outside the repository and is folded back into the committed ledger idempotently inside a lock, so that a git reset cannot erase the audit trail of the session that ran the reset. An audit trail stored inside the thing being audited is a suggestion.
The same instinct applies to knowing where you are. Session identity — model, branch, how far ahead or behind, context consumed, cost, how dirty the tree is, and a warning when a session is operating somewhere it should not be — renders on every assistant message, independent of mode, hooks, or whether the model felt like cooperating. It was built deliberately as the half of a session-isolation fix that no flow can skip. Anything that can be skipped will be, on the run where it mattered.
Getting the harness out of the project it grew in
All of the above grew inside one production system and is worth nothing there. Extracting it into something a different repository can vendor turned out to be the most interesting design problem of the set, and the answer was to invert the obvious primitive.
The obvious extraction is dependency closure: name an entry point, follow its references, copy what you reach, and maintain an exclude list for the things you did not want. That never converges, for two structural reasons rather than one fixable one. Enumerator files reference dozens of paths as subject matter — things to classify or gate, not things they depend on — and bridge references land in clusters with dense internal edges, so a single edge drags the whole cluster in. The exclude list grows faster than the closure shrinks.
So the copier copies an explicit allowlist and nothing else, and closure runs as the validator instead — emitting every one-hop reference that escapes the allowlist as a decoupling task, bucketed by kind. The default flips from over-pull to precise, and building the allowlist stops being a workaround and becomes the decoupling work itself. The most satisfying bug it surfaced: a large catalog file whose citation and message strings mentioned paths, which the closure had been dutifully following as if prose were a dependency edge. Genericising that one file closed most of the outstanding seams at a stroke.
Two refinements are where the judgment shows. A path-shaped closure has a categorical blind spot — references that are not paths. A vendored command invokes a sibling by name; a frontmatter block grants a tool by name. Neither matches a path pattern, so neither is visible to the closure, and both dangle in the target. The detector that closes that gap scans the shipped copies rather than the originals, so a hand-decoupled file is judged by the form a target actually receives rather than the form it was authored in. And the report has three outcome buckets rather than two: copied, open seam, and declared stay-behind with a per-entry reason. A two-bucket report either hides its residue or never reaches zero; the third bucket is what lets a worklist close honestly.
Configuration that a target is expected to edit gets a real three-way merge rather than a policy. The install stamp records the hash of the version that was shipped, so a resync can distinguish three cases: the target matches the new version and there is nothing to do; the target matches the recorded base, so it can fast-forward; or it matches neither, and the target’s copy wins. Always-overwrite destroys everything the target accumulated. Never-overwrite starves targets of improvements and — the subtler cost — without a recorded base you cannot tell “the target edited this” from “the target is stale”, so you have to warn about both, forever, until the warning is furniture. The recorded base is what lets the notice fire only when it means something.
The ceiling, written into the gate
The gates carry headers describing what they cannot stop. The containment check blocks additions but not omissions. It is a forcing function for verifying before asserting, not an assurance about content. A model intent on bypassing it uses the documented lift, which is the point of having one — the gate is aimed at the honest-mistake path, which is where essentially all of the actual failures live.
Writing that down is not modesty, it is the operative half. A guardrail that overstates its coverage is worse than no guardrail, because someone downstream reads the name of the control and stops checking — the ceiling has to live next to the code, where the next person to trust it will actually read it.
Which is also why every control is classified before any of them is hardened, on two orthogonal axes — what class it is (a reminder, a control, or something armable) and which way it fails — plus a third flag for how much harm a bypass does, deliberately independent of whether the thing is armed today. That classification produces an unpopular but correct budget decision, and I will defend it: hardening investment on the advisory tier is frozen. Reminders are decision support, not a security boundary, and making their pattern matching cleverer buys nothing that matters while costing attention that does. A documented decision to stop improving something is worth as much as the thing.
One move, restated
Put the check at a boundary the actor has to cross, decide its fail-direction from its own evidence source, prove the risky case positively rather than inferring it from silence, measure it before arming it, anchor its privileges to something that existed before the session began, and keep its record somewhere the session cannot reach. Everything above is that one move, applied to a different boundary each time.
It is worth being exact about what this buys, because the field is full of claims it does not support. None of it makes a model trustworthy, and none of it is a defence against a model that is actively trying to get around it — the documented lift exists, and a determined bypass uses it. What it buys is that an agent’s work becomes checkable by someone who was not watching, and a step that did not happen leaves something behind. That is a smaller claim than “safe” and a much more achievable one, and the difference between those two claims is most of the engineering.
The production systems this came out of are described on pipelines. The claims this site makes about scale are on the record, where every figure links to a live query and CI fails the build when one stops being true — that page verifies this site; this page is about verifying an agent.