DEV Community

Amartya Jha for CodeAnt AI

Posted on Originally published at codeant.ai

How a GitHub Triage Role Hijacked an Already-Authorized Claude Code Action Run

A GitHub collaborator with only the triage role could shift Claude Code Action's authorized trigger boundary and inject post-authorization input into a repository-writing run.

HackerOne #3918594 · Claude Code Action v1.0.185, commit 9db594c7a0e82298c121c18b7f08aa1579ce7341 · CVSS 4.0 score 7.5, High


Authorizing an automated job is really two promises: who gets to start it, and which data it's allowed to see once running. Most security reviews focus on the first and barely glance at the second, which is exactly where CodeAnt AI Security Research went looking in Claude Code Action, the GitHub bot that lets Claude respond to issues and pull requests and push changes back to a repository.

What we found: a collaborator holding nothing more than GitHub's triage role, no write access, no ability to launch Claude directly, could wait for a maintainer to authorize a run and quietly shift that run's own idea of when it had been authorized, so the already-privileged job treated data the collaborator posted afterward as if it had always been there.

This is not ordinary prompt injection. The data in question was created after the cutoff meant to exclude it, and it only got in because that cutoff itself was silently reassigned to a different person's action.

What Claude Code Action is supposed to guarantee

Two controls sit between a GitHub event and a repository-writing Claude run.

The first is a write-permission gate: before Claude gets any repository-writing tools, the Action checks whether the actor behind the triggering webhook actually has write access to the repo.

The second is a temporal cutoff: comments posted or edited after the trigger moment are filtered out of the context Claude sees, so nobody can sneak extra instructions in after a maintainer has already greenlit the run.

Both controls sound airtight individually. We went looking for whether the actor the first control checks and the timestamp the second control uses actually describe the same underlying event. They didn't, at least not for one common trigger type.

Reconstructing a timestamp from the wrong source

For most trigger events, Claude Code Action reads its timestamp from the webhook payload that fired. issues.labeled events work differently: the Action instead queries GitHub's API for the issue's full label history, filters to every event where a matching label was applied, and picks whichever happened most recently.

The resolver, in essence, does this:

const matches = events.filter(
  event => event.event === "labeled" && event.label?.name === labelName
);
const latest = matches.sort(byCreatedAtDescending)[0];
return latest?.created_at;
Enter fullscreen mode Exit fullscreen mode

GitHub's label-event data includes both an actor and a unique event identifier. Neither gets used here. The resolver treats "an event of this type, with this label name, that happened most recently" as if that were enough to identify one specific, authorized event. It isn't.

Two decisions, checking two different things

Split this into the two questions the Action is actually answering.

The first: is this actor allowed to start a write-capable run? Answered by checking context.actor on the original webhook. A maintainer passes; a triage-level collaborator does not.

The second: which repository context belongs to that now-authorized run? Answered, for label triggers, by a live query that ignores actor entirely and just grabs the newest matching event.

permission identity: the original webhook's actor and event
content identity:    whichever live event matches type and label most recently
Enter fullscreen mode Exit fullscreen mode

Those two answers are supposed to describe the same moment. Once they don't, anyone who fails the first question can still shape the outcome of the second, as long as they have enough GitHub permissions to produce a matching event of their own.

Watching the boundary actually move

We built a deterministic timeline to prove this out:

09:59  A maintainer-authored task already exists on the issue
10:01  Maintainer applies the "claude" label
10:02  Triage-level collaborator comments RELEASE_APPROVAL=attacker-controlled
10:03  Same collaborator removes the "claude" label, then reapplies it
Enter fullscreen mode Exit fullscreen mode

At 10:01, when the run is supposed to be authorized, the 10:02 comment doesn't exist yet, so it has to stay out of scope no matter what.

Run the resolver with only the original 10:01 event visible, and it correctly returns 10:01, comment excluded. Add the 10:03 relabel event back in, and the resolver instead returns 10:03. The exact same comment, unchanged, now reads as pre-trigger content and lands in the prompt.

None of this required forging a webhook or impersonating the maintainer. The collaborator used only the ordinary label-management permissions GitHub already grants that role. The authorized job did the substitution itself, by trusting a live, mutable history over the event it was actually triggered by.

What was and wasn't the trusted boundary

To be clear: issue comments were never treated as trusted input, they're expected to be arbitrary and untrusted. Triage collaborators managing labels isn't a flaw either, GitHub deliberately gives that role exactly that capability.

The actual protected boundary is narrower: the snapshot of context that existed at the moment a maintainer's authorization fired. Claude Code Action explicitly filters out anything added after that snapshot so a privileged run's inputs can't shift after the fact.

That distinction, control over post-authorization input rather than simple access to untrusted content, is what separates this from a garden-variety prompt injection report.

Following the mutation all the way to a commit

We split verification into two independent layers so neither claim depended on the other.

The first layer isolated the resolver and prompt-building logic itself: resolveTriggerTimestamp(), fetchGitHubData(), the permission check, and prompt generation, fed scripted responses standing in for a maintainer and a triage-level actor. It confirmed the maintainer's webhook passes the gate cleanly, the triage actor's own webhook fails it every time, and only a later same-label event from the triage actor moves the cutoff.

The second layer went further, into the Action's bundled runtime and its real tag-mode tool access: edit, commit, push, loopback-only, against a local bare repository. The issue held an ordinary maintainer task: take the latest RELEASE_APPROVAL value, write it to a file, commit and push it. The attacker's comment held nothing but a plain value, no jailbreak language. Wherever the rebinding took effect, Claude wrote, committed, and pushed that value; wherever it didn't, the value was simply absent.

Isolating exactly what changed

We designed five controls so each one changed a single security-relevant variable at a time:

Scenario Permission check Cutoff selected Attacker's value Repository sink
Triage actor triggers directly Rejected Never reached Never reached No write occurs
Only the maintainer's original event exists Allowed 10:01 Excluded No attacker-chosen file
A later event with a different label exists Allowed 10:01 Excluded No attacker-chosen file
A later same-label event from the triage actor exists Allowed, under the maintainer's webhook 10:03 Included File committed and pushed
Same later event, but actor binding is enforced Allowed, under the maintainer's webhook 10:01 Excluded No attacker-chosen file

Every case reproduced across three clean runs each. Notably, the sink-level proof recorded zero permission denials the entire time, because as far as the Action's own logic was concerned, it was still faithfully executing the maintainer's original, authorized job.

What a repository actually loses

Any repository using issues.labeled with a Claude trigger label, combined with a write-capable workflow token, was exposed to having its authorized job's inputs quietly mutated by a collaborator well below write access.

Depending on the maintainer-authored task, admitted data could shape generated source or config, release metadata, the content of Claude's commits, which branches got pushed, and anything downstream that trusted those commits without a second look.

We rated this High rather than Critical: exploitation needs a label-triggered workflow, a collaborator with triage permissions, a timing window before the authorized job finishes its live lookup, and a maintainer task where the admitted data meaningfully changes the output.

Anthropic's assigned score reflects that conditionality:

CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:P/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N
Enter fullscreen mode Exit fullscreen mode

The actual root cause

Underneath the GitHub-specific details, this is a case of mutable semantic attributes standing in for a real event identity.

what should have been bound: the webhook's own, immutable event
what actually got bound:     the newest live event sharing type and label name
Enter fullscreen mode Exit fullscreen mode

event === "labeled" and label.name === "claude" describe a whole class of events, not one specific event. Picking the newest member of that class makes the boundary sensitive to anything that happens after the original authorization, as long as it matches type and label.

The permission check was correct the moment it ran. The comment filter was correct for whatever timestamp it was handed. The failure lived entirely in the handoff between those two steps, where the timestamp's actual origin got lost.

How we'd fix it

The durable solution carries one authenticated event identity, unbroken, from authorization through context collection through execution.

Where GitHub exposes a trusted, immutable delivery identifier, use that instead of reconstructing anything from live history.

Where a live lookup is unavoidable, require the selected event's actor to match the original webhook's actor, anchor candidates to a job-receipt timestamp so nothing afterward can drag the cutoff forward, and fail closed on ambiguity.

Preserve the original webhook payload for trigger-time state, and add regression coverage for a different actor relabeling the issue mid-run.

What this means for agentic CI more broadly

Authorizing a job isn't only a decision about who may start it. It's also, implicitly, a decision about which inputs it's allowed to consume, and if that snapshot can shift after approval, the authorization can be correct while the resulting behavior isn't.

Two events sharing an action type and a label name are not the same event; they can belong to different actors and trust levels even when every field looks identical.

A role check reviewed in isolation ("can this role trigger the workflow") misses the more useful question: what can a role that fails it still influence once a stronger role has already passed.

Disclosure timeline

Date Event
August 5, 2026 Report submitted to Anthropic through HackerOne (#3918594)
August 13, 2026 Anthropic validated the issue, assigned High severity, CVSS 4.0 score 7.5
August 14, 2026 Bounty of $XXXX awarded
August 24, 2026 Editorial cutoff; report still triaged and private, no confirmed fixed version

Checking your own exposure

Start by seeing whether your workflow even uses this trigger pattern:

grep -rn -E "anthropics/claude-code-action|issues.*labeled|types:\s*\?labeled" .github/workflows/
Enter fullscreen mode Exit fullscreen mode

You're in scope if your workflow triggers on issues.labeled with a Claude trigger label, runs in tag mode or otherwise grants workspace edits plus commit and authenticated push, and hands the job a write-capable token.

Anyone with GitHub's triage role, or any other sub-write role, can add and remove labels freely without writing code, generating the newer same-label event this bug depends on.

Until a fixed version is confirmed, restrict who can apply your Claude trigger label to write-and-above roles, treat anything a label-triggered run commits or pushes as unreviewed until a human looks at it, and keep downstream CI or deploy steps from auto-consuming those commits.

Where this leaves things

Claude Code Action's write-permission gate worked exactly as intended: it rejected the triage collaborator's own attempts to start a run, every time. The vulnerability sat one layer deeper: an already-authorized job later reconstructed its own trigger boundary from mutable history, and accepted a newer event belonging to the very principal it had just rejected.

The invariant here is simple to state and easy to violate: the event that defines a privileged agent's input boundary has to be the same event that authorized the run. Keep that identity intact, and later activity, however legitimate on its own, can't quietly rewrite an earlier authorization decision.


This was originally published by CodeAnt AI's Security Research Team. Read the complete breakdown here →

Top comments (0)