One Layer Said the Value Was Safe. The Next Layer Ran It. Claude Code, Gemini CLI and Codex All Failed the Same Way.
- Patrick Duggan
- 3 hours ago
- 9 min read
Novee Security presented four sessions at Black Hat USA and DEF CON last week. Three of them were the same finding in three different products: Anthropic's Claude Code, Google's Gemini CLI, and OpenAI's Codex. Elad Meged and colleagues showed that a stranger with no repository privileges could open a public GitHub issue and take over the automation that was supposed to be maintaining the repository.
Two CVEs came out of it. CVE-2026-54316 in Claude Code, CVSS 9.1 at NVD, every release from 0.2.54 through 2.1.162, fixed in 2.1.163. CVE-2026-12537 in Gemini CLI, CVSS 10.0, fixed in 0.39.1 and run-gemini-cli 0.1.22. The Codex finding got no CVE because OpenAI fixed their own repository three days after disclosure, which fixes their repository and nobody else's.
We are going to be careful about one thing throughout this piece. Claude is the model we build on and work with every day. That is exactly why we are not going to soften what happened here, and it is why the comparison runs in both directions: Claude Code's bug was real and had a nine-plus CVSS, and Gemini CLI's was worse.
The bug is not in the model
The reflex when an AI agent gets exploited is to talk about the model — that it was tricked, that it believed the attacker, that alignment failed.
That is not what happened in any of these three.
In all three, the model behaved the way a model behaves. The failure was in the harness: the ordinary, unglamorous code wrapped around the model that decides what actually runs. And in all three, the failure had the same shape. One layer inspected a value and marked it safe. A different layer, later, executed it — using different parsing rules, or a different process, or a different trust assumption. Nothing was bypassed. Two components simply disagreed about what a string meant, and the gap between them was the vulnerability.
That shape is not new and it is not specific to AI. It is the same defect class as a WAF that normalises a URL differently from the application behind it, or a filesystem check that runs against a path the kernel later resolves somewhere else. What is new is where it now sits: in front of a process holding your repository write token.
Claude Code: the validator read a different string than git did
Claude Code's command validation pipeline strips single-quoted content before running its twenty-three security checks. The reasoning is defensible in isolation — quoted material is data, and you do not want a quoted word tripping a check meant to catch a command.
So an attacker puts the payload in the quotes, as a flag value on an otherwise boring git command. The preprocessor removes everything between the quotes. The validators receive git push --receive-pack= — an empty flag on a routine push — and approve it twenty-three times.
Then git runs. Git does not strip quotes; git treats --receive-pack as "the command to run on the other end," which is its documented purpose. The value the validators never saw is the value git executes.
Both halves are correct in isolation. The preprocessor correctly removed quoted data. Git correctly executed its flag. The bug lives in the space between them, and it is invisible to anyone reading either component on its own.
Novee reports three separate rounds of patch-and-bypass before the final CVE — which tells you the first two fixes were also written by people reading one component at a time.
Gemini CLI: the annotation nobody enforced, and /proc
Gemini's is worse, and Google's own response says so. They shipped a breaking change to the headless execution trust model rather than a patch, which is what you do when the flaw is the architecture rather than a line of code.
Two failures stacked. First, tool registration checks that a tool name starts with run_shell_command( and never parses the restriction annotation that follows — so a tool declared as restricted to echo was, at runtime, not restricted to anything. The runtime check in shell.ts verified that the command was non-empty and the paths were valid. It never checked the restriction, because the restriction was never parsed into anything checkable. It was a comment that looked like a control.
Second, and this is the one worth stealing as a general lesson: the child process environment was sanitised. Secrets were stripped before spawning. But the parent process kept the full environment, and parent and child shared a PID namespace — so the child reads /proc/$PPID/environ and gets GITHUB_TOKEN, GEMINI_API_KEY and the OIDC credentials right back.
Sanitising what you hand a child process is not isolation if the child can still read the parent. That is a containment claim, not a containment mechanism, and the difference only shows up when somebody tests it.
The chain ends with the attacker holding a write-capable GITHUB_TOKEN, dispatching a second workflow, and pushing to main on a project with roughly two million monthly downstream installs.
Codex: the file that was trusted because it was on disk
OpenAI ran two Codex passes in one job, sharing one checkout and one writable workspace. They protected .git/, .agents/ and .codex/ as read-only.
They did not protect AGENTS.md — the default instruction file loaded on every invocation.
Pass one reads the attacker's issue body. The injected instruction tells pass one to write a poisoned AGENTS.md. Pass one's output then fails schema validation, and failing validation is precisely what triggers pass two. Pass two starts, loads AGENTS.md as authoritative instructions, and now the attacker is writing the agent's standing orders.
Note the mechanism: the validation failure was the trigger. The safety check did not stop the attack, it advanced it to the stage where the attacker already had control. And the trust decision — "instructions on disk are ours" — was made at a point in time when it was true, and consumed at a point in time when it was not.
The part with no defence: a download counter as an exfiltration channel
Round three of the Claude Code work is the piece that should change how you think about egress.
The attacker creates sixty-four Hugging Face model repositories, named char-a through char-9, plus char-dash and char-underscore. The compromised agent reads one character of a secret and fetches attacker/char-<value>/resolve/main/config.json.
Hugging Face publishes download counts per repository. The attacker polls their own sixty-four repositories and watches which counter went up by one. That is the character. Repeat across roughly forty positions and you have the API key.
Now count what the defender had available to catch this.
There is no attacker domain, so there is nothing to add to a blocklist. There is no C2, so there is no beacon interval to spot. Every request is a read-only GET to huggingface.co — a host that is allowlisted in essentially every machine-learning pipeline on the planet, and would be the last thing an egress policy blocked. The data never travels in a request body, a header, a DNS label, or a hostname. It travels as which legitimate URL was chosen. The secret is encoded in the selection, and the channel is the vendor's own public analytics.
We have written before about dead-drop resolvers, where the indicator is an address on a public blockchain rather than a server anyone controls. This is the same idea one step further along. The dead drop still had a value you could extract and block. A download counter does not even have that. There is nothing to write down.
This is the subtraction pattern we keep running into: the attack wins by removing the artefact your defence assumes will exist. No malicious domain. No suspicious payload. No anomalous destination. Your network telemetry is not wrong about this traffic — a machine-learning tool fetched some config files from Hugging Face, which is true, and normal, and complete.
The only place this is visible is at the agent: the fact that something read a credential character by character and made forty sequential fetches to structurally identical URLs. That is a behavioural signature at the harness layer, and it is the layer nobody is instrumenting.
What made all three reachable: the trigger cost nothing
Every one of these starts the same way. An anonymous person opens an issue on a public repository. No fork, no pull request, no privilege, no review, no approval gate. On Gemini CLI the workflow fires automatically. On Claude Code, tagging @claude runs it.
That is the actual exposure, and it is a configuration you may have without having decided to have it. If a workflow in your repository runs on issue_comment or pull_request_target, and it hands untrusted text to an agent, then the trust boundary of your CI is "anyone with a GitHub account." Those triggers exist specifically to run with repository secrets available, which is what makes them useful and what makes them this.
We checked ourselves, and the answer is honest but not flattering
We run Claude Code every day. So the first question was our own exposure, not anyone else's.
Our version is 2.1.227. The fix landed in 2.1.163, so we are past it — but we are past it because we take updates, not because we knew. Anyone still pinned below 2.1.163 is inside the affected range, and the affected range goes back to 0.2.54, which is nearly the whole product history.
We then checked our thirty-two GitHub workflows for the trigger pattern. Zero use issue_comment. Zero use pull_request_target. Our one Claude-invoking workflow is workflow_dispatch only — manual — and has been commented out since December 6, 2025.
We would like to tell you that was a security decision. The comment in the file says: DISABLED Dec 6, 2025 - Cavan Cost Optimization. REASON: Burns credits daily.
We turned it off because it was expensive. The repository is also private, so no stranger can open an issue on it in the first place — also not a decision made with this in mind. Two things stood between us and this class of attack and neither of them was put there on purpose. That is worth saying out loud, because the same is probably true of some of your controls, and you will not know which ones until somebody tests them.
What to do this week
Patch first, and note that patching is the easy half. Claude Code to 2.1.163 or later. Gemini CLI to 0.39.1 and run-gemini-cli to 0.1.22. If you run the two-pass Codex workflow pattern in your own repository, OpenAI's fix was to split the passes into separate jobs with independent checkouts — and nobody is going to notify you, because there is no CVE and no advisory feed pointing at your copy.
Then do the part that survives the next one of these:
Inventory your agent triggers. Grep your workflows for issue_comment and pull_request_target. Any hit that reaches an AI agent means an anonymous stranger can start a job that holds your secrets. Gate it on a repository-membership check, or on a maintainer label, or move it behind workflow_dispatch.
Scope the token, because you will not catch the injection. Every one of these ended with a write-capable GITHUB_TOKEN. Prompt injection is not a solved problem and will not be solved this quarter. A token that cannot push to main turns repository takeover into a bad afternoon.
Stop treating an allowlisted host as a safe destination. The Hugging Face channel works because huggingface.co is trusted as a hostname. Egress policy built on hostnames cannot see a secret encoded in URL selection. If you want a chance at this class, the telemetry has to come from the agent — what it read, what it fetched, in what order — not from the network.
Assume the restriction you declared is not enforced. Gemini's shell restriction was parsed by no one. Test your guardrails by trying to break them, not by reading the config that says they exist. A control that has never been attacked is a claim.
The industry spent two years asking whether the model can be trusted. These three findings say the model was not the interesting part. The interesting part is the hundred lines of glue around it that decide what runs — written fast, reviewed lightly, and now sitting in front of the credentials to everything you ship.
Credit where it belongs: this research is Novee Security's — Elad Meged and colleagues, presented at Black Hat USA 2026. We did not find these bugs. We read the work, checked our own estate against it, and reported what we found. Vendor timelines and CVSS figures are as published by NVD, Anthropic, and Google, and the three vendors' ratings do not agree with each other; we have cited the NVD figures where they differ.
Every indicator in this post is in the feed. Free.
1.58M+ IOCs, STIX 2.1 / TAXII, 88% novel vs ThreatFox, exploited-CVE leads ahead of CISA. No credit card — a free API key in 30 seconds, and you can audit every claim above against the live endpoints.
Was this useful? Thirty seconds, no cookies, no tracking, no third parties, your address hashed and never stored. If the box below does not load, the same question lives at https://analytics.dugganusa.com/nps.html?post=harness-not-the-model-three-agents-one-github-issue




Comments