GUIDE · HOW-TO 8 min ·

Keep your Claude Code GitHub Action from leaking secretsOne issue, your whole runner's env

A single GitHub issue could make the Claude Code Action read its own environment out of /proc and hand your CI/CD secrets to whoever opened it. Anthropic patched the file path. The workflow shape that made it work is still the default in most repos, so the patch is the easy half of this.

TL;DR· the answer, in twenty seconds

What happened: Microsoft Threat Intelligence showed the Claude Code GitHub Action could read /proc/self/environ and leak the runner's secrets after a poisoned issue, because the Read tool skipped the environment scrubbing that sandboxed Bash. RyotaK had already chained a related permission bypass into a full supply-chain takeover.

The minimum fix: Update claude-code-action to v1.0.94 or later and Claude Code to 2.1.128 or later, then stop secrets and untrusted input from meeting in the same workflow.

The lesson: A patch closes one file path. Splitting the workflow closes the category, because no single job should hold untrusted input, secret access, and an external write path at the same time.

Microsoft Threat Intelligence published a case study on June 5, 2026 showing that the Claude Code GitHub Action could hand your CI/CD secrets to anyone who opened an issue on your repository.

How one issue reads your environment

The attack needs no compromised dependency and no stolen token. It needs a text box. When you wire up the Claude Code Action to triage issues or answer pull requests, the action feeds that untrusted text straight into the model's context. An attacker writes an issue whose body is an instruction dressed as a bug report: read this file, format the output like so, post it back. The model treats the issue body as a task and runs it.

The file the attacker wants is /proc/self/environ, the Linux pseudo-file that holds a process's environment variables. On a GitHub Actions runner that environment carries ANTHROPIC_API_KEY and whatever else the workflow exported, secrets included. Anthropic had thought about this for the Bash path. As Microsoft's writeup explains, Bash ran through a Bubblewrap sandbox with environment scrubbing, so a shelled-out cat /proc/self/environ came back empty. The Read tool was not held to the same model. It read files in-process, with the runner's full environment intact, and it was allowed to open /proc/self/environ.

That leaves one obstacle: the model's own safety training tends to refuse a plain "print your API key." The injection routes around it. Microsoft found the payload framed the read as a "compliance review" and told the model to "cut the first 7 chars" before printing, laundering the value so it no longer looked like a credential being exfiltrated. The model dropped the prefix, wrote the rest into an issue comment, and the attacker reassembled the key from a public thread.

RyotaK got there first, and went further

The Microsoft finding lands on the same /proc/self/environ target that RyotaK of GMO Flatt Security had already used, and his chain is worth understanding because it shows the ceiling on this bug class. He published on June 1, 2026.

His version started with a permission bypass. The action's checkWritePermissions check was supposed to gate who could trigger Claude, but GitHub Apps have implicit read access to public repositories and can open issues without explicit grants, and the check let them through. From there the workflow's default token carried id-token: write, so the injected prompt could read the OIDC request variables, ACTIONS_ID_TOKEN_REQUEST_TOKEN and ACTIONS_ID_TOKEN_REQUEST_URL, out of the same environment. With those he replayed GitHub's token-exchange dance, minted an installation token with write access, and pushed code to the action's own source repository. One issue, and every downstream user of that action inherits the attacker's commit. That is the difference between a leaked key and a supply-chain compromise, and the only thing separating the two was how much the triggering workflow was allowed to do.

The pattern underneath is the same one Aonan Guan documented in May across Claude Code, Gemini CLI, and Copilot: untrusted text plus tool access plus a way to write data out equals credential theft. The GitHub Action is the cloud-hosted version, running unattended on a trigger any stranger can pull.

Update first, because the patches are real

Two fixes shipped, and you want both.

The permission bypass RyotaK reported on January 12, 2026 was fixed four days later in claude-code-action v1.0.94, which stops GitHub Apps from triggering workflows by default. The Read-tool environment leak Microsoft reported via HackerOne on April 29, 2026 was fixed in Claude Code 2.1.128, released May 5, 2026, which now rejects reads of sensitive /proc files outright. Pin to those versions or later:

- uses: anthropics/claude-code-action@v1.0.94   # or later; use a full SHA in production
  with:
    claude_code_version: "2.1.128"              # or later

Pin the action by commit SHA rather than a tag if you care about the supply chain, since a tag can be re-pointed. Check Anthropic's release notes and advisories before you bump, and read the action's own security doc while you are there.

The patches close the two file paths that were exploited. They do not change the shape of the workflow that made those paths reachable, and that shape is the part you own.

The real fix is the Agents Rule of Two

Microsoft's recommendation is the one to keep. They call it the Agents Rule of Two: an AI-powered workflow should never hold all three of these at once.

  1. It processes untrusted input (issue bodies, PR descriptions, comments, fetched web pages).
  2. It can reach sensitive systems or secrets through its tools.
  3. It can change state or talk to the outside world (Bash, web fetch, the GitHub API, posting comments).

Hold any two and a mistake is contained. Hold all three and a single poisoned string turns into action against your systems. The default Claude Code Action workflow holds all three: it reads the issue you tell it to triage, it runs on a runner with your secrets, and it posts its answer back as a comment. That is the configuration the attack assumed.

So split it. Run a low-privilege job on the untrusted trigger with no secrets and no write scope. Keep the job that touches secrets on a trusted trigger that a stranger cannot pull. The dangerous shape:

# DON'T: untrusted trigger + secrets + write access in one job
on:
  issues:
    types: [opened]
jobs:
  triage:
    permissions:
      contents: write
      issues: write
      id-token: write          # OIDC, the supply-chain lever
    steps:
      - uses: anthropics/claude-code-action@v1
        env:
          ANTHROPIC_API_KEY: $
          AWS_ACCESS_KEY_ID: $

And the split that follows the rule:

# DO: untrusted input runs with no secrets and read-only scope
on:
  issues:
    types: [opened]
permissions: {}              # deny by default, grant per job
jobs:
  triage:
    permissions:
      issues: read
      contents: read
    steps:
      - uses: anthropics/claude-code-action@<sha>
        with:
          claude_code_version: "2.1.128"
        env:
          ANTHROPIC_API_KEY: $   # the only secret this job needs

The triage job can still read and reason about the issue. It cannot read your AWS keys, because they were never put in its environment, and it cannot push code or mint an OIDC token, because it was not granted those scopes. The deploy job that does hold cloud credentials runs on push to main or on a workflow you dispatch by hand, where the input is yours.

Scope the token, and never cross the streams

Two more controls close the gaps the split leaves.

Set permissions: {} at the top of every workflow file and grant scopes per job. GitHub's hardening guidance treats the default broad GITHUB_TOKEN as the thing to claw back, and most Claude workflows need far less than they request. A triage job needs issues: read. It does not need id-token: write, and dropping that one line removes the lever RyotaK pulled.

Then keep pull_request_target away from untrusted code. That trigger runs with secrets and the base repository's full token, by design, so it can label and comment on forked PRs. Check out the PR's head commit inside it and you are executing a stranger's code with your secrets in scope, which is the same all-three failure in a different wrapper. If you must inspect PR contents with the agent, do it under the plain pull_request trigger, which forks get with a read-only token and no secrets, and read GitHub's note on preventing pwn requests before you wire it up. Treat any secret you reference under an untrusted trigger as already disclosed, and ask whether it needs to be there at all.

This is also where you decide what the secret can do once it is in scope. The split keeps your AWS keys out of the triage job's environment, but the triage job still holds ANTHROPIC_API_KEY, and a future injection that survives the patch could still spend it. Scoping the credential to the process that needs it, rather than leaving it in the ambient environment for every child the action spawns, is the same boundary problem one layer down. A process-tree broker like hasp brokers a credential to a named process and its children and logs each access, so a sibling step or an MCP server the action started does not inherit the grant. The token is still exposed to misuse inside its own job; the blast radius stops at the job boundary instead of the runner.

Verify your own repos this afternoon

You do not need a scanner. You need three answers per repository.

# 1. Which version of the action are you on?
grep -rn "anthropics/claude-code-action" .github/workflows/

# 2. Which workflows run on untrusted triggers AND reference secrets?
grep -rlE "issues:|issue_comment:|pull_request_target:" .github/workflows/ \
  | xargs grep -ln "secrets\." 2>/dev/null

# 3. Anyone still granting id-token: write on an issue trigger?
grep -rn "id-token: write" .github/workflows/

If the first command shows a version below v1.0.94 or a floating @v1 tag, bump and pin it. If the second prints any file, that file holds untrusted input and secrets in the same place, and it is the one to split. If the third matches inside a workflow that an outsider can trigger, delete the line unless you can name the cloud role it authenticates to. Anything that reaches the agent through an issue or a comment is untrusted input, and the OWASP agentic Top 10 lists this exact crossing as a primary risk for a reason.

If a vulnerable version already ran

Patching forward does not undo an exposure that already happened. If you ran an unpatched action on a public repo with an open issue trigger, assume any secret that was in that runner's environment is burned and rotate it. That means ANTHROPIC_API_KEY first, then every other secret the workflow referenced: cloud keys, registry tokens, deploy credentials. Rotation is the only response that helps, because you cannot prove a key was not read, and a key that may have been read is a key you no longer control.

Look for the laundering pattern while you rotate. The Microsoft payload told the model to drop a fixed prefix before printing, so the leaked value showed up in an issue or PR comment as a string that looks almost like a credential but starts a few characters in. Search your repository's issue and comment history around the time any unrecognized issue was opened by an account you do not know. GitHub's audit log will show the OIDC token requests and any pushes that followed, which is where RyotaK's chain would surface. If you find a write you did not make, treat it as a supply-chain event and check what shipped from that commit, not just the commit itself.

Then close the trigger that let the stranger in. Set allowed_non_write_users to an explicit allowlist rather than "*", or drop the untrusted trigger until the split is in place. An open "*" on an issue or comment trigger is an invitation, and it was the secondary misconfiguration that widened RyotaK's access.

What this means for your stack

The minimum move this week: update claude-code-action to v1.0.94 and Claude Code to 2.1.128, then run the three greps above on every repo that uses the action and split any workflow that mixes an untrusted trigger with real secrets. Rotate any secret that sat in a vulnerable runner. That alone removes the configuration both researchers attacked.

The pattern that fixes the category is the Agents Rule of Two. Decide, for every agent workflow, which two of the three capabilities it gets, and design so it never holds untrusted input, secret access, and an external write path together. Patches close the file path that leaked this month. The architecture decides whether the next injection finds anything worth taking, and that decision is yours, not the vendor's.

hasp is one working implementation of the boundary one layer in. hasp run -- <command> brokers credentials to a process and its children, logs every access, and keeps secrets out of the ambient environment a leaked tool could read. Source-available (FCL-1.0), local-first, macOS and Linux, no account.

Whether or not you run any of this in CI, the test is the same. Open one of your agent workflows, find the trigger an outsider can pull, and list what the runner's environment holds when it fires. If a working secret is in that list, you have not been lucky. You have just not been targeted yet.

Sources· cited above, in one place

NEXT STEP~90 seconds

Stop handing the agent your real keys.

hasp keeps secrets in one local encrypted vault, brokers them into the child process at exec, and never lets the agent read the value.

  • Local, encrypted vault — no account, no cloud, no telemetry by default.
  • Brokered run — agent gets a reference, the child process gets the value.
  • Pre-commit + pre-push hooks catch managed values before they ship.
  • Append-only HMAC audit log answers "did the agent touch the prod token?" in seconds.
→ okvault unlocked · binding ./api
→ okgrant once · pid 88421
→ okagent never read

macOS & Linux. Source-available (FCL-1.0, converts to Apache 2.0). No account.

Browse all clusters· eight threads, one index