# Agentic workflow injection: fixtures and a coverage comparison

Reproducible vulnerable/fixed fixtures for the class of bug published as
[CVE-2026-44246](https://github.com/MIC-DKFZ/nnUNet/security/advisories/GHSA-63mx-j37w-gh59)
(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.

## The class

A GitHub Actions workflow hands untrusted text to an AI agent that has the
repository's credentials.

Four conditions, all necessary:

1. **An untrusted trigger.** `issues`, `issue_comment`, `pull_request_review` —
   events whose text anyone with a GitHub account can author.
2. **A write scope on the job.** `issues: write`, `contents: write`, and so on.
3. **An opt-out of the action's own actor check.** `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.
4. **The text reaching the agent.** Either interpolated into the workflow
   (`${{ github.event.issue.body }}` 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.

## The part worth knowing: the fix commit is not the fix

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: ${{ github.event.issue.number }}` 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.

## Measured coverage

Three detectors, run over both fixtures and all three real revisions. Full raw
output and tool versions in [results.md](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:

* **zizmor** is a general-purpose Actions auditor. It reports pinned-action and
  credential-persistence hygiene identically on all three revisions. Out of
  scope for this class by design, and included because "the well-known linter was
  clean on the vulnerable file" is easily mistaken for a clean bill of health.
* **sisakulint** has purpose-built AI rules and is the only tool here that names
  the CVE's own mechanism: `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.
* **agentbound** is the only tool that separates revision 2 from revision 3, by
  reading the agent's tool allowlist rather than the job's `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.

## Using it

```bash
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:

* **sisakulint does not analyse a file outside a git repository.** Handed one, it
  prints `not found` and exits `3`. Unwatched, that looks identical to a clean
  scan.
* **agentbound's JSON `path` is a basename**, so a directory scan over files with
  the same name is ambiguous.

## Fixing it

Detection is the easy half. **[docs/mitigations.md](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.

## Provenance

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.

## Scope

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.
