August 8, 2026
Your Agent’s Command Allowlist Is a Parser, and It Disagrees With Your Shell
Six disclosed findings across Anthropic, Google, and OpenAI CI integrations share one root cause. An explanation of what it is, why each…

By Krishna
8 min read
- 1 Six disclosed findings across Anthropic, Google, and OpenAI CI integrations share one root cause. An explanation of what it is, why each patch was followed by another, and what replaces the pattern. All findings disclosed and patched; no exploit code here.
- 2 git push runs whatever you name
- 3 The same shape, six times
- 4 Neither parser was broken
- 5 This class already had a name
Six disclosed findings across Anthropic, Google, and OpenAI CI integrations share one root cause. An explanation of what it is, why each patch was followed by another, and what replaces the pattern. All findings disclosed and patched; no exploit code here.
raw : --receive-pack='sh -c "id"'
validator sees : '--receive-pack='
metachars found: Falseraw : --receive-pack='sh -c "id"'
validator sees : '--receive-pack='
metachars found: FalseIt removes single-quoted spans first, on the reasoning that quoted text can't act as a shell metacharacter. Then it scans what's left for ;, &, |, $, and backticks. It finds none, sees an allowlisted verb, and approves.
The quoted text still reaches git. And git runs it.
That check belonged to a coding agent's GitHub Action, and variants of the same mistake shipped at all three of Anthropic, Google, and OpenAI. The runner where it lands holds GITHUB_TOKEN, a vendor API key, and the OIDC request token that exchanges for more. Nothing fails, either — the push succeeds and the workflow goes green.
There are almost six findings were disclosed against those three vendors in 2026 by two independent research groups. Each was patched. Each patch was followed by another finding. This is about what all six share, and why fixing them one at a time was never going to converge.
Scope: coding agents running in CI with credentialed runners. Everything here is disclosed and patched, and the mechanisms go no further than what the vendors and researchers already published.
git push runs whatever you name
The flag explains the first finding, and it's the part most engineers have never had reason to learn.
From git's own documentation for git push:
--receive-pack=<git-receive-pack>
--exec=<git-receive-pack>
Path to the git-receive-pack program on the remote end.
The flag names a program. That's its entire purpose — you point git at a binary when it isn't on the default $PATH.
I ran this on git 2.55.0 over a local transport, with a benign marker instead of a payload:
$ git push --receive-pack="sh -c \"echo SHELL_REACHED > marker.txt\"; git-receive-pack" origin master
To ../remote.git
* [new branch] master -> master
$ cat marker.txt
SHELL_REACHED$ git push --receive-pack="sh -c \"echo SHELL_REACHED > marker.txt\"; git-receive-pack" origin master
To ../remote.git
* [new branch] master -> master
$ cat marker.txt
SHELL_REACHEDRead the middle line again. The push succeeded. Appending ; git-receive-pack keeps the real transfer working, so the branch updates, the step exits zero, and CI shows a green check. Absence of failure is not absence of execution.
None of this is a git vulnerability. It's documented, intended behaviour that has worked this way for years. The defect belongs to every validator that put git push on an allowlist without reading git's flag grammar. git fetch and git clone carry the equivalent --upload-pack.
Which means an allowlist doesn't grant you a verb. It grants you every argument that verb accepts.
The two lanes stop agreeing at the first transformation. The ALLOW was decided on the left; the execution happened on the right.
Two components handle the same string. The validator decides whether it's safe. The executor runs it. Each one parses the string to do its job, and they don't parse it the same way.
The validator's parse removes quoted spans, then looks for metacharacters. The executor's parse is the shell's: word-splitting, quote removal, then git's own flag handling on the result. Same bytes, two grammars.
A security decision is only sound if the two parses agree on every property the policy depends on. The policy here depends on "does this string cause execution." The validator answers no. The executor answers yes.
That mismatch has a general form, and once you have it, the other five findings stop looking like five bugs.
The same shape, six times
Take the config line most people running Gemini CLI have written some version of:
// declared: coreTools: ["run_shell_command(echo)"]
// enforced: toolName.startsWith("run_shell_command(")// declared: coreTools: ["run_shell_command(echo)"]
// enforced: toolName.startsWith("run_shell_command(")The parenthesized restriction was never parsed. Registration matched the prefix and registered the full, unrestricted ShellTool; runtime checked only that the command was non-empty and the paths were valid, never re-checking against the declared list. Under --yolo, fine-grained allowlists were bypassed entirely.
Two readings of run_shell_command(echo). The human's: "shell tool, restricted to echo." The code's: "a string starting with run_shell_command(." Both are internally consistent. Only one of them was enforced, and it wasn't the one in the docs.
[Embedded content: fd8cc0375275ac6c1bf3df74bece1850]
The last three rows widen the pattern past lexical parsing, and that widening is the point rather than a loose edge.
Gemini's environment case is a boundary disagreement. The child process env was sanitized before spawn; the parent's wasn't, and both ran under the same UID and PID namespace. /proc/$PPID/environ returns the parent's full environment, GITHUB_TOKEN included. The code named a trust boundary. The kernel was never told about it.
Codex disagrees about which bytes count as instructions. It loads AGENTS.md from disk as instructions on every run. Two passes shared one workspace, and AGENTS.md sat in the writable part of it, so the first pass could write what the second pass obeyed.
And the [bot] check treats a naming convention as proof of privilege. GitHub Apps have implicit read access to public repos, so an attacker installs their own App and files an issue whose actor name ends in [bot].
Neither parser was broken
This is the uncomfortable part, and it's why the patches kept not being the last one.
Git parses its flags correctly. The shell performs quote removal correctly. startsWith returns exactly what startsWith is documented to return. /proc exposes what /proc is designed to expose. Take any of these components in isolation, review it on its own terms, and you will find nothing wrong.
The defect doesn't live in a component. It lives in the pair.
That's an awkward place for a defect to live. It isn't visible in either codebase during review, and it doesn't show up in either component's test suite. Fixing whichever component you decide was at fault doesn't remove it either. Each vendor fix in that table addressed a specific string — a specific flag, a specific guard, a specific hostname. The class is not string-specific. Add a pattern and you've closed one differential in a space you haven't enumerated.
Which is exactly what a decade of prior work on this would have predicted.
This class already had a name
Parser differentials aren't new and they aren't about AI. Security researchers have been documenting them for years in URL parsing, where a validator and a fetcher extract different hosts from the same URL and defeat an SSRF allowlist. The same shape appears in MIME type validation against browser sniffing. In HTTP request smuggling, a front end and a back end disagree about where one request ends and the next begins.
They're still landing. CVE-2026–52747, published 2026–07–10, is a parser differential in ModSecurity that lets attackers bypass WAF rules, rated CVSS 8.6.
The literature's most useful observation is the one from the previous section: the differential can exist when both parsers are individually correct and complete.
Coding agents didn't invent this defect. They became a new and unusually well-credentialed consumer of it — a component that takes attacker-influenced text and turns it into commands, running on a box that holds push tokens.
What replaces the allowlist
There are three answers, and the ordering between them matters more than any one of them.
One: remove the privilege. If the runner holds no repository write credential, code execution on it is contained to a box with nothing worth taking. The Cloud Security Alliance's guidance is to run agents in dedicated workflows holding no write credentials, and to require a human approval step through GitHub's environment: protection rules before a privileged workflow acts on agent output. Pair it with egress filtering so the runner can't reach attacker infrastructure. This is first because it's the only option whose correctness doesn't depend on a parser.
Two: validate structure, not text. If you must gate commands, parse once into an argv structure and hand that to the executor. Never pass a string to a second parser and call the first one's opinion a security decision. Deny unknown flags, not just unknown verbs — the git push case was a permitted verb the whole way through.
Three: put the boundary in the kernel. Separate UID, separate PID namespace, real isolation. The test is simple: if a control's guarantee can be falsified by reading /proc, it was never a guarantee.
The ordering exists because a sandbox doesn't rescue a bad parse, and the Gemini finding shows why. CVE-2026–12537 is host-level code execution before the sandbox starts — CVSS v4 10.0, network vector, no privileges, no user interaction. Headless mode auto-trusted workspace folders, so a .gemini/.env file arriving in an untrusted PR checkout was loaded as legitimate config and reached an OS command, all of it during startup.
A sandbox that initializes after config load protects nothing that config load can reach.
The rule underneath all three: never let one component decide the safety of a string that a different component will re-parse. Either the two parses are provably identical, or the decision belongs somewhere the parse can't reach.
Privilege removal loses two rows, and they're the rows teams care about. Auto-triage and auto-fix workflows exist to act without a human in the path; putting a human back in the path removes the reason the workflow was built. Teams running agents for volume will not accept it, and saying otherwise would be dishonest about the tradeoff.
Structured argv validation is the middle option, and it isn't a complete fix either. A permitted binary can still interpret a permitted argument as code — you've narrowed the space, not closed it.
Choose privilege removal if the agent's output is reviewed anyway, which covers most PR-authoring workflows. Choose structured validation if the agent must act unattended and you can enumerate its binaries. If you're keeping a string allowlist, know that you've accepted the union of every listed binary's argument grammar as your security perimeter.
What I could not confirm
The --receive-pack reproduction ran on git 2.55.0 over a local transport only. I didn't test ssh:// or https://, so whether the mechanism holds there is unknown.
Searching for separate CVE or GHSA identifiers for the receive-pack and arbitrary-file-read findings turned up nothing. NVD scopes CVE-2026–54316 narrowly to the WebFetch hostname issue, with CWE-183, CWE-200, and CWE-515 — no command-injection CWE. Several write-ups attribute all three Claude Code rounds to that one CVE, which is an easy compression of a talk that covered them together, but it doesn't match the advisory. Those rows are marked unidentified above rather than guessed at.
Aikido reported five Fortune 500 organizations with configurations consistent with the vulnerable pattern as of mid-2026. That's exposure, not compromise, and there's no public evidence any of these six was exploited against a third party.
Whether each vendor's fix is complete is not knowable from outside. Each addressed the reported instance.
Takeaways
- Audit what your allowlist's binaries can be told to do, not just which binaries are on it.
git pushbrings--receive-pack;git fetchandgit clonebring--upload-pack. You inherit the union of every listed binary's flag grammar. - Verify enforcement empirically instead of reading the config schema. A fine-grained tool restriction that is never parsed looks identical in the file to one that is — that's the entire Gemini finding.
- Enumerate what the runner holds before you harden the validator. Severity is a function of the credential list —
GITHUB_TOKENscope, vendor API key,ACTIONS_ID_TOKEN_REQUEST_TOKEN— not of the bug.
The compressed version: never let one component decide the safety of a string that a different component will re-parse.
What's on your agent's allowlist that you haven't checked the flag grammar of?
Sources
- NVD — CVE-2026–54316 (accessed 2026–08–07)
- GitHub Advisory — GHSA-jj69–4grx-fqj5 (CVE-2026–12537)
- GitHub Advisory — GHSA-wpqr-6v78-jr5g
- git-scm — git-push documentation
- Novee Security — Critical flaws in Anthropic, Google, and OpenAI's coding agents, presented at Black Hat USA 2026–08–05
- GMO Flatt Security (RyotaK) — Poisoning Claude Code: one GitHub issue to break the supply chain, 2026–06–01
- Cloud Security Alliance — Research note on Claude Code GitHub Action prompt injection
- Sonar — Security implications of URL parsing differentials
- Iterasec — Parser differential vulnerabilities explained
The git push and shell quote-removal behaviours were reproduced directly on git 2.55.0, local transport, 2026-08-08.
Thanks for reading.