Nine advisories landed against one coding agent's command-approval gate between March and October 2026, five of them on a single day, and not one of them is a bug in the allowlist's contents. They are all the same defect: the gate parses the command string, then hands the same string to bash, and the two do not agree about where the command ends. If your agent decides what to run by pattern-matching a shell string, you are maintaining a second shell — and you will lose.
At a glance
Roo Code was a VS Code coding agent with 24.3k GitHub stars. Its auto-approval feature let a developer pre-approve commands by prefix — echo, git status, npm test — so the agent could run them without a prompt. Every advisory below is against that one decision path.
| Published | CVE | Component named in the advisory | Severity |
|---|---|---|---|
| 2026-03-30 | CVE-2026-30307 | Command auto-approval module (OS command injection) | Critical |
| 2026-07-20 | CVE-2026-63108 | Auto-approve execute path (command injection) | High |
| 2026-09-08 | CVE-2026-82536, CVE-2026-82537 | Shell command auto-approve bypass | High |
| 2026-10-13 | CVE-2026-110202 … CVE-2026-110206 | getCommandDecision() and parseCommandLine() | High (CVSS v4.0 7.7) |
Two facts frame everything that follows. First, all nine say "through 3.54.0" and none lists a patched version. Second, the repository was archived by its owner on 15 May 2026 and is read-only; the README says "The Roo Code Extension was shut down on May 15th" and points users at ZooCode, a community fork, and at Cline, the project Roo Code was itself forked from. So "through 3.54.0" is not a version range waiting to be closed. It is the final state.
Two parsers, one string
The shape of the control is simple enough to hold in your head. The model proposes a string. The gate splits it into sub-commands, normalises each one, and checks the first word against an allowlist and a denylist. If every sub-command is allowed, the string is passed to a shell and run with the developer's own privileges — their SSH agent, their cloud credentials, their write access to the repository.
The gate's reading of the string is a guess about what the shell will do with it. The verified October findings are two different ways that guess was wrong, with two different CWEs, in two different files.
The redirect that moved the command
CVE-2026-110202 is in getCommandDecision() in src/core/auto-approval/commands.ts. Before matching, the function stripped redirections so that a benign 2>&1 would not confuse the prefix match. The line, at v3.54.0, reads:
// Remove simple PowerShell-like redirections (e.g. 2>&1) before checking const cmdWithoutRedirection = cmd.replace(/\d*>&\d*/, "").trim()
Both digit groups are \d*, so the pattern also matches a bare >&. In bash a bare >& is not a file-descriptor duplication; it redirects both stdout and stderr to the file named by the next word. Delete the operator and that filename slides into command position, where the allowlist happily matches it:
ALLOW = ["echo"] DENY = ["touch"] input gate says bash does --------------------------------- -------------- ---------------------------- touch /tmp/ROO_PWNED auto_deny (blocked) echo hi 2>&1 auto_approve echo hi >¬echo touch /tmp/ROO_PWNED ask_user (blocked) >&echo touch /tmp/ROO_PWNED auto_approve touch /tmp/ROO_PWNED
On the last line the gate strips >&, sees echo touch /tmp/ROO_PWNED, matches the echo prefix and approves. Bash reads the same string as "redirect both streams to a file called echo, then run touch." The file appears and the denied command runs. GitHub classifies this CWE-436, Interpretation Conflict, which is exactly the right name for it.
The comment that was not a comment
The second mechanism is one layer lower, in the tokeniser. parseCommand() in src/shared/parse-command.ts uses the shell-quote library to split the string. shell-quote treats a # as starting a comment even when it is glued to the end of a word, and returns the remainder as a single comment object. Roo Code's token loop handled operator tokens and string tokens, so an object token with no op property matched neither branch and was silently dropped:
parse("echo hi#;touch /tmp/ROO_PWNED")
→ ["echo", "hi", { comment: ";touch /tmp/ROO_PWNED" }]
gate sees: echo hi → matches ALLOW, approve
bash sees: echo hi# ; touch ... → '#' mid-word is literal, touch runs
Bash starts a comment only at the beginning of a word. In hi# the # is an ordinary character, the ; after it is a real separator, and the command behind it executes. The gate never saw it at all — not misjudged, not seen. The sibling CVE-2026-110205 is a third variant: parseCommandLine() discarded command substitutions placed next to glob characters, so echo *$(...) matched an allowlisted prefix. GitHub files that one as CWE-184, Incomplete List of Disallowed Inputs.
Seven disagreements, three layers
The researcher's public writeups for this cluster are named for the construct each one abuses: redirects, glob tokens, quote boundaries, comment boundaries, the pipe-both operator, function substitution, and output used as a command. Seven writeups, one module. That is the number worth staring at, because it tells you the defect is not located in any of the seven.
Three different layers were disagreeing at once, and each had its own notion of where a command begins:
- bash, the only reading that actually executes.
- the
shell-quotelibrary, a generic POSIX-ish tokeniser that was never a bash emulator and does not claim to be — its comment rule differs, and that difference is a bypass. - the gate's own regex layer, hand-written normalisation applied on top of the tokeniser's output, with its own bugs (the
\d*above) and nogflag, so even its intended behaviour stopped at the first match.
Patch any one of these and the other two still disagree. That is why the fixes the writeups propose are correct and insufficient at the same time: requiring \d+ in the redirect regex closes CVE-2026-110202 exactly, and leaves the shape of the control untouched. The next construct is a shell feature nobody on the project had thought about yet, and bash has a lot of them.
Note also what the CVSS v4.0 vectors say about reachability: AV:N/AC:L/AT:P/PR:N/UI:P — network attack vector, low complexity, no privileges, and passive user interaction. The developer does not click anything. They ask the agent to summarise an issue, read a README, or triage a dependency's changelog, and the text they asked it to read contains the command. That is ordinary indirect prompt injection; the allowlist was the entire control standing between it and a shell.
The patch that is not coming
The archive date matters more than it looks. A vulnerability in a maintained product is a scheduling problem. A vulnerability in a read-only repository with 24.3k stars is a permanent property of every machine still running the extension, and of every fork that started from that tree.
That second clause is the part that outlives the product. ZooCode began as a community fork; it inherits commands.ts and parse-command.ts as they stood, and the seven disagreements with it, unless somebody has re-derived each fix independently. Cline is upstream of Roo Code rather than downstream, so it does not inherit these specific regressions — but it ships the same category of feature, as does every coding agent with an auto-approve list. A bypass is a property of the approach, not of the brand. Anybody shipping prefix-matched shell approval should treat the seven writeups as a test suite, not as somebody else's incident. And if you are evaluating a coding agent, "does it have an allowlist" is the wrong question; what the allowlist is protecting is the right one.
Where to put the decision instead
The useful generalisation is that an approval gate must decide on the same object the system will act on. A shell string is not that object — it is a program in a language with its own parser, and anything you do to it short of running it is an approximation. Four moves, in rough order of how much they buy:
- Approve an argv vector, not a string. Have the model emit
["git", "status", "--short"]and execute it withspawnand no shell. There is now exactly one reading of the command, the gate's reading is the executed reading, and redirects, globs, substitutions, pipes and comments do not exist because no shell is involved. Operators the model genuinely needs become explicit tool parameters instead of syntax. - Make the sandbox, not the list, carry the risk. A command that escapes the allowlist inside a container with no credentials, a read-only mount of the repo and a default-deny egress policy is a nuisance. The same command on a developer laptop reaches an SSH agent and a cloud profile. Pre-approval is only as safe as the blast radius it sits in front of, and the laptop is the worst place to run it.
- Approve effects, not syntax. "May write under
./src, may not reach the network, may not touch~/.ssh" is enforceable by the kernel and carries no parsing risk. "Commands starting withecho" is enforceable only by a parser you wrote. - Log the string you executed, with the decision that let it through. Every one of these bypasses is visible after the fact in a log pairing the proposed command, the gate's verdict, and the exit status. Without that pairing you cannot tell a bypass from a normal approval, which is how a bypass goes unnoticed for seven months.
None of this requires giving up auto-approval. It requires accepting that the allowlist is a convenience feature for reducing prompts, not a security boundary — and putting a real boundary underneath it.
FAQ
Is this only a Roo Code problem?
No. The bugs are Roo Code's, but the design — match a shell string against a prefix list, then hand the string to a shell — is shared by most coding agents with an auto-approve feature. Roo Code is the one where somebody sat down and enumerated the disagreements publicly.
Can I just fix the regex?
You can close each specific finding, and the proposed fixes do. What you cannot do by patching is make a hand-written normaliser agree with bash on every construct, because the set of constructs is bash's to define and it keeps being larger than your test suite.
Why does the CVSS say no privileges and passive interaction?
Because the attacker never touches the machine. They put the command in content the agent will read — an issue comment, a web page, a dependency's release notes — and the developer's ordinary request to read that content is the whole of the user interaction.
Does an argv allowlist have no bypasses at all?
It removes the shell-parsing class entirely, which is the one these nine advisories live in. It does not remove argument-level abuse: git with -c core.sshCommand=…, or any binary with a --exec flag, is still a shell by another route. Allowlist the flags too, or keep the binary out of the sandbox.
The repository is archived. Why write about it?
Because archived code does not stop running, and forks start from it. The cluster is also the clearest public evidence available that prefix-matched shell approval fails as a class rather than as a sequence of mistakes.
Further reading
On this wiki:
- Auto-approval & command allowlists — the concept page, including the argv-vs-string rule.
- Ambient authority — why the developer's own credentials are the real payload.
- Blast radius — sizing what a bypass reaches.
- Agents on developer workstations — the environment these gates actually run in.
- Prompt-injection defence in 2026 — the delivery mechanism for all nine.
Sources:
- GHSA-v45q-xfmj-r4wr / CVE-2026-110202 — redirect-stripping bypass, CWE-436.
- GHSA-66pw-2qq8-258w / CVE-2026-110205 — glob-adjacent command substitution, CWE-184.
- 7rah/oss-cve — the seven writeups, with reproducible proofs of concept.
- Roo-Code v3.54.0
commands.ts— the decision function as shipped.