How a Triage User Injects Into a Claude Code Action Write Run

HackerOne #3918594

CVSS 7.5

Amartya Jha

In this Security Research

No headings found on page

In this Security Research

What if a run that was correctly authorized to write to your repository could be tricked into pulling in data that arrived after the moment it was authorized, placed there by someone who was explicitly denied the ability to start that run at all?

That's what we found in Claude Code Action, the GitHub Action that lets Claude respond to issues and pull requests and push changes back to your repo.

A collaborator holding nothing more than GitHub's triage role, no write access, no ability to launch Claude, could wait for a maintainer to authorize a run, then quietly move the run's trigger boundary forward and feed it their own data. The maintainer's already-authorized job, which held edit, commit, and push capability, would consume that data as if it had been there all along.

Anthropic validated this as High, CVSS 4.0 score 7.5. It isn't ordinary prompt injection. The injected value was created after the authorization cutoff and reached the privileged run only because the cutoff was silently rebound to a different, lower-privileged user's event.

Now, here's how we found it, and how it works.

Where This Started

We've been auditing the trust boundaries in agentic CI at CodeAnt AI Security Research. Agentic GitHub workflows are a rich target because they constantly mix two very different classes of data: an authorization event (a maintainer applying a trusted label) and live repository context (issue comments, reviews, PR discussion). Any time those two are bound to different objects, there's a boundary to probe.

We pulled Claude Code Action v1.0.185 (commit 9db594c7a0e82298c121c18b7f08aa1579ce7341) and traced how a labeled issue becomes a repository-writing run. Two controls stood out, and both looked correct in isolation:

  1. A write-permission gate: before Claude gets repository-writing tools, the Action checks whether the actor in the webhook has write permission.

  2. A temporal cutoff: comments created or edited after the trigger time are filtered out, so content added after authorization can't silently enter the running job.

The problem was that those two controls were bound to different events. The permission check used the actor from the original webhook. But for issues.labeled, the Action didn't take the event time from that webhook. It fetched the issue's live event history, found every labeled event with the same label name, and selected the newest one, without requiring that event to have been created by the actor who passed the permission check.

That's an identity-rebinding bug. And it turns a legitimate, low-privilege GitHub capability into control over the input of a privileged run:

maintainer applies trigger label  -> run is authorized (write permission OK)
triage collaborator adds data, removes label, reapplies same label
  -> a newer "labeled" event now exists
  -> the authorized run mistakes it for its own trigger event
  -> the comment cutoff moves forward
  -> post-authorization data enters the final prompt
maintainer applies trigger label  -> run is authorized (write permission OK)
triage collaborator adds data, removes label, reapplies same label
  -> a newer "labeled" event now exists
  -> the authorized run mistakes it for its own trigger event
  -> the comment cutoff moves forward
  -> post-authorization data enters the final prompt
maintainer applies trigger label  -> run is authorized (write permission OK)
triage collaborator adds data, removes label, reapplies same label
  -> a newer "labeled" event now exists
  -> the authorized run mistakes it for its own trigger event
  -> the comment cutoff moves forward
  -> post-authorization data enters the final prompt

To be precise about the capability: the triage collaborator still could not invoke the repository-writing workflow directly, their own webhook fails the write-permission gate. What they gained was narrower and more important: control of the post-authorization input consumed by a maintainer-authorized run that held edit, commit, and push capabilities.

Why the Trigger Time Matters

Agentic GitHub workflows combine two classes of data:

  • an authorization event, such as a maintainer applying a trusted label; and

  • live repository context, such as issue comments, reviews, and pull-request discussion.

Fetching live context is convenient, but it creates a race. A user can change that context while a queued or starting job is still preparing its prompt. Claude Code Action therefore applies a temporal cutoff: comments created or last edited after the trigger time should not be included in the run.

The intended invariant is:

authorized webhook at time T
  -> include context that existed at or before T
  -> exclude context created or changed after T
authorized webhook at time T
  -> include context that existed at or before T
  -> exclude context created or changed after T
authorized webhook at time T
  -> include context that existed at or before T
  -> exclude context created or changed after T

That invariant only holds if T belongs to the webhook that was actually authorized.

For label events, the implementation had to reconstruct T from GitHub's issue-event API. The relevant resolver was equivalent to:

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

GitHub's event representation includes an event actor and an event identifier. Neither was used to bind the selected event to the original webhook. Event type, label name, and recency were treated as an identity.

Two Security Decisions, Two Different Objects

The defect is easier to see when the job is split into its two decisions.

Decision 1: may this actor start a write-capable run?

The Action checked context.actor from the webhook. A maintainer with write permission passed. A triage collaborator did not.

Decision 2: which repository context belongs to that authorized run?

The Action queried the current issue-event history. It selected the most recent event with the same action and label name, regardless of actor. That timestamp became the cutoff used to collect comments for the final prompt.

The first decision authorized one lifecycle object. The second decision consumed another.

permission identity: original webhook actor and event
content identity:    newest live event with matching type and label
permission identity: original webhook actor and event
content identity:    newest live event with matching type and label
permission identity: original webhook actor and event
content identity:    newest live event with matching type and label

Once those identities diverged, a user who could not pass the first decision could influence the result of the second.

Reproducing the Boundary Shift

The deterministic test used the following timeline:

09:59  Maintainer-authored task already exists
10:01  Maintainer applies the "claude" label
10:02  Triage collaborator posts RELEASE_APPROVAL=attacker-controlled
10:03  Triage collaborator removes and reapplies the "claude" label
09:59  Maintainer-authored task already exists
10:01  Maintainer applies the "claude" label
10:02  Triage collaborator posts RELEASE_APPROVAL=attacker-controlled
10:03  Triage collaborator removes and reapplies the "claude" label
09:59  Maintainer-authored task already exists
10:01  Maintainer applies the "claude" label
10:02  Triage collaborator posts RELEASE_APPROVAL=attacker-controlled
10:03  Triage collaborator removes and reapplies the "claude" label

At 10:01, the comment does not exist. It must therefore remain outside the maintainer run.

With only the original event available, the resolver returns 10:01 and the comment is excluded. When the later same-label event is present, the resolver returns 10:03. The exact same comment now appears to be pre-trigger data and enters the prompt.

The attacker does not need to edit the original webhook or impersonate the maintainer. They only need the legitimate issue-management operations available to their lower role. The original job performs the substitution itself when it reads mutable event history.

The Protected Boundary and Capability Delta

The security claim is not that issue comments are trusted, they are expected to be untrusted input. It is also not that triage collaborators can manage labels, GitHub intentionally grants them that capability.

The protected boundary is the snapshot created by a maintainer's authorization event. Claude Code Action explicitly filters later content so that the inputs to a privileged run cannot change after that event.

The complete capability transition was:

Element

Proven value

Attacker principal

Collaborator with GitHub's triage role

Existing capabilities

Comment on issues and manage labels

Direct authorization

Rejected by Claude Code Action's write-permission gate

Protected boundary

Maintainer webhook and its trigger-time context snapshot

Identity error

A newer same-name event from another actor replaces the authorized event's temporal identity

Privileged sink

Tag mode with workspace edits, scoped commit tools, and authenticated push

New capability

Mutate the model input of an already-authorized repository-writing run after authorization

This is what separates the issue from ordinary prompt injection. The value was not merely present in an issue that Claude chose to read. It was created after the explicit cutoff and reached the run only because the cutoff was rebound to a different user's event.

From Input Mutation to a Repository Write

We split the proof into two layers so each claim could be tested independently.

Layer 1: exact resolver and prompt construction

The first harness imported the release's unmodified implementations of:

  • resolveTriggerTimestamp();

  • fetchGitHubData();

  • the write-permission check;

  • context preparation; and

  • final prompt generation.

Deterministic Octokit responses represented the maintainer and triage roles. The harness confirmed that:

  • the maintainer webhook passed the exact permission gate;

  • the triage collaborator's direct webhook failed it;

  • a later same-label event from the triage actor moved the cutoff;

  • the post-trigger value entered the exact final prompt only after that move;

  • the original-event-only control excluded the value; and

  • a later event for a different label also excluded it.

Three clean runs produced identical prompt hashes for the positive and negative cases.

Layer 2: the repository-writing sink

The second harness used the Action's bundled Claude Code runtime and its actual tag-mode tool configuration. All model traffic was loopback-only, every credential was synthetic, and the Git remote was a local bare repository.

The issue body contained a maintainer-authored task: copy the latest RELEASE_APPROVAL value from the supplied context into release-approval.txt, commit it, and push it. The attacker comment contained only a data value. It did not ask the model to ignore instructions or perform a malicious action.

In every positive run, event rebinding admitted the value, Claude wrote it, committed it, and invoked the release's push helper. In every matched negative run, the trusted task remained present but the post-trigger value was absent, so no file and no remote branch were created.

Differential Evidence

The controls were designed to change one security-relevant property at a time.

Case

Permission result

Selected cutoff

Attacker value

Repository sink

Triage actor triggers directly

Rejected

Not reached

Not reached

No write

Maintainer event only

Allowed

10:01

Excluded

No attacker-selected file

Later different-label event

Allowed

10:01

Excluded

No attacker-selected file

Later same-label event from triage actor

Allowed under maintainer webhook

10:03

Included

File committed and pushed

Same-label event with actor binding enforced

Allowed under maintainer webhook

10:01

Excluded

No attacker-selected file

The exact positive and negative matrices passed three consecutive clean runs each. The sink proof recorded zero permission denials, because the Action believed it was still carrying out the maintainer-authorized job.

Impact

A repository that used issues.labeled with a Claude trigger label and supplied a write-capable workflow token could expose its authorized job to post-authorization input mutation by a lower-privileged collaborator.

Depending on the maintainer-authored task and repository automation, the admitted data could influence:

  • generated source or configuration;

  • release metadata and approval files;

  • commits produced by Claude;

  • branches pushed by the Action; and

  • downstream CI or deployment behavior that consumes those commits.

The finding was rated High, not Critical. Exploitation required a suitable label-triggered workflow, a collaborator with triage permissions, a timing window before the authorized job completed its live lookup, and a task in which the admitted data influenced output. We did not claim that every injected value automatically becomes arbitrary code execution.

Anthropic assigned CVSS 4.0 score 7.5:

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
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
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

Root-Cause Analysis

The implementation used mutable semantic attributes as a substitute for event identity.

expected binding:
  authorized webhook -> that webhook's immutable event boundary

affected binding:
  authorized webhook -> newest live event with the same type and label name
expected binding:
  authorized webhook -> that webhook's immutable event boundary

affected binding:
  authorized webhook -> newest live event with the same type and label name
expected binding:
  authorized webhook -> that webhook's immutable event boundary

affected binding:
  authorized webhook -> newest live event with the same type and label name

The fields event === "labeled" and label.name === "claude" describe a class of events. They do not identify one event. Selecting the newest member of that class makes the boundary sensitive to legitimate actions performed after authorization.

This is a lifecycle authorization problem. The permission decision was correct when it ran, and the comment filter was correct for the timestamp it received. The vulnerability appeared in the transition between them, where the timestamp's provenance was lost.

Recommended Remediation

The durable fix is to carry one authenticated event identity through authorization, context collection, and execution.

  1. Prefer an immutable event identifier supplied by a trusted delivery field when GitHub exposes one for the trigger.

  2. If a live lookup is unavoidable, require the selected event actor to match the original webhook actor.

  3. Bound candidate events to a trusted job-receipt or delivery timestamp so later activity cannot move the cutoff forward.

  4. Fail closed when more than one live event can satisfy the reconstruction criteria.

  5. Preserve the original webhook payload for every field intended to represent trigger-time state.

  6. Add regression tests for relabeling by another actor while the authorized job is queued or starting.

Actor matching alone is helpful but is not a complete event identity. The strongest design avoids reconstructing security state from a mutable history whenever the original delivery can provide or anchor that state.

Lessons for Agentic CI Systems

1. Authorization must cover the data snapshot

Approving an agentic job is not only a decision about who may start it. It is also a decision about which inputs the job may consume. If the input snapshot can move after approval, the authorization can be correct while the resulting action is not.

2. Semantic equality is not identity

Two events can share an action and a label name while belonging to different actors, times, and authorization contexts. Matching fields that look unique in ordinary use is not enough for a security boundary.

3. Lower roles should be tested against lifecycle transitions

Role checks are often reviewed in isolation. The more productive question is what a rejected role can change after a stronger role has passed the gate. Queues, retries, live lookups, reruns, and resumptions are all points where identities can be rebound.

4. Sink validation should avoid relying on hostile instructions

The strongest impact proof used a trusted maintainer task and attacker-controlled data. That design established the authorization failure without assuming the model would follow an overt prompt injection.

Disclosure Timeline

Date

Event

August 5, 2026

Report submitted to Anthropic through HackerOne (#3918594)

August 13, 2026

Anthropic validated the issue and assigned High severity, CVSS 4.0 score 7.5

August 14, 2026

Bounty of $XXXX awarded

August 24, 2026

Editorial cutoff; report remained triaged and private, with no confirmed fixed version

Are You Affected?

Step 1: Check whether you run Claude Code Action with a label trigger

You are in scope of the pattern if your workflow does all of the following:

  • triggers Claude Code Action on issues.labeled (a trigger label such as claude);

  • runs in tag mode (or otherwise grants workspace edits, commit tools, and authenticated push); and

  • supplies a write-capable workflow token to the job.

A quick way to check your workflow files:

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

Step 2: Check who can move your labels

The exposure only exists if someone below write can manipulate the trigger label. Review your repository roles:

  • Collaborators with GitHub's triage role can add/remove labels and comment, but cannot write code.

  • If triage (or any sub-write role) can apply and remove your Claude trigger label, they can create the newer same-label event this finding relies on.

Step 3: Reduce exposure

Until a fixed version is confirmed:

  • Restrict who can apply your Claude trigger label (limit the label to write-and-above roles, or gate it behind a separate protected mechanism).

  • Prefer triggers whose authorization event and content boundary come from the same immutable delivery, rather than a label event reconstructed from live history.

  • Treat any file, commit, or branch produced by a label-triggered run as untrusted until reviewed, and keep downstream CI/deploy steps from auto-consuming those commits without review.

  • Update Claude Code Action once Anthropic confirms a fix, and re-verify the fixed version.

Note: Remediation status was not publicly confirmed at the editorial cutoff. Reconfirm the fixed version and affected releases with Anthropic before acting on this section.

Conclusion

Claude Code Action correctly rejected the triage collaborator's own attempt to start a repository-writing run. The vulnerability arose because an already-authorized maintainer job later reconstructed its trigger boundary from mutable event history and accepted a newer event belonging to that rejected principal.

The central invariant is straightforward:

The event that defines a privileged agent's input boundary must be the same event that was authorized to start the run.

Preserving that identity prevents later repository activity from changing the meaning of an earlier authorization decision, and it's a stronger foundation for every agentic CI system that mixes an authorization event with live, mutable context.

References

This research is part of an ongoing effort by CodeAnt AI Security Research to audit the trust boundaries in agentic development and CI tooling. This finding was conducted through Anthropic's authorized HackerOne program; the reproduction used local Git repositories, synthetic credentials, and a loopback-only model fixture, and no external repository or user data was modified. More findings will be published as coordinated disclosure timelines are met.

If you build an agentic developer or CI tool and believe a similar boundary may be at risk, we'd love to help: securityresearch@codeant.ai

[FAQ]

Frequently Asked
Questions

What did CodeAnt AI Security Research find in Claude Code Action?

What version was affected?

Isn't this just prompt injection?

Who can exploit it, and what do they gain?

Why is this rated High (7.5) rather than Critical?

[GET STARTED]

Find out what's already

exploitable in your codebase.

Find out what's already

exploitable in your codebase.

Find out what's already exploitable in your codebase.

START PENTEST

NO CC REQUIRED