Skip to content
Published on

One Issue, the Whole Supply Chain — How an Agent Inside CI Broke, and What the Defenses Actually Bought

Share
Authors

Introduction — Where an Issue Body Becomes a Command

On June 1, 2026, RyotaK of GMO Flatt Security disclosed Poisoning Claude Code: One GitHub Issue to Break the Supply Chain. The gist is exactly what the title says — a single GitHub issue could take over a repository that used Claude Code GitHub Actions, and the affected repositories included Anthropic's own.

A previous post covered cross-site prompt injection in web agents. Where that post was a defense design grounded in a paper, this one is something that actually happened in a shipped product, with the timeline, CVSS scores, bounty amounts, and fix commits all public. And there's one more important difference — the same flaw family was actually exploited in a different product.

This post is not an attack recipe. It doesn't carry a payload. Instead it looks only at what an engineer building agents actually needs — exactly where the trust boundary broke, what the vendor's defenses do and don't stop, and whether there's a point where you can say "that's enough."

The Stage — What the Agent Inside CI Was Holding

Claude Code GitHub Actions is a workflow that hooks Claude Code into CI/CD. It handles things like automated code review, issue triage and labeling, and comment-driven code generation. It has two operating modes.

  • tag mode — triggers when a specific keyword (a Claude mention, by default) is mentioned in an issue or PR comment.
  • agent mode — triggers when a workflow has a prompt input configured. Used for slash commands or scheduled jobs.

To do anything with GitHub, the Claude GitHub App has to be installed on the repository, and the permissions that app holds are: read/write repository contents (code), read/write issues and PRs, read/write discussions, read/write workflows (Actions). Unless a separate token is specified, this app's token is used by default. Setup gets easier, and the attack surface gets wider.

This is already enough to assemble what Simon Willison called the lethal trifecta — access to private data, exposure to untrusted content, and a means of communicating externally. When all three land in one process, a single injection turns into exfiltration. A CI runner has, textbook-style, all three.

Step 1 — The One Line That Said "If It's a Bot, Let It Through"

Aware of this risk, the action by default blocked users without write permission from triggering workflows. The documentation states it plainly — the action can only be triggered by users with write access to the repository.

This control was implemented in the checkWritePermissions function. The function checks whether the actor's permission is write or admin. But ahead of that check sat a branch like this.

// src/github/validation/permissions.ts (before the fix)
if (actor.endsWith("[bot]")) {
  core.info(`Actor is a GitHub App: ${actor}`);
  return true;
}

If the actor's name ends in [bot], it passes unconditionally, regardless of actual permissions. On the surface this looks reasonable — a GitHub App is usually a trusted entity installed by the repository's admin. The problem is that assumption doesn't hold on public repositories.

As GitHub's own documentation states, a GitHub App has an implicit permission to read public resources even with no default permissions. And that implicit permission doesn't stop at reading. Opening an issue or PR on a public repository requires no special permission — just as any GitHub user can open an issue on someone else's repository, a GitHub App can use its own installation token to create issues and PRs on any public repository. It doesn't need to be installed on the target repository.

So the bypass comes together like this. An attacker creates their own app, installs it on their own repository (no special permission required), and uses that installation token to open an issue on the target public repository. Since the actor is a bot, checkWritePermissions returns true, and the workflow starts processing content the attacker controls.

What stings here is that a checkHumanActor check — which asks the GitHub API whether the actor type is really a User — already existed in tag mode. It was only agent mode that was missing it. The defense wasn't absent; it existed on only one of the two paths.

This is the first lesson. Judging trust by the shape of a name is not a trust check. endsWith("[bot]") doesn't ask "is this actor trustworthy" — it asks "what does this string look like." And the shape of a string is something an attacker gets to choose.

Step 2 — Where Data Becomes a Command

Once the bypass works, attacker-controlled content flows into the agent's context. The concrete example RyotaK gives is the issue-triage workflow in the anthropics/claude-code repository. This workflow instructs Claude to read issues via an MCP tool, and its allowed-tool list included things like this.

--allowedTools "Bash(gh label list),
                mcp__github__get_issue,
                mcp__github__get_issue_comments,
                mcp__github__update_issue,
                mcp__github__search_issues,
                mcp__github__list_issues"

mcp__github__get_issue reads, mcp__github__update_issue writes. Read and write tools sit side by side on the same allow list. That combination is what later becomes the exfiltration channel.

The structure of the injection itself is this. The attacker writes the issue body to look like an error message. When Claude reads the issue, it perceives the read as having failed and, while attempting to "recover," executes the command planted in the body. Something handed over to be read as data is instead interpreted as an instruction — the classic failure of in-band signaling, the same structure SQL injection and XSS have already proven out. Here, though, the channel is natural language.

Something the researcher himself honestly notes in a footnote matters — that description alone doesn't reliably trigger command execution; the actual exploit required far more sophisticated prompt engineering. In other words, it's probabilistic. But CI is a place where an attacker can open an issue as many times as they like. Attach unlimited retries to a probabilistic attack, and it becomes deterministic.

Step 3 — What Looks Read-Only Is the Exfiltration Channel

Once you have command execution, exfiltration comes next. Two things overlap here.

First, for the sake of UX, Claude Code was set up to run some read-only commands without approval. This is exactly the spot RyotaK dug into in the post right before this one (January 12, 2026). Commands like echo, man, sed, and sort were allowed by default, with a blocklist on arguments meant to prevent side effects — and that blocklist had eight holes in it. This was assigned CVE-2025-66032 and fixed in Claude Code v1.0.93 (per NVD, CVSS 3.1 9.8 / CVSS 4.0 8.7, CWE-77). Anthropic's response was to rip out the blocklist and switch to an allowlist approach. The researcher's conclusion is the same — blocklists lose in security-sensitive functionality like command execution.

Second, the runner process's environment variables hold secrets. Linux has a pseudo-file that exposes the current process's environment variables as-is, and reading it with an allowed read command pulls secrets into the context. Then, writing that back into the issue body via mcp__github__update_issue, the attacker just has to refresh their own issue.

This is where it matters why the ultimate target was OIDC credentials. GitHub Actions supports OIDC, which lets a workflow prove its own identity. If a workflow has id-token write permission, it can obtain a signed token from GitHub saying "I am workflow X in repository Y." Claude Code GitHub Actions uses this mechanism to obtain a privileged GitHub App installation token — it gets an OIDC token, submits it to Anthropic's backend token-exchange endpoint, the backend verifies it, and returns a Claude GitHub App installation token.

The credentials needed to request that OIDC token are ACTIONS_ID_TOKEN_REQUEST_TOKEN and ACTIONS_ID_TOKEN_REQUEST_URL, both environment variables. In other words, if these two leak, an attacker can reproduce the entire token-exchange process and end up with an installation token that has write access to repository contents, issues, PRs, and workflows. The repository is handed over.

Blast Radius — The Action's Own Repository Used That Same Workflow

So far this is "one repository." The reason this becomes a supply-chain issue is separate.

anthropics/claude-code-action — the repository for the action itself — was also running the same family of agent-mode workflow. That means the same technique could plant malicious code in the action's own source, which then propagates to every downstream repository that depends on it. The repository that built the tool was itself exposed to that tool's vulnerability.

RyotaK also found one more common misconfiguration that reaches a comparable level of compromise even without the action's own bug. Setting allowed_non_write_users to * lets anyone trigger the workflow, and this was present both in Anthropic's own repository and in the official example workflows. Repositories that copied the example inherited the same flaw wholesale.

The documentation flags this option as dangerous and says to use it "only for workflows with extremely limited permissions," citing issue labeling as an example. But the researcher's rebuttal is sharp.

  • Issue write permission isn't actually limited — it can delete or edit existing issues.
  • This workflow requires an Anthropic API key regardless. That's a sensitive secret on its own, and exposing it to untrusted input is already unfit for purpose.
  • Even with tightened permissions, exfiltration was still possible — the action posts a summary of the completed work to the workflow run's summary section by default, and that section is public.

And chaining these two becomes privilege escalation. Get an issue-write token from the loose triage workflow (via the summary section), use that token to edit an issue posted by a trusted user after it's been triggered, and get the strict tag-mode workflow to process it as trusted context. Tag mode holds id-token write permission, which is where the OIDC credentials come from. From an external user to full repository takeover — reached by composing two workflows.

Looked at individually, each workflow behaves "as intended." The flaw comes from composition.

This Isn't Hypothetical — It Actually Happened to Cline

Up to here, this was responsible disclosure and no real damage occurred (the researcher notes in a footnote that testing was confined to his own test repositories). But the same flaw family was actually exploited in a different product.

On February 9, 2026, security researcher Adnan Khan disclosed Clinejection. The AI coding tool Cline's issue-triage workflow — also built on claude-code-action, with Bash, Write, and Edit tools open, and triggerable by any GitHub user opening an issue — was vulnerable to prompt injection. The vulnerable window ran from December 21, 2025 to February 9, 2026.

The chain is interesting. Rather than pulling secrets straight out of the triage workflow, it instead poisons the GitHub Actions cache to jump over into the nightly-release and npm-publish workflows and steal deployment credentials there. And those nightly credentials carry the same permissions as production credentials — the VS Code Marketplace and OpenVSX bind tokens to the publisher, not the extension, and npm uses the same package for both production and nightly. Nightly was assumed to be a lower-tier credential, but the registries' credential model didn't support that distinction.

The disclosure timeline is worth recording too. Khan reported it via a GitHub private vulnerability report and email on January 1, 2026, followed by a follow-up email on January 8, a DM to the CEO on January 18, and a final attempt on February 7 — with no response through all of it. It was fixed in under an hour after disclosure on February 9 (PR 9211 — removed the AI workflow, stopped the nightly job's cache use). Cline officially confirmed and announced immediate mitigation on February 10, and on February 11 reported a full credential rotation and audit results — no unauthorized releases between December 21, 2025 and February 9, 2026, and all 41 npm versions matched their source.

But that audit window ends on February 9. On February 17, 2026, using a compromised npm publish token, cline@2.3.0 was published without authorization (GHSA-9ppg-jx86-fqw7). The only difference from the legitimate 2.2.3 version was one added postinstall script line in package.json, and that script globally installed one unrelated package. The malicious version was live for roughly 8 hours, from 3:26 AM to 11:30 AM Pacific time (SafeDep's analysis, February 18, 2026).

A few things worth flagging honestly here.

  • The installed package was legitimate and not malicious. It didn't steal credentials or plant a backdoor. SafeDep's assessment is that this is closer to a proof of concept — demonstrating feasibility rather than delivering a real payload. GHSA's severity rating is also Low.
  • Still, the same postinstall hook could have just as easily delivered a credential stealer or a reverse shell. The payload was benign, but the mechanism was proven.
  • The exact path of the token theft has not been publicly confirmed. SafeDep says so explicitly. Khan himself notes in his update that he ran his PoC against a mirror rather than the target repository, and that a different actor apparently found that PoC and attacked Cline directly to obtain the credentials.
  • GHSA gives no specific number for how many installs were affected. I won't invent one here.

The lesson is still clear, though. Rotation doesn't end the moment you believe the window is closed. An organization that audited through February 9 and confirmed it was clean saw a release go out on February 17. Cline subsequently revoked the tokens and moved npm publishing to OIDC provenance, removing the long-lived static token as an attack surface entirely. That's the real fix — not reissuing the token, but eliminating it.

Same Product, Different Door — You Don't Need Injection to Break In

To be fair, one more thing should be added. The same action also has a separately disclosed vulnerability that reaches the same outcome through a path that is not prompt injection. GHSA-8q5r-mmjf-575q / CVE-2026-47751 (disclosed May 20, 2026, medium severity, affects versions below 1.0.74, CWE-78 and CWE-200, reported by reptou via HackerOne).

The advisory describes a combination of three causes — checking out the PR's head branch (attacker-controlled content), reading the working directory's .mcp.json through the default config source, and having a setting enabled that unconditionally activates every project MCP server. As a result, an attacker who opened a PR containing a malicious .mcp.json could execute arbitrary code on the runner, and if a privileged user triggered the action on that PR, the workflow's secrets (API keys and tokens) could leak.

No model was tricked here at all. The agent's configuration itself was under attacker control. And the shape of the cause is identical to the earlier incident — three decisions that each look reasonable in isolation (checking out a PR to review it, reading a config file, turning on project MCP servers), where the flaw appears at the point where all three meet.

This is the third lesson. Focusing only on injection defenses misses this entire family. Untrusted repository content doesn't arrive only as a prompt — it also arrives as configuration, dependencies, and build scripts. Everything the agent reads is input, and the configuration file is the most powerful input among them.

The Defenses Anthropic Actually Shipped

RyotaK rates Anthropic's response as very fast. The timeline runs like this — the permission bypass reported January 12, 2026, fixed on January 16 (four days), the misconfiguration reported January 17, several rounds of additional mitigation and follow-up bypasses from February through April, disclosure on June 1. The misconfiguration issue was cleaned up in Claude Code GitHub Actions v1.0.94. Anthropic rated these at 7.8 on CVSS v4.0 and paid a bounty (3,800plusa3,800 plus a 1,000 bypass bonus). The researcher's post gives no CVE or GHSA number for this issue, and I could not find one either — the CVE-2026-47751 mentioned later is a different flaw in the same action.

Sorting the shipped defenses by character:

Fixes that moved a real boundary. The GitHub App was blocked from triggering workflows by default (the fix commit adds a checkHumanActor call to the prepare step of agent mode). And issues or comments edited after triggering are no longer processed, cutting off the workflow-composition attack. Both of these are fixes that moved the trust boundary itself.

Closures that shut a channel. The workflow run's summary section was disabled by default, removing one public exfiltration route.

Slowdowns. Everything else falls here. Environment variables are scrubbed from child processes spawned by Claude Code, and a custom wrapper around the gh command validates arguments. Why that wrapper was necessary is the most valuable detail in this whole story — gh issue view is, by any reasonable read, a read-only command for reading an issue. But the gh CLI accepts a URL as a positional argument. So if an injection makes it send a secret value as part of that URL, a "read-only" command becomes an exfiltration channel as-is. The wrapper blocks this by checking that the issue number is a single number.

This is the second lesson. A tool's exfiltration capability comes from its argument surface, not its name. A tool that can accept an arbitrary URL or an arbitrary path is a means of external communication no matter how read/list/view-ish its name sounds. Writing allow lists at the level of tool names misses this.

The Honest Limits — What the Vendor's Own Documentation Admits

This is where the real reason for writing this post begins. Vendor documentation usually oversells its defenses, but claude-code-action's security documentation doesn't. So there's something worth quoting.

On environment-variable scrubbing, the documentation states this — it's a best-effort scrub, PID namespace isolation is added on Linux runners where bubblewrap is available, but "this reduces but does not eliminate prompt injection risk." So it recommends keeping workflow permissions minimal and validating all output.

On static tokens, it's even more specific — don't use personal access tokens; static tokens don't rotate between runs, so they "can be partially or fully recovered over time through prompt injection." And the sentence that follows is the sharpest part — restricting allowed tools "reduces the speed of recovery but may not eliminate the risk."

This phrasing shouldn't be skimmed past. It doesn't say the defense stops exfiltration — it says it slows the rate of exfiltration. In other words, the model here is not a binary boundary but a leak rate. If a token lives long enough and an attacker tries enough times, eventually it all leaks out. That's why the prescription isn't "protect the token" but "use short-lived tokens."

Content sanitization is the same story. The action strips HTML comments, invisible characters, markdown image alt text, hidden HTML attributes, and HTML entities. And the documentation immediately adds — "new bypass techniques may emerge." So it recommends reviewing raw input from external contributors before Claude processes it.

Residual risk is documented for bot blocking too. Bots allowed via allowed_bots do not have their repository permissions checked. On a public repository, an external entity — including any GitHub App anyone made — can trigger events like opening an issue, commenting, or reviewing a PR, and if a workflow is listening for those events with allowed_bots set to *, that app can invoke the action with a prompt it controls. In other words, the original vulnerability is closed by default, but reopened by a single option.

And the researcher's own footnote sums all of this up in one sentence. Claude Code mitigates simple reads of the environment-variable pseudo-file, but that mitigation is "a defense-in-depth measure that raises the bar, not a security boundary, and more sophisticated exfiltration techniques remain possible."

There's a measured limit here too. RyotaK states that, as of writing, he had reported roughly 50 vulnerabilities that bypass Claude Code's permission system to execute arbitrary commands to Anthropic. His conclusion is not diplomatic — the permission model around Claude Code is not robust enough to safely handle untrusted input. So he recommends not using allowed_non_write_users at all, no matter how narrow the permission.

This number needs to be read correctly. 50 doesn't mean "Claude Code is unusually sloppy." It means one researcher, alone, found that many in a product that is actively researched and quickly patched. A permission system layered on top of a command parser only holds if the parser is perfect, and shell command parsing is the kind of problem that never becomes perfect. Willison's line still holds — we still don't know how to block this with 100% reliability.

So What Should Agent Builders Actually Do

Stripped down to the design principles that actually carry over from this incident:

1. Judge trust by verified properties, not by the shape of the actor. String suffixes, username patterns, header values — an attacker gets to pick all of these. A check that asks the issuer, like checkHumanActor, is the real thing. And that check needs to be present on every trigger path — a defense that exists on only one path is no defense at all.

2. Don't hand secrets to a workflow that processes untrusted input. This is the surest move. Assume you can't stop the injection, and instead build a state where there's nothing left to leak. This is where the point about even an Anthropic API key being sensitive comes from.

3. Build tool allow lists around the argument surface, not the name. The fact that gh issue view can become an exfiltration channel proves this principle. A tool with an argument that accepts an arbitrary URL, arbitrary path, or arbitrary command is a means of external communication. What Khan recommends for Cline is the same — don't give Bash, Write, and Edit to a triage workflow.

4. Review workflows by composition, not individually. If a loose workflow and a strict workflow live in the same repository, an attacker will chain them together. Caches, issue state, artifacts — every piece of state shared between workflows is a trust boundary. One of Khan's recommendations nails this exactly — don't consume a cache in a workflow that holds production deployment secrets. Integrity matters more than saving a few minutes on a release build.

5. Verify whether your non-production credential is actually a production credential. This was the crux of the Cline incident. If a registry binds tokens to the publisher, a nightly token is a production token. Either separate the namespaces, or drop static tokens altogether and move to OIDC provenance.

6. Model your defenses as a leak rate. Taking the vendor documentation's phrase "reduces the speed of recovery" seriously changes the design. Defense in depth raises the cost of an attack; it doesn't make the attack impossible. So the matching countermeasure is shortening lifetimes and shrinking blast radius — short-lived tokens, narrowly scoped permissions, and logging. This is the same reason RyotaK recommends readers check workflow run logs for signs of compromise.

Closing

What makes this incident notable isn't a new attack technique. If anything, everything here is old — a flawed trust check, in-band signaling, blocklists losing, misunderstood credential scope, workflow composition. What's new is that the thread stitching these together is natural language. The moment an issue body becomes a command, everyone who can open an issue becomes a potential holder of CI execution rights.

Anthropic's response was fast and conscientious (a four-day fix, documented residual risk, a paid bounty). Cline's response was slow, and that delay led to an actual unauthorized release. What the two contrasts tell you is about the importance of process, not about which side "solved" injection. Nobody has solved it. The vendor's own documentation says so, the researcher proved it with 50 findings, and a real breach happened in the meantime.

So if you're attaching an agent to CI right now, the better move is to change the question. Not "how do I stop injection" but "assuming injection succeeds, what can leak from this runner." The second question has an answer. Nobody, as of July 2026, can answer the first.

References