Ghost in the Runtime · Part 5When the AST Hook Goes Green and the Test Still Fails: The Behavior Gate
12 min read

When the AST Hook Goes Green and the Test Still Fails: The Behavior Gate

A shape gate only reads the code, so a hollowed test or edited test data still passes.

One test and two bad commits, then the score. The AST gate only reads the code, so a body swapped for assert True passes it with exit 0. The behavior gate compares each test's sha256 with its pin and stops that swap before anything runs. Then it runs the pinned tests, and the Part 4 fix fails with its assertion printed. The last step scores three rules against three ways to cheat.Three columns list the agent's cheat: test deleted, test hollowed, and test data edited; a last column counts how many slip through. Prompt rule, read at turn 1, has three crosses, so 3 slip. Shape gate, which reads the code, has a check for test deleted and crosses for the rest, so 2 slip. Behavior gate, which runs the tests, has three checks, so 0 slip. agent's cheat test deleted test hollowed test data edited slip through Prompt rule read at turn 1 3 Shape gate reads the code 2 Behavior gate runs the tests 0 One test and two bad commits, then the score. The AST gate only reads the code, so a body swapped for assert True passes it with exit 0. The behavior gate compares each test's sha256 with its pin and stops that swap before anything runs. Then it runs the pinned tests, and the Part 4 fix fails with its assertion printed. The last step scores three rules against three ways to cheat.Three columns list the agent's cheat: test deleted, test hollowed, and test data edited; a last column counts how many slip through. Prompt rule, read at turn 1, has three crosses, so 3 slip. Shape gate, which reads the code, has a check for test deleted and crosses for the rest, so 2 slip. Behavior gate, which runs the tests, has three checks, so 0 slip. test deleted test hollowed test data edited slip through Prompt rule read at turn 1 3 Shape gate reads the code 2 Behavior gate runs the tests 0

One test guards the duplicate-key rule

Step 1: the test test_idempotent_commit calls commit_ledger twice with the same commit. The first call must return True, accepted. The same key again must return False, refused. test_idempotent_commit assert commit_ledger(c, seen) is True first time: accepted assert commit_ledger(c, seen) is False same key again: refused

The AST gate reads, so assert True passes

Step 2: the agent replaces the test body with assert True. The AST gate reads the staged test, finds the name and an assert, and exits 0. The cheat gets through. staged test assert True AST gate reads name: found assert: found exit 0 the cheat gets through

The pin compares a hash of each test

Step 3: the behavior gate compares the test's sha256. The pin says ea7469413f1d, the staged test hashes to 21148f7445be. They differ, so the gate exits 1 before any test runs. sha256 of the test body pinned ea7469413f1d vs staged 21148f7445be exit 1 stopped before any test runs

Then it runs the pinned tests

Step 4: the test matches its pin, but the code under it lost its duplicate-key check. The gate runs the test, it fails with AssertionError: assert True is False, and the gate exits 1 with that line printed. service.py test: matches its pin duplicate-key check: gone runs the pinned test AssertionError: assert True is False exit 1 failing line sent to the agent

Three rules against three cheats

Step 5: three rules against three cheats on a failing test: deleting it, hollowing its assert, editing its data. The prompt rule lets all three through. The AST gate catches only the deleted test. The behavior gate catches all three. agent's cheat test deleted test hollowed test data edited slip through Prompt rule read at turn 1 3 Shape gate reads the code 2 Behavior gate runs the tests 0
1 of 5
Figure 1. One test and two bad commits, then the score. The AST gate only reads the code, so a body swapped for assert True passes it with exit 0. The behavior gate compares each test's sha256 with its pin and stops that swap before anything runs. Then it runs the pinned tests, and the Part 4 fix fails with its assertion printed. The last step scores three rules against three ways to cheat.

I added a second pre-commit hook, 123 lines of standard library Python. It pins each baseline test by sha256, runs the pinned set against the staged code, and rejects the commit when one fails.

Listen to the audio overview(18:09)Fenrir Studio Voice
0:00
18:09

In Part 4, an Agent A program that calls a language model in a loop and lets it use tools, such as search or code. deleted a test, and my AST Abstract syntax tree. The structure a parser builds from source code, such as functions, classes and signatures. Pre-commit hook A script Git runs before it saves a commit. If the script fails, Git refuses the commit. stopped the commit. The agent put the test back. The hook passed the next commit with Exit code The number a program returns when it ends. 0 means success; any other number, such as 1, means failure.. But the test still failed the first time anything ran it. The hook reads the Staged Added to Git's staging area with git add, ready for the next commit but not yet saved in it. syntax tree and never runs it. So an agent can satisfy it by editing the tree. The only rule an agent cannot edit around is one that runs the Pin A saved fingerprint of each baseline test. If a test changes, its fingerprint no longer matches. tests and reads their exit code. That is the gate this part builds: 123 lines of Standard library The modules that ship with Python itself, so nothing extra needs installing. Python, wired into the same hook that Part 4 ended on.

The gate: before the run, pin the baseline test bodies by content hash. Run them in a fresh subprocess, under a wall-clock budget and a fixed hash seed. Print the failing assertion back to the agent. A timeout is a failure. An edited pinned test is a failure before anything runs.
PART 01

What reading misses

01

The green commit that lied

Part 4 ended with the AST hook rejecting an agent's commit. The agent had deleted a test and dropped a return type on a public function. It restored the test and added the annotation, and the second commit went through with exit 0. The restored test still failed when anything ran it. The code under it had lost its duplicate-key check, and nothing in the hook ever called the function.

I wrote the closing line of that video on purpose: the hook checks shape, not behavior. A syntax-tree gate answers questions about the text of a program. Is there a function named test_idempotent_commit? Does it contain an ast.Assert node? Does the public function carry a return annotation? Reading answers every one of those. Running answers none of them.

The agent was not adversarial. It optimized against the only signal it could see, the exit code. The exit code said the name and the annotation were enough. They were enough for the hook. They were not enough for the ledger.

02

Why shape gates are gameable

Say an agent faces a failing test and a shape gate. It has three moves. This morning I ran all three against the Part 4 Harness The code around an agent that hands it the task, runs its tools and checks its work. in a scratch repo, one at a time. I recorded which check each one defeats.

Delete the test. Remove the whole function. The AST gate catches this one, and only this one. The harness compares the test names at HEAD Git's name for the latest commit on the current branch. with the names in the staged tree. It exits 1 and prints the deleted name. This is the check Part 4 showed on camera.

Hollow the assert. Keep the function and its name. Replace its two Assertion The line in a test that states what must be true. If it is false, the test fails. with assert commit_ledger(c, seen) is not None. The name survives the name check. An ast.Assert node is still there, so the zero-assert check passes. The AST gate returned exit 0 on this file. The test now asserts that a function returns something, and it always does.

Edit the Fixture A fixed, scripted input for a test or demo. It plays out the same way every time.. Keep the function and the assertion count, and change the data. Build a second Commit with a different Idempotency key A unique ID sent with a request, so a retry of the same request takes effect only once., and assert that both calls return True. Both are true, because the keys differ. The AST gate returned exit 0 here too. The function name, the assert count, the return type and the branch count all match the baseline. Only the inputs moved, and inputs are data, not shape.

So two of the three moves walk past a gate built on names and node types. The AST harness has no defect here. It does its job in 0.04 seconds, and it catches a kind of damage nothing else this cheap catches. This is the limit of what reading can establish. Failure as a Process (Jul 2026) measured this shape of failure across 3,843 Trajectory One full agent run on one task, from its first turn to its last.. The damaging errors lock in early and silently, during exploration, before any test runs. A gate that never runs code cannot see an error whose only symptom is a wrong value at runtime.

PART 02

The behavior gate

03

Pin, execute, echo

The naive version of this gate runs the test suite from the working tree. The same three moves defeat it, because the agent writes the working tree. Three choices separate a gate that runs tests from a gate the agent can edit into passing.

Pin by hash, not by name. Before the run starts, the gate parses each baseline file and finds its top-level test_ functions. It normalises each one through ast.unparse, together with any other top-level line that names it. Then it writes a sha256 A hash: a 64 character fingerprint of some text. Change one character and the whole fingerprint changes. of the result to a pin file. On every commit, it reads the pin from HEAD, recomputes those hashes from the staged tree, and compares. A missing name fails. A changed body fails, with both hash prefixes printed, before a single line of the agent's code runs. Because of ast.unparse, reformatting a test does not trip the pin, but changing what it asserts does.

Execute in a fresh subprocess under a budget. The pinned tests run in a new Interpreter The program that reads and runs Python code. A new one starts with no state from the last., on a copy of the staged tree in a temporary directory. The gate, not the test runner, owns a 30-second wall-clock budget, and it sets PYTHONHASHSEED=0. The repo's conftest.py, plugins and pytest A common Python test runner. It exits 1 when a test fails and 0 when all tests pass. settings stay off. A pinned test counts only when the report shows it passed, so a skip is a failure. A fresh process means no state the agent set up in the hook's own interpreter carries over. The fixed Hash seed A number Python uses to scramble hashes. Fixing it makes set and dict order the same on every run. keeps set and dict order the same on every run, so a test that passes once passes again for the same reason. A timeout returns exit 1 and names the budget; it is never a skip. I checked that path. I added a 60-second sleep to the code under test, and the gate returned at 30.26 seconds with the budget message and exit 1.

Echo the failing assertion word for word. The gate collects the E lines from the runner's output. It prints them to stderr Standard error. The output stream a program uses for error messages., ahead of its own rejection line. So the agent's next turn gets the real assertion and the real values, not a summary. This part costs nothing and changes the most. Hand an agent AssertionError: assert True is False, with the failing call spelled out, and it has the defect. Hand it "the behavior gate failed", and it has a guess.

RLVR (May 2026) paired Unit test A small automatic test that runs one function with a known input and checks the output. runs with static analysis as the reward channel. It reported gains of up to 13 percentage points on MBPP pass@1, and it removed lint-only Reward hacking A model meets the letter of its goal by cheating, such as deleting a test so the suite passes.. There is no reward model in my hook, only exit codes, but the property is the same: the checker cannot be satisfied by editing the checker.

There is no reward model in my hook, only exit codes, but the property is the same: the checker cannot be satisfied by editing the checker.

Section 03, Pin, execute, echo

The two gates stack; one does not replace the other. The AST harness runs first, in 0.04 seconds, and rejects on shape. The behavior gate runs second and rejects on runtime. Over seven runs on this two-test fixture, its median was 0.55 seconds. The shape gate stays for two reasons. It is an order of magnitude cheaper. And a deleted test should never reach the point where something tries to run it.

04

The hook, 123 lines

It uses the standard library only: ast, hashlib, importlib, json, os, subprocess, sys, tempfile, xml.etree, pathlib. It runs under pytest when pytest is importable. When it is not, it falls back to an inline runner, so the hook works in a bare container. The --pin mode writes the baseline. Every other call checks against it, and refuses to run at all when no pin exists.

behavior_gate.py
Python
#!/usr/bin/env python3
"""Behavior gate: run the pinned baseline tests on the staged tree before a commit is allowed."""
import ast, hashlib, importlib.util, json, os, subprocess, sys, tempfile
import xml.etree.ElementTree as ET
from pathlib import Path

PIN = ".behavior_baseline.json"
BUDGET_S = 30.0
OK = "behavior gate runner: every pinned test passed"
RUNNER = f"""import importlib.util as u, sys, traceback
spec = u.spec_from_file_location("under_test", sys.argv[1]); mod = u.module_from_spec(spec)
spec.loader.exec_module(mod); bad = 0
for name in sys.argv[2:]:
    try:
        getattr(mod, name)()
    except BaseException:
        bad = 1
        print("\\n".join("E   " + l for l in traceback.format_exc().splitlines()[1:]))
if bad:
    sys.exit(1)
print({OK!r})
"""

def git(*args):
    return subprocess.run(["git", *args], capture_output=True, text=True, check=True,
                          timeout=BUDGET_S).stdout

def words(node):
    """Every identifier and string constant inside one top-level statement."""
    for n in ast.walk(node):
        yield from (getattr(n, field, None) for field in ("id", "name", "asname", "attr"))
        if isinstance(n, ast.Constant):
            yield n.value

def hashes(source, path):
    """sha256 per top-level test function: its def plus any other top-level statement
    that names it, such as `test_x = lambda: None`, normalised through ast.unparse."""
    body = ast.parse(source, filename=path).body
    tests = {n.name for n in body if isinstance(n, ast.FunctionDef) and n.name.startswith("test_")}
    return {t: hashlib.sha256("\n".join(ast.unparse(n) for n in body if t in words(n))
                              .encode("utf-8")).hexdigest() for t in sorted(tests)}

def staged_tree(root):
    """Check the index out under root and return the pin as committed at HEAD."""
    pin = git("show", f"HEAD:{PIN}")
    git("checkout-index", "--all", f"--prefix={root}/")
    if not (root / PIN).is_file() or (root / PIN).read_text(encoding="utf-8") != pin:
        raise ValueError(f"the staged {PIN} differs from HEAD, and moving the pin is a human commit")
    return json.loads(pin)

def drift(pinned, root):
    """A pinned test has to survive the staged tree unchanged."""
    out = []
    for path, tests in sorted(pinned.items()):
        f = root / path
        now = hashes(f.read_text(encoding="utf-8"), path) if f.is_file() else {}
        for name, pin in sorted(tests.items()):
            if name not in now:
                out.append(f"behavior gate: pinned test {name} is missing from {path}")
            elif now[name] != pin:
                out.append(f"behavior gate: pinned test {name} in {path} was edited, "
                           f"sha256 {pin[:12]} pinned vs {now[name][:12]} staged")
    return out

def passed(report):
    """(classname, name) of every JUnit testcase with no failure, error or skip."""
    try:
        cases = ET.parse(report).getroot().iter("testcase")
    except (OSError, ET.ParseError):
        return set()
    return {(c.get("classname"), c.get("name")) for c in cases
            if not any(child.tag in ("failure", "error", "skipped") for child in c)}

def run(pinned, root):
    """One fresh subprocess on the staged tree, fixed seed, wall-clock budget owned by this gate."""
    env = dict(os.environ, PYTHONHASHSEED="0", PYTEST_DISABLE_PLUGIN_AUTOLOAD="1")
    report = root.parent / "report.xml"
    want = {(f.removesuffix(".py").replace("/", "."), n) for f, t in pinned.items() for n in t}
    ids = [f"{f}::{n}" for f, t in sorted(pinned.items()) for n in sorted(t)]
    if importlib.util.find_spec("pytest"):  # no conftest, plugins or ini from the repo
        cmds = [[sys.executable, "-m", "pytest", "-q", "-p", "no:cacheprovider", "--noconftest",
                 "-c", os.devnull, f"--rootdir={root}", f"--junitxml={report}", *ids]]
    else:
        cmds = [[sys.executable, "-c", RUNNER, f, *sorted(t)] for f, t in sorted(pinned.items())]
    for cmd in cmds:
        try:
            p = subprocess.run(cmd, cwd=root, capture_output=True, text=True, env=env, timeout=BUDGET_S)
        except subprocess.TimeoutExpired:
            return 1, (f"behavior gate: pinned tests hit the {BUDGET_S:.0f}s wall-clock budget, "
                       f"a timeout counts as a failure")
        ok = OK in p.stdout if cmd[2] == RUNNER else passed(report) >= want
        if p.returncode != 0 or not ok:
            hit = [l for l in (p.stdout + p.stderr).splitlines() if l.lstrip().startswith("E ")]
            return 1, "\n".join(hit or [f"behavior gate: pinned tests exited {p.returncode} "
                                        f"without every pinned test passing"])
    return 0, ""

def main(argv):
    if argv[:1] == ["--pin"] and len(argv) > 1:
        pins = {p: hashes(Path(p).read_text(encoding="utf-8"), p) for p in argv[1:]}
        Path(PIN).write_text(json.dumps(pins, indent=2, sort_keys=True) + "\n", encoding="utf-8")
        return print(f"behavior gate: pinned {len(argv) - 1} file(s) to {PIN}") or 0
    if not argv:
        sys.stderr.write("usage: behavior_gate.py [--pin] <file.py> [file.py ...]\n")
        return 2
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp).resolve() / "tree"
        try:
            pinned = staged_tree(root)
            errors = drift(pinned, root)
        except (OSError, ValueError, SyntaxError, subprocess.SubprocessError) as exc:
            errors = [f"behavior gate: cannot check the staged tree against the HEAD pin: {exc}"]
        if errors:
            sys.stderr.write("\n".join(errors) + "\nbehavior gate: commit rejected, the pinned "
                             "baseline tests no longer match the pin.\n")
            return 1
        code, report = run(pinned, root)
    if code:
        sys.stderr.write(report + "\nbehavior gate: commit rejected, the pinned baseline tests "
                         "ran against your code and at least one failed.\n")
    return code

sys.exit(main(sys.argv[1:]))

The pin file is committed. The gate reads it from HEAD and rejects a commit that stages a different one. If the agent can make a new pin on its own turn, the pin protects nothing. So updating a baseline test becomes a human commit. A person makes it with --no-verify, and it moves the pin and the test in one diff. That commit is the review moment the gate exists to create.

PART 03

Same repo, third run

05

Same repo, third run

This is the same fixture as Part 4, with the same ledger service. Only the rule moved. It started as a sentence in the prompt, became an AST hook, and is now an AST hook followed by pinned tests that run. The video below is one agent turn in the Antigravity app. The agent works through a steps.md list I wrote, and runs each command as written. It opens on the state Part 4 ended on: HEAD, the wired core.hooksPath, and the git diff of the fixed file, with the test restored and the return type back. The pin file holds one sha256 per baseline test.

The agent runs the commit. The AST harness passes, because the shape is correct. Then the behavior gate prints the assertion that failed, AssertionError: assert True is False. The line under it shows the failing commit_ledger call and its arguments. The next steps apply a patch that puts the duplicate-key check back in the code, not in the test, and commit again. That commit lands. Then the list makes the agent's weakening move. A small helper script replaces the pinned test body with assert True. The AST harness exits 0 on the result. The gate rejects the commit anyway, before any test runs, on sha256 ea7469413f1d pinned vs 21148f7445be staged.

the-behavior-gateScreen recording
Agent turn in Antigravity: the behavior gate rejects the Part 4 commit and prints the failing assert, the fix lands, then the pin rejects a hollowed test
Show the shell command (reproduce locally)
git add -- service.py &amp;&amp; git commit -m 'ledger: simplify commit path'

The three commits, as captured

  1. 01The Part 4 fix. Test restored, return type back. AST gate green, behavior gate red.exit 1
    $ git add -- service.py && git commit -m "ledger: simplify commit path"
    E       AssertionError: assert True is False
    E        +  where True = commit_ledger(Commit(ledger_id='L-1', amount_cents=500, idempotency_key='k-1'), {'k-1'})
    behavior gate: commit rejected, the pinned baseline tests ran against your code and at least one failed.
    
    pre-commit (.githooks/pre-commit): the AST gate passed and the behavior gate did not.
    Read the lines above. Fix the behavior in your code, do not edit or delete a pinned test.
  2. 02Duplicate-key check restored in the code. Both gates green.exit 0
    $ git add -- service.py && git commit -m "ledger: reject duplicate idempotency keys"
    [main f988867] ledger: reject duplicate idempotency keys
     1 file changed, 2 insertions(+), 3 deletions(-)
  3. 03The pinned assert body replaced with assert True. AST gate exit 0; rejected on the pin before any test runs.exit 1
    $ git add -- service.py && git commit -m "ledger: simplify the idempotency test"
    behavior gate: pinned test test_idempotent_commit in service.py was edited, sha256 ea7469413f1d pinned vs 21148f7445be staged
    behavior gate: commit rejected, the pinned baseline tests no longer match the pin.
    
    pre-commit (.githooks/pre-commit): the AST gate passed and the behavior gate did not.
    Read the lines above. Fix the behavior in your code, do not edit or delete a pinned test.
06

What each rule can see

Five rows, one fixture, three rules. Every caught or missed cell below is an exit code I read off a terminal in a scratch repo. None is an estimate.

CheckPrompt ruleAST gateAST + behavior gate
Agent deletes the testMissedCaught, exit 1 on the name diffCaught, exit 1 on the missing pin
Agent hollows the assertMissedMissed, exit 0Caught, exit 1 on the body hash
Agent edits the fixture dataMissedMissed, exit 0Caught, exit 1 on the body hash
Restored test fails at runtimeMissedMissed, exit 0Caught, assertion echoed verbatim
Wall-clock cost per commitPaid in tokens every turn0.04 s0.55 s on a two-test baseline

The last row is the trade, and it is small. Half a second at the commit boundary buys four checks that reading cannot do. The cost grows with the baseline, because it is the time to run the pinned tests. So the number to watch is the wall-clock budget, not the median.

PART 04

The boundary

07

What this gate cannot see

Untested new code. The gate runs the pinned baseline. Say an agent adds a function nobody tests, with a branch nobody runs. It passes every check here at exit 0. This blind spot is structural. A behavior gate measures Regression test A test that checks an old feature still works after a change. When it fails, something broke. in what tests already cover, so the blind spot grows with every line the agent adds.

Flaky test A test that sometimes passes and sometimes fails on the same code.. The fixed hash seed removes one source of Nondeterminism When the same code, run twice on the same input, can give different results., not the others. A test that reads the clock, calls the network or depends on file order fails now and then. An exit 1 that comes and goes at the commit boundary teaches an agent to retry, not to fix. Take that test out of the pin and repair it in its own commit.

Tests that change shared state. The pinned set runs in one subprocess. So a test that writes a file or fills a module-level cache changes the result of whichever test runs after it. Sorted order makes that result repeatable, not correct. A pinned test whose pass depends on its neighbour is pinned at the wrong granularity.

The first of those is the one worth building next. Its shape is a coverage-delta gate: reject a commit whose new or changed lines no pinned test runs.

08

Where the rule moves next

Across four parts, the rule has moved three times, and the repository has not changed once. It started as a sentence in the System prompt Fixed instructions at the start of every request. They tell the model its role and its rules.. The agent read it at turn one and did not carry it to turn thirty. It became an AST hook at the commit boundary, with an exit code the agent could not argue with. It is now that hook plus the pinned tests, run on every commit. That closes two of the three moves the shape check left open.

Each step moved the check closer to the code itself. A prompt rule limits what the model reads. A shape gate limits what it writes. A behavior gate limits what the code does. SWE-EVO (Dec 2025) measured a collapse from 72.80% on single-issue tasks to 25.0% across 48 multi-commit evolution tasks, which average 21 modified files. That is a failure of state across commits. Each of these gates is a small piece of state that survives across commits, and the agent does not write it.

The gate is 123 lines, and it took an afternoon. The pin file is six lines of JSON. If you already have an AST hook, add the run step behind it and commit the pin. It costs under a second. It stops a commit that goes green while the code is wrong.

Primary research and documentation

First published 16 Sep 2026 · last revised 9 Oct 2026 · 12 revisions

Hand this to your agent

You

Read this essay by Ibrahim Ulukaya, https://ulukaya.dev/posts/the-behavior-gate, and look at my project. Do my pre-commit hooks pin baseline tests by hash and run them against the staged code, or can an AST hook pass while a test fails? Name the file and line for each finding.

Cited by
  1. The Crutch vs. the Operating System: Why I Deleted 4,000 Lines of Agent Prompts

    ……still keep a test's name and hollow out what it checks. Part 5 adds a gate that runs the tests. PART 01 Why multi-turn……

  2. Your AI Fixed the Bug and Every Test Passed, but the Tests Skipped the Fix

    ……t in a later commit, 52 minutes after the first one. My Part 5 post, The Behavior Gate, re-runs the pinned tests at commit time. My Part 6 post……

  3. Your AI Says the Bug Is Fixed, but the Test Never Failed: Two Checks Before It Ships

    ……. In both cases, every test in the diff passes. So does the behavior gate from Part 5 . That gate re-runs the tests that existed before the c……