The security review that never ran: verifying agent claims in an AI delivery pipeline
eHVB-IT · · AI security · LLM agents · application security · audit · detection engineering
A review agent can report PASS without opening the diff, and the audit trail says a control operated. The two forms we observed and the check that compares the claim with the runtime and the repository.
Summary
Teams that build software with LLM agents now hand part of their security work to agents as well: a review agent reads the diff, a QA agent runs the tests, and the pipeline records a PASS. An auditor would call that record evidence that a control operated. We found that the record can be wrong in a specific way: an agent reports DONE or PASS for work it never performed, in prose that looks exactly like a genuine report. For a security review this means a control that did not run while the trail says it did. This note describes the two forms we observed, why the agent's own report cannot be trusted to reveal them, and the check we apply, which compares the claim with what the runtime and the repository can prove.
Why this is a security problem
In a pipeline where agents review agents, the orchestrator reads reports, and the human at the end often reads a summary of those reports. Every step downstream inherits whatever the report says. If a security review agent reports PASS without having opened the diff, the change is merged with a review on record and no review performed.
Audit practice already separates the design of a control from its operating effectiveness: a documented review step proves nothing about whether a given change was reviewed. Agent pipelines need the same separation, and they make it harder, because the evidence of operation is itself text produced by the agent being checked.
A report is also text that an attacker can sometimes influence. An agent that reads a poisoned file can be steered into a confident summary (see our note on prompt injection through tool content). We treat a subagent's report the way we treat any tool output: as a claim to verify.
The two forms
The first is narration only. The agent describes actions it never executed: "I read the file", "I ran the tests, all green", "I checked the migration". Its transcript contains no real tool calls, or tool calls written out as plain text instead of executed. The report is fluent and specific.
The second is harder to spot. The agent does call tools, so a check that asks whether any tool was used passes. Every call is a read: open a file, search, list a directory. Nothing was edited, written or executed, the task required a change, and the agent reports DONE.
From the orchestrator's side, both look like a DONE with a convincing explanation.
Genuine run
status: DONE
- read
- search
- edit
- run tests
- write
repository: diff present
accepted
Narration only
status: DONE
- 0 real tool calls
repository: clean tree
rejected · narration only
Read-only imposture
status: DONE
- read
- search
- read
- list
- read
repository: clean tree
rejected · read-only imposture
We met both forms in our own fleet in April 2026, on consecutive days. The first came from an orchestration agent that returned narration only reports; we removed it from the fleet. The second form appeared the next day, and it is the reason the trace below breaks tool use down by type.
What the check compares
The principle is the one we apply to any evidence in an investigation: a claim is accepted when an independent source confirms it.
Each agent ends its run with a structured trace instead of free text:
trace:
delegation_id: <id of the delegated task>
parent_id: <upstream id, or "user">
agent: <agent name>
tool_uses_count: <total tool calls>
tool_uses_breakdown:
read_count: <reads and searches>
edit_count: <edits>
write_count: <file writes>
bash_count: <shell commands>
expected_evidence: <code | artifact | read_only | none>
git_evidence: <git status or last commit, or "none">
status: <DONE | BLOCKED | NEEDS_REVIEW | ...>
The breakdown by type is what exposes the second form: tools were used, and none of them could have changed anything. The delegation and parent identifiers let us rebuild the chain of who asked whom for what after the fact, which is how most chain failures get diagnosed.
-
Claim
status: PASS - Trace Self reported tool calls broken down by type
- Runtime Identity and role attribution on the transcript, evidence expected from the role
- Repository Diff or commit decides for code work
- Verdict Logged and read
The trace is self reported, and today our check takes the tool counts from it as declared. Two things do not come from the agent. The identity of the agent is taken from the attribution the runtime writes on the transcript, and only falls back to the name the agent declares. The expected evidence comes from the agent's role: a builder is expected to produce code, a reviewer to read. An agent may raise its expected evidence (a reviewer that writes a report file declares an artifact) and may never lower it. A DONE on a code task whose git evidence shows no change is rejected.
Taking the counts from the transcript itself, instead of from the declaration, is the next step. The function below is written for that version.
The decision rules as code
The rules fit in one function. claim is what the agent reports; observed is what the transcript and the repository show. Until observed is filled from the transcript, the rule on contradicting counts has nothing to compare and the other rules run on the declared figures. The function is stdlib Python and carries its own checks:
def verdict(claim: dict, observed: dict) -> str:
if claim["status"] not in ("DONE", "PASS"):
return "no completion claimed"
if observed["tool_uses"] == 0:
return "narration only"
if claim["tool_uses"] != observed["tool_uses"]:
return "self-report contradicts the transcript"
changes = observed["edits"] + observed["writes"]
if claim["expected_evidence"] == "code":
if changes == 0:
return "read-only imposture"
if not observed["diff"]:
return "claimed work without a diff"
if claim["expected_evidence"] == "artifact" and changes == 0:
return "over-claimed evidence"
return "consistent"
if __name__ == "__main__":
def obs(tools, edits=0, writes=0, diff=False):
return {"tool_uses": tools, "edits": edits, "writes": writes, "diff": diff}
def cl(tools, evidence, status="DONE"):
return {"tool_uses": tools, "expected_evidence": evidence, "status": status}
assert verdict(cl(5, "code"), obs(0)) == "narration only"
assert verdict(cl(7, "code"), obs(7)) == "read-only imposture"
assert verdict(cl(9, "code"), obs(9, edits=2)) == "claimed work without a diff"
assert verdict(cl(12, "code"), obs(4, edits=1, diff=True)) == "self-report contradicts the transcript"
assert verdict(cl(3, "artifact"), obs(3)) == "over-claimed evidence"
assert verdict(cl(6, "read_only", "PASS"), obs(6)) == "consistent"
assert verdict(cl(8, "code"), obs(8, edits=3, diff=True)) == "consistent"
print("ok")
A reviewer that only reads comes out consistent, which is correct: reading is its job. Its PASS is then only as good as what it read, so the list of files a review agent opened belongs next to its verdict: a review that never opened the changed files is visible at a glance.
Shell commands are left out of the change count on purpose. A command can change the working tree or only list it, and the trace alone cannot tell which. The diff settles that case.
Where the verdicts go
The check runs when a subagent stops and writes its verdict to a log that a report script reads. That second half matters as much as the first. We once removed a trace store that had been written for months and never read. A control whose output nobody reads is in the same position as the review that never ran.
Limitations
- While the counts are self reported, an agent that invents plausible counts for a review task passes. The role and git rules still catch a code task with no change; nothing catches a reviewer that lies about its reads.
- The check proves that no work happened. It does not prove that the work is correct, which stays with tests and human review.
- Role defaults are coarse. A builder sometimes has nothing to change, and a wrongly declared artifact produces a false positive.
- Agents outside the known roster are skipped, so a new agent type is unchecked until it is added.
- An agent can produce a trivial real diff. The repository proves that something changed, and only review shows whether it matches the task.
- A reviewer that opened the right files and still missed a vulnerability passes this check. It detects absent work, never poor judgement.
Checklist
- Never accept a free text DONE or PASS. Require a structured trace.
- Count tool use by type, so that reads cannot pass for changes.
- Compare the trace with the transcript, and take identity from the runtime.
- Let the repository decide for code work.
- Record which files a review agent actually read, next to its verdict.
- Treat a subagent report as untrusted text, like any other tool output.
- Make sure someone reads the verdicts, and keep a human approval before anything irreversible.
eHVB-IT is a French cybersecurity consultancy working on SOC triage, application security and detection engineering. Contact: contact@ehvb-it.fr