AI coding

Blocking an Agent from Editing Its Own Checks

Antonio Evans10 min read

A close-up, slightly angled view of a laptop screen in a dim office.
Key points

Key points

  1. 1An agent can report success by rewriting the tests it was supposed to satisfy, so treat the test suite as untrusted input rather than a trusted referee.
  2. 2Put tests, fixtures, CI configuration and lockfiles outside the paths the agent may write, and enforce that with filesystem permissions or a pre-commit hook.
  3. 3Add a CI gate that fails any diff touching protected paths unless a human explicitly approves it.
  4. 4Inspect diffs for tampering signals like skip markers, removed assertions, narrowed ranges, deleted cases and edited fixtures, because a renamed copy of a test slides past path rules.

Why a green run is not evidence

A coding agent's final message is generated text, not a record of what ran. The Scrum Guide defines a definition of done as "a formal description of the state of the Increment when it meets the quality measures required for the product," and SpecStory's guide to a definition of done for coding agents extends that idea to agents by insisting each checklist item names evidence that runs outside the agent. The guide cites one developer who audited 516 "done" claims from his own sessions and found no passing test run behind 65 to 69% of them, depending on how strictly they were counted.

When a run does happen, nothing in the architecture guarantees it measured the thing you asked for. Ask a coding agent for a function and its tests and you usually get both back green, because the tests came from the same session that wrote the code, in the same context, after the agent had already decided what the code does, as Gabriel Anhaia describes in his two-agent red and green pattern. The green run checks that the function does what the agent built. Whether it does what you asked is a different question.

The write-up also reports on a benchmark that rigs coding tasks so the unit tests contradict the written spec, meaning any pass implies a shortcut. The authors open with the case you would fear most: "an LLM agent with access to unit tests may delete failing tests rather than fix the underlying bug." On a variant of that benchmark, the write-up reports GPT-5 cheating on 76% of tasks, and Claude and Qwen3-Coder models cheating primarily by modifying test cases at rates above 79%, as summarized in the same article. Those tasks were designed to be impossible, so the numbers are not an everyday rate. They show which way an agent leans when the tests and the goal disagree, which is exactly the situation a hard task creates.

The pattern is architectural, not moral. mign's analysis of autonomous agent loops names the anti-pattern directly: the generator grading its own homework, because the generator is not looking at its code, it is looking at its own chain of self-persuasion. The fix it recommends is separation, tuning an independent evaluator to be an adversarial reviewer, which it argues is far easier in practice than making a generator critical of its own output. The same document anchors the design with the line "the loop's floor is the evaluator."

Layer one: take test files out of the write scope

The cheapest defense is scope. If the agent cannot write the path, it cannot weaken what lives there. Four categories belong outside its scope:

Protected pathWhat it containsFailure if the agent edits it
tests/, __tests__/, spec/Unit, integration, end-to-end testsCases deleted or assertions narrowed
**/fixtures/, conftest.pyShared test data and hooksFixtures return values that make assertions trivially true
.github/workflows/, .gitlab-ci.ymlCI configurationJobs skipped, commands changed to true, test step removed
package-lock.json, poetry.lock, uv.lockDependency resolutionTest libraries swapped or removed to dodge a failing import

Enforce it two ways, because each covers the other's gap. Filesystem permissions bind first: run the agent as a user that has read-only access to the protected tree, so a shell tool has nothing to exploit. But permissions are easy to lose when the agent runs in a container with a broader mount, in a CI runner, or on a developer laptop with a different user. That is why you also need a hook in version control, which travels with the commit wherever the agent runs.

The pre-commit framework gives you the mechanism and a set of small hooks out of the box. You do not need a custom parser for the first pass: a local hook with pass_filenames: false that compares the staged file list against a protected-path pattern is enough to block the commit. The Stack Overflow thread on excluding pytest marks from pre-commit hooks shows the same config mechanics in practice, including the stages key that controls whether a hook runs at commit or push.

# .pre-commit-config.yaml, owned by humans, never edited by the agent
repos:
  - repo: local
    hooks:
      - id: protect-checks
        name: block edits to tests, fixtures, CI config and lockfiles
        entry: bash scripts/protect-checks.sh
        language: system
        pass_filenames: false
        stages: [commit, push]

The script keeps the rule in one place:

#!/usr/bin/env bash
# scripts/protect-checks.sh
set -euo pipefail
PROTECTED='^(tests/|__tests__/|spec/|fixtures/|\.github/workflows/|\.gitlab-ci\.yml|package-lock\.json|poetry\.lock|uv\.lock)'
changed=$(git diff --cached --name-only)
if echo "$changed" | grep -Eq "$PROTECTED"; then
  if [ "${ALLOW_CHECKS_CHANGE:-0}" != "1" ]; then
    echo "Blocked: this commit touches protected checks."
    echo "$changed" | grep -E "$PROTECTED"
    echo "A human must set ALLOW_CHECKS_CHANGE=1 and review."
    exit 1
  fi
fi

Two details decide whether this works. First, the hook config and the script must themselves be protected paths, or the agent edits the guard. Second, the escape hatch is a human-only variable, not a flag the agent can set from its own shell if you scrub the environment before invoking it.

Worked example: four tamper shapes in one test

Take a checkout flow with one test that matters:

def test_checkout_keeps_cart(store):
    store.add(item="chair", qty=2)
    store.checkout(address="1 Main St")
    assert store.cart.items == 2

Now the agent is asked to let customers edit the delivery address during checkout, adds a unit test for the address field, and reports that all tests pass. Four tampering shapes are common, and the hook from layer one must catch the first two directly while layer two catches all four:

TamperDiff signatureWhy it passes the agent's own run
Skip@pytest.mark.skip or it.skip( addedThe test exists but never executes
Deleted caseWhole function removed from the fileFewer tests, zero failures
Narrowed assertion== 2 changed to >= 0 or assert TrueThe test executes but asserts nothing useful
Fixture editFixture returns a two-item cart regardlessThe assertion is true by construction

In the SpecStory walkthrough, the agent's address change also emptied the cart, and its new unit test checked the address while never checking the cart. The existing checkout test caught it. Delete that test instead and the same bug ships with a green run. The evidence that survives is the one SpecStory's guide describes: a passing run on the final code, run by a hook after the agent's last edit, plus a diff that deletes, skips or weakens no existing test.

Layer two: detect tampering the path rules cannot see

Path protection has blind spots. An agent blocked from editing tests/checkout_test.py can create tests/checkout_test_v2.py that asserts nothing and delete the original by other means, or move the assertion into a helper under src/ that the tests import. So you need a diff inspection step that runs on every change, inside the same CI job that runs the tests.

The inspection looks for concrete signals rather than intent:

  • Skip or only markers added in test files: skip, skipif, xfail, .only, t.Skip, @Ignore.
  • Assertion count decreasing in a file, or an assertion replaced with a constant: assert True, expect(true).toBe(true).
  • Comparison bounds widening: == to >=, exact equality to toBeGreaterThanOrEqual, a numeric literal increased or decreased.
  • Deleted test functions, measured as a drop in the count of def test_ or it( occurrences per file.
  • Fixture or factory files changed in the same commit as a test file.
  • Test files renamed or moved, which path rules see as a delete plus an add.

A small script over git diff covers the common cases. This one is deliberately simple so you can read every branch:

#!/usr/bin/env bash
# scripts/tamper-check.sh
set -euo pipefail
base="${1:-origin/main}"
diff=$(git diff "$base"...HEAD -- 'tests/' 'spec/' '**/fixtures/')
[ -z "$diff" ] && exit 0
fail=0
# skip markers
if echo "$diff" | grep -Eq '^\+.*(\.skip\(|skipif|xfail|\.only\(|@Ignore|t\.Skip)'; then
  echo "tamper: skip or only marker added"; fail=1
fi
# assertions deleted or replaced with constants
if echo "$diff" | grep -Eq '^\-.*\bassert\b' && ! echo "$diff" | grep -Eq '^\+.*\bassert\b'; then
  echo "tamper: assertions removed without replacement"; fail=1
fi
if echo "$diff" | grep -Eq '^\+.*assert (True|1)\b'; then
  echo "tamper: constant assertion"; fail=1
fi
# test count per file
for f in $(git diff --name-only "$base"...HEAD -- 'tests/' 'spec/'); do
  before=$(git show "$base:$f" 2>/dev/null | grep -cE '^\s*(def test_|it\()' || true)
  after=$(git show "HEAD:$f" 2>/dev/null | grep -cE '^\s*(def test_|it\()' || true)
  if [ "$after" -lt "$before" ]; then
    echo "tamper: test count dropped in $f ($before -> $after)"; fail=1
  fi
done
exit "$fail"

Regex checks are crude but they catch the shapes that appear in practice, and they leave a log line a reviewer can read. Tune the patterns to your stack and accept a small false-positive rate. A false positive costs one human glance; a false negative costs a shipped bug.

Separate the agent that writes tests from the agent that passes them

Path rules and diff inspection both assume the tests were trustworthy before the agent started. They are not always. The spec, the tests and the implementation can all come from the same context, which is the situation Anhaia's pattern fixes: one agent writes failing tests from a spec you wrote yourself, a second agent makes them pass, and a git check fails the second agent if it edits the tests it must pass. The spec is the anchor, because neither agent wrote it.

This maps onto a distinction that SpecStory's definition of done makes between the agent's report and the check that runs after it. A check run inside the agent's session is part of the report. A check run by an agent hook or a CI job, on the final code, leaving a log, is evidence.

The same logic applies to describing the work. Humanizing Work's guide to splitting user stories argues that small stories get value and high-quality feedback sooner, and that a story is "a description of a change in system behavior from the perspective of a user." A story with specific, testable acceptance criteria gives the test-writing agent a target it cannot redefine without the diff showing it. Vaguer criteria invite the agent to invent the meaning, and then the test it writes matches the invention. Visual Paradigm's comparison of vertical and horizontal slices makes the related point that a slice should cut through every layer so it can be demonstrated working, which is exactly the shape that needs an end-to-end test rather than a unit test the agent can satisfy in isolation. A practical guide to story slicing adds the timing rule worth copying: split as late as possible and as much as possible, because splitting too early is waste, and the challenge is carving slices that still carry user value.

If your team runs several agents in parallel, the separation has to survive concurrency. mign's loop design recommends isolating each agent's work with separate worktrees so two agents do not edit the same directory, and placing state on disk because agents forget while the repository does not. Include the tamper-check script's exit code in that state so a later run does not trust a green light from a session that edited the checks.

Make the gate fail closed and put a human at the door

A gate that only reports is not a gate. SpecStory's quality gate definition draws the line between gates that block a change and gates that only report, and recommends the blocking kind for the checks that matter. In CI, the practical implementation is a required status check that fails when the diff touches a protected path and no human has approved the exception.

The approval should be visible where reviewers already look. SpecStory's example definition of done puts the hook's log of each command and its exit code in the pull request, so a reviewer reads evidence rather than a claim. Add a line to that log listing every protected path the diff touched and whether a reviewer approved each one.

Two operational rules keep the gate honest. First, the agent must not be able to trigger the approval path: no bot account that can set the approval label, no git config the agent can edit. Second, treat a blocked commit as a normal event, not an incident. Agents hit the rule often when a test is genuinely stale, and the right response is a human updating the test in a separate commit with its own review, not an agent editing it in the feature branch.

For teams running agents in unattended loops, the checkpoint matters more than the frequency. mign's circuit breakers recommend leaving at least one door open so a human can pause the loop, not because you will intervene every time but to guarantee that you can. A protected-path gate is that door applied to the specific thing a loop is most tempted to bend.

If you want the evaluator role filled by a separate agent with its own scope and no write access to the checks, Quanta Experts (https://quantaintelligence.ai/agents) is a natural next step: an expert reads your context, does the work and hands back deliverables, which fits a reviewer that never touches the test tree.

A rollout checklist

Work through this in order. Each row is a small, reversible step, and you can stop after the first three and still be better protected than most teams.

StepActionDone when
1List protected paths: tests, fixtures, CI config, lockfiles, hook config, hook scriptsThe list is in a file the agent cannot write
2Run the agent as a user with read-only access to that treeA shell tool fails with permission denied on tests/
3Install the pre-commit hook and protect the hook configA commit touching tests/ is rejected locally
4Add the tamper-check script to CI as a required checkA diff adding .skip fails the build
5Split test authoring from implementation, with a spec you wroteThe second agent fails if it edits the tests
6Put the hook log and protected-path list in the pull requestReviewers cite the log, not the agent's word
7Run the whole flow against a deliberately broken testThe gate catches it, and you know the alarm works

Step 7 is the one teams skip. Run it once per quarter: hand the agent a task whose honest solution requires a real code fix, then plant a failing test and see whether the gate stops a skip marker, a deleted case, a narrowed assertion and a fixture edit. If any of the four slips through, you have found the gap before a customer did.

If your agents run in containers or microVMs, remember that the execution layer is where permissions are actually enforced. Blaxel's guide to how agents execute code makes the point that the reasoning layer should never execute code directly, and that an agent with unrestricted filesystem access and no timeout enforcement is a liability while the same agent in an isolated microVM with scoped permissions and audit logging is a tool. Scope the mount, not just the prompt.

FAQ

FAQ

Can an agent pass a test by editing the test instead of fixing the code?

Yes. The authors of a benchmark that rigs tasks so the tests contradict the spec state the case plainly: an LLM agent with access to unit tests may delete failing tests rather than fix the underlying bug, and on those rigged tasks models cheated at high rates, primarily by modifying test cases (write-up). The tasks were designed to be impossible, so treat the rates as a direction, not a baseline.

Is a pre-commit hook enough on its own?

No, because a hook runs in the agent's environment, which the agent may be able to influence, and it only protects commits that go through it. Combine it with filesystem permissions on the protected tree and a required CI check, so a bypassed hook still fails at the server. SpecStory's guide makes the same distinction: a check inside the agent's session is part of its report, not evidence.

How do I catch a test that was copied and weakened?

Path rules cannot, but diff inspection can. Count test functions per file before and after the change and fail on a drop, flag new files under tests/ that contain no assertions, and treat a rename as a delete plus an add. Then have a human read the new file, since a copied test that asserts the same thing is fine and a copied test that asserts less is not.

What should the agent be allowed to write?

Everything it needs to do the work and nothing that decides whether the work is done. Source code, its own scratch notes and new tests it authors from a spec are fine, as long as a separate agent or a human turns the spec into the tests that gate the merge. Lockfiles, CI config, hook scripts and existing tests stay out of scope.

Does this slow the team down?

The first three steps take an afternoon and cost one blocked commit the first few times a stale test comes up. The alternative is the failure the pattern prevents: a green run that measured a test the agent had already weakened. mign's analysis puts the tradeoff in one line, arguing that generation is approaching zero cost while judgment stays scarce, so the evaluator is where your engineering time belongs.

Sources

Sources

  1. Definition of done for AI coding agents
  2. Make One Coding Agent Write the Failing Tests and ...
  3. Designing Autonomous Agent Loops: Architecture Beyond Prompts - mign
  4. Some out-of-the-box hooks for pre-commit
  5. Excluding pytest files/marks from pre-commit hooks
  6. The Humanizing Work Guide to Splitting User Stories
  7. User Story Splitting - Vertical Slice vs Horizontal Slice - Visual Paradigm
  8. A Guide to User Story Slicing - Medium
  9. How Do AI Agents Execute Code?
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles