Did the agent actually do it? Tamper-evident verification of AI agent work: definition-of-done as code.
Project description
dunnit
Did the agent actually do it?
AI coding agents claim completion when work isn't done. They delete failing
tests, add @pytest.mark.skip, hardcode expected outputs, stub functions with
NotImplementedError, and then report success. Researchers call it
reward hacking and have catalogued
dozens of hack categories; developers
ranked agents "lying about task completion" among the
top pain points of 2026.
dunnit is the trust layer between "the agent says done" and "it is done."
You declare a machine-checkable definition of done in dod.yaml. dunnit verify
re-runs the proof itself and inspects the git diff for gaming. It never trusts
the transcript — and it won't let the agent rewrite the contract either.
pip install dunnit
dunnit init # write dod.yaml
dunnit verify # ✓ / ✗ evidence + exit code
60-second demo
An agent is asked to fix mul(). Instead it skips the failing test and stubs
the code:
--- a/test_app.py
+++ b/test_app.py
+import pytest
+@pytest.mark.skip
def test_mul():
- assert mul(2, 3) == 6
+ pass
--- a/app.py
+++ b/app.py
def mul(a, b):
- return a * b
+ # TODO fix later
+ raise NotImplementedError
The agent reports: "All tests pass ✅ Task complete." dunnit disagrees:
$ dunnit verify
✓ smoke: `python -c "import app"` exited 0
✗ tamper:added-skips: test_app.py: skip markers added: ['@pytest.mark.skip']
✗ tamper:removed-assertions: net assertions removed from tests (1 removed vs 0 added)
! stubs:todo-left-behind: app.py: # TODO fix later
! stubs:not-implemented: app.py: raise NotImplementedError
verdict: FAIL — it did not do it
$ echo $?
1
What it checks
| Check | Catches | Result |
|---|---|---|
checks commands |
Failing tests/lint/build the agent claimed were green — dunnit executes them itself | FAIL |
tamper:deleted-tests |
Test files deleted from the diff | FAIL |
tamper:added-skips |
pytest.mark.skip, unittest.skip, xfail, it.skip, describe.skip added |
FAIL |
tamper:removed-assertions |
Net loss of assert / expect( lines in test files |
FAIL |
tamper:protected-path |
Agent touched files it must not — including dod.yaml itself |
FAIL |
stubs:* |
TODO/FIXME, raise NotImplementedError, except: pass in changed code |
WARN |
The last row matters: the obvious meta-hack is editing the verifier's config to
weaken the definition of done. dod.yaml is protected by default, so that diff
is itself a FAIL. Protect your CI and tests too:
dod.yaml reference
version: 1
base: origin/main # git ref to diff against; default HEAD (uncommitted work)
checks: # proof commands, each must exit 0
- name: tests
run: pytest -q
timeout: 600 # seconds, optional
- name: lint
run: ruff check .
protected: # touching these fails verification (default: [dod.yaml])
- dod.yaml
- .github/**
- tests/** # optional: freeze tests entirely for agent changes
test_globs: # what counts as a test file (defaults cover py/js)
- tests/**
- "**/test_*.py"
tamper: true # diff-based test-gaming checks
stubs: true # stub/TODO detection in changed code
Use it where agents work
Claude Code — block "done" until it's true
dunnit snippet claude prints this; add it to .claude/settings.json:
{
"hooks": {
"Stop": [
{ "hooks": [ { "type": "command", "command": "dunnit verify >&2 || exit 2" } ] }
]
}
}
Exit code 2 blocks Claude from stopping and feeds the failing evidence back into its context, so it fixes the real problem instead of declaring victory.
Cursor — make the rule explicit
dunnit snippet cursor → save as .cursor/rules/dunnit.mdc:
---
description: Definition-of-done enforcement via dunnit
alwaysApply: true
---
Before you claim any task is complete, run `dunnit verify` and read the verdict.
- FAIL means the task is NOT done. Fix the real problem and re-run.
- Never edit dod.yaml, tests, or CI config to make verification pass.
- Never add skip markers or delete failing tests.
- Include the dunnit verdict output in your final summary.
Belt-and-braces: even if the agent ignores the rule, running dunnit verify
in CI (below) catches the gamed diff before merge.
GitHub Actions — gate the merge
dunnit snippet github:
- name: dunnit verify
run: |
pip install dunnit
git fetch origin ${{ github.base_ref || 'main' }}
dunnit verify --base origin/${{ github.base_ref || 'main' }} --json
pre-commit
dunnit snippet pre-commit prints a local hook that runs on every commit.
Python API
from dunnit import verify
verdict = verify("dod.yaml", base="origin/main")
if not verdict.passed:
for e in verdict.evidence:
print(e.check, e.status.value, e.detail)
Machine-readable verdicts
dunnit verify --json emits structured evidence for orchestrators and bots:
{
"tool": "dunnit",
"verdict": "fail",
"evidence": [
{"check": "tests", "status": "pass", "detail": "`pytest -q` exited 0"},
{"check": "tamper:added-skips", "status": "fail", "detail": "test_app.py: ..."}
]
}
Why not just CI?
CI runs your tests. It does not notice that the agent deleted the failing test, skipped it, or edited the workflow so it never runs. dunnit checks the diff for exactly those moves, protects its own contract, and gives agents a verdict they can consume mid-task — before the PR is ever opened.
Design principles
- Never trust the transcript. Every claim is re-verified by execution.
- The contract is part of the attack surface.
dod.yamlis protected by default. - Zero LLM in the core. Deterministic, fast, free, offline. (An optional LLM judge is on the roadmap as an additional signal, never the only one.)
- Model- and framework-agnostic. Works with Cursor, Claude Code, Codex, Devin, or hand-written diffs — dunnit only looks at the repo.
Roadmap
pytest plugin · coverage non-regression · hardcoded-output detection · optional LLM judge · MCP server (agents self-verify) · signed verdict attestations · JS/Go check packs. Issues and PRs welcome.
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file dunnit-0.2.0.tar.gz.
File metadata
- Download URL: dunnit-0.2.0.tar.gz
- Upload date:
- Size: 13.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6724827bd361e13cc7c425d76cfda7d187961fb63e0afbcea62645499a04bebb
|
|
| MD5 |
6cc61a8861297e854f4377d43ba8dbd0
|
|
| BLAKE2b-256 |
1dd989eb4a4310f724b63d59989177b5fdab64105396f41a7b1efb0b1ee33bfd
|
Provenance
The following attestation bundles were made for dunnit-0.2.0.tar.gz:
Publisher:
publish.yml on palasht75/dunnit
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
dunnit-0.2.0.tar.gz -
Subject digest:
6724827bd361e13cc7c425d76cfda7d187961fb63e0afbcea62645499a04bebb - Sigstore transparency entry: 2266929686
- Sigstore integration time:
-
Permalink:
palasht75/dunnit@1a7aa5e8d09c78ee4fe48ffa8f0d62563a45da37 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/palasht75
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@1a7aa5e8d09c78ee4fe48ffa8f0d62563a45da37 -
Trigger Event:
release
-
Statement type:
File details
Details for the file dunnit-0.2.0-py3-none-any.whl.
File metadata
- Download URL: dunnit-0.2.0-py3-none-any.whl
- Upload date:
- Size: 14.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
29fa727420ee7dd62500875d480e1c88c5439655a0a1ecae606f2d6bc318e4aa
|
|
| MD5 |
d91e7a5be3b7f376923f424e5e836319
|
|
| BLAKE2b-256 |
4ff5206f0217a45e1701a408b9a985cb1a9b6cb265f37c9ea53f6bf861bb1a0e
|
Provenance
The following attestation bundles were made for dunnit-0.2.0-py3-none-any.whl:
Publisher:
publish.yml on palasht75/dunnit
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
dunnit-0.2.0-py3-none-any.whl -
Subject digest:
29fa727420ee7dd62500875d480e1c88c5439655a0a1ecae606f2d6bc318e4aa - Sigstore transparency entry: 2266929937
- Sigstore integration time:
-
Permalink:
palasht75/dunnit@1a7aa5e8d09c78ee4fe48ffa8f0d62563a45da37 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/palasht75
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@1a7aa5e8d09c78ee4fe48ffa8f0d62563a45da37 -
Trigger Event:
release
-
Statement type: