Reproducible vulnerable/fixed fixtures for the class of bug published as CVE-2026-44246 (nnU-Net, agentic workflow injection, CVSS 7.2, CWE-1427), plus the measured output of three detectors against them.
It exists because there was nothing to test a detector against. Writing a rule for this class means writing a fixture by hand and hoping it is faithful; the published revisions were only available by knowing which commit to look at, which turned out to be the interesting part.
A GitHub Actions workflow hands untrusted text to an AI agent that has the repository’s credentials.
Four conditions, all necessary:
issues, issue_comment, pull_request_review —
events whose text anyone with a GitHub account can author.issues: write, contents: write, and so on.claude-code-action and
codex-action refuse a run actor without write permission unless an input
(allowed_non_write_users, allow-users) explicitly opts them in. Without
that input an untrusted author never reaches the agent, and the workflow is
the safe configuration rather than this one.$ inside prompt:) or fetched at run time
(gh issue view) with the token the job hands over.It is not template injection. Nothing is evaluated as shell; the payload is
prose, and the interpreter is the model. That is why the usual advice — quote
your variables, do not eval — does not apply, and why the fix below is about
what the agent can reach rather than about escaping input.
nnU-Net hardened this workflow in two commits, and a detector that treats “before” and “after” as a binary gets the middle one wrong.
| revision | commit | date | agent’s --allowedTools |
verdict |
|---|---|---|---|---|
| vulnerable | 94300b49e716 |
2026-04-13 | gh issue comment, gh issue edit |
reachable write |
| “the fix” | 4e4770b0b0e6 |
2026-04-24 | gh issue comment only; labelling moved to a wrapper script |
still reachable write |
| later | 11bd8746fc06 |
2026-04-27 | neither; a later step posts from a file the agent writes | not reachable |
The commit that everyone would call the fix — its message is “hardened issue
and PR agents” — removed gh issue edit and routed labels through
.github/scripts/safe-label.sh, but left Bash(gh issue comment:*) with the
agent. The agent could still be steered into commenting, and the allowlist
pattern gh issue comment:* is not scoped to the triggering issue, so the
target was the model’s to choose. Only the third commit closed it: the agent now
writes /tmp/issue-comment.md and a later, non-agent step posts it with
ISSUE: $ taken from the event.
Two things follow, and they are why this repository is not just two files:
“Fixed” is a claim about a revision, not a version. v2.4.1 is cited as the
fixed release, but its .github/workflows/ contains only codespell.yml — the
agent workflows are not in that tag at all. You cannot verify the fix from the
tag; you have to pin the commit.
The permissions block never changes. issues: write is present and correct
in all three revisions, including the last one, because a later step needs it. A
detector that keys on permissions alone cannot separate revision 1 from
revision 3. What separates them is what the agent may call.
Three detectors, run over both fixtures and all three real revisions. Full raw output and tool versions in results.md.
| revision | agentbound 0.1.3 | sisakulint v0.3.7 | zizmor 1.30.1 |
|---|---|---|---|
1-vulnerable |
HIGH write-scope, HIGH untrusted-content | ai-action-prompt-injection |
— |
2-fix-commit |
HIGH write-scope, CRITICAL author-association | — (generic only) | — |
3-later |
LOW write-scope, CRITICAL author-association | ai-action-excessive-tools, ai-action-execution-order |
— |
No detector is simply “wrong” here — they answer different questions:
ai-action-prompt-injection fires on the vulnerable
revision, where the issue body is interpolated into prompt:, and stops once
the interpolation is gone. Correct. Its findings on the later revision are
worth reading before acting on them — ai-action-excessive-tools flags
Write, which this workflow uses on purpose to write the file that a later
step posts; and ai-action-execution-order wants the agent last, which is
exactly what this design avoids, since the privileged steps come after the
model’s turn ends.permissions. Its
ci-agent-missing-author-association finding is reported at critical on the
later revision and its own README describes that as over-severe rather than
false: auto-triage is meant to be reachable by anyone.Every tool here has a finding it reports on a revision whose author had already reasoned about that exact condition. That is the normal state of a heuristic, and the reason a coverage table is more useful than a pass/fail column.
git clone https://github.com/sushant-me/agentic-workflow-injection
cd agentic-workflow-injection
bash scripts/fetch-revisions.sh # the three real revisions, pinned by SHA
bash scripts/benchmark.sh # runs every detector that is installed
benchmark.sh reports a detector that is missing as missing rather than
skipping it silently, because a coverage table that quietly omits a tool proves
nothing about it.
The fixtures are the reduced shape, written for this repository and MIT-licensed
like the rest of it. They are mechanism-faithful rather than copies:
fixtures/vulnerable.yml carries the four conditions and nothing else, and
fixtures/fixed.yml is the same file with the six changes that matter, each
annotated. If you are adding a rule, those two files are the smallest inputs it
has to get right.
Two interface quirks, handled in the script but worth knowing:
not found and exits 3. Unwatched, that looks identical to a clean
scan.path is a basename, so a directory scan over files with
the same name is ambiguous.Detection is the easy half. docs/mitigations.md covers the other half: how to bound an agent’s authority, with the layers ranked by one question — does this control depend on the model choosing to comply?
That ranking is the whole point. An instruction in a prompt is input, not a guard clause; a tool pattern that names its target, or a token the agent never holds, is a bound. The guide works down from “the model cannot do the wrong thing” to “the model was asked not to”, with a checklist and the nnU-Net evolution as the worked example.
fixtures/fixed.yml tags each of its six changes with the layer it implements
and with whether it is load-bearing — two of them are not, and are labelled
that way so a reader does not overestimate the file.
Fetched at run time by commit SHA, never vendored:
| file | repository | commit | path |
|---|---|---|---|
real/1-vulnerable.yml |
MIC-DKFZ/nnUNet |
94300b49e716 |
.github/workflows/issue-triage.yml |
real/2-fix-commit.yml |
MIC-DKFZ/nnUNet |
4e4770b0b0e6 |
.github/workflows/issue-agent.yml |
real/3-later.yml |
MIC-DKFZ/nnUNet |
11bd8746fc06 |
.github/workflows/issue-agent.yml |
No copy of nnU-Net’s configuration is redistributed here; re-run the fetch and compare the bytes yourself against the blobs above.
This is a detection-engineering artifact. It contains no exploit, and nothing
here was tested against a live workflow — the vulnerable revisions are static
files, already public in nnU-Net’s history and referenced by a published
advisory. The fixtures are marked do not deploy because they exist to be
detected, not copied.
If you maintain a detector for this class and want a row in the table, the
benchmark script is the interface: add a section that runs your tool over
targets and prints its findings, and the fixtures and pinned revisions do the
rest.
MIT.