The allowlist and the shell disagreed about where the command ended

Nine advisories hit one coding agent’s command-approval gate between March and October 2026, five on a single day, and none of them is a bug in the allowlist’s contents. Matching a shell string means maintaining a second shell — and the repository has been archived since May, so "through 3.54.0" is the final state.

By Agentic AI Wiki 12 min read

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.

PublishedCVEComponent named in the advisorySeverity
2026-03-30CVE-2026-30307Command auto-approval module (OS command injection)Critical
2026-07-20CVE-2026-63108Auto-approve execute path (command injection)High
2026-09-08CVE-2026-82536, CVE-2026-82537Shell command auto-approve bypassHigh
2026-10-13CVE-2026-110202 … CVE-2026-110206getCommandDecision() and parseCommandLine()High (CVSS v4.0 7.7)
Roo Code command-approval advisories published in 2026 A horizontal bar chart of GitHub advisories against Roo Code's command auto-approval path by publication date: one on 30 March 2026, one on 20 July, two on 8 September, and five on 13 October. All nine name versions through 3.54.0 and none lists a patched version. Advisories against the command auto-approval path, 2026 30 Mar 1 20 Jul 1 8 Sep 2 13 Oct 5 1 2 3 4 5 Repository archived read-only on 15 May 2026; no advisory lists a patched version.
Nine advisories, one module, seven months. The October cluster is one researcher working through the parser systematically.

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

One command string, two parsers, two different commands The model proposes a single shell string. The approval gate strips the redirect operator and reads the next word as the command, matching an allowlisted prefix and approving without a prompt. Bash reads the same string as a redirection to a file named echo followed by a denied command, which then runs with the developer's own credentials. Nothing in the path compares the two readings. The model proposes one string >&echo touch /tmp/ROO_PWNED Approval gate replace(/\d*>&\d*/, "") reads the command as echo touch /tmp/ROO_PWNED bash >& = redirect both streams reads the command as touch /tmp/ROO_PWNED nothing in the path compares the two readings verdict: auto_approve no prompt is shown to the developer denied command executes with the developer's own credentials
The gate decides on its reading of the string. The shell executes its own reading. Nothing checks that the two agree.

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
>&notecho 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-quote library, 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 no g flag, 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.

Three places the same approval policy can be enforced Three columns. Matching a shell string requires a parser that must agree with bash and is the location of all nine advisories. Matching an argv vector removes the shell entirely, so there is one reading of the command. Constraining the sandbox enforces effects in the kernel and carries no parsing risk at all. Same policy, three enforcement points Match a shell string "git status --short" needs a parser that agrees with bash on every construct parsing risk: all nine CVEs Match an argv vector ["git","status","--short"] no shell is involved, so there is one reading of the command parsing risk: none; flags still matter Constrain the sandbox no creds, ro mount, deny egress effects enforced by the kernel, whatever string gets through parsing risk: none at all
The same policy, enforced at three different places. Only two of them are free of a parser.

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 with spawn and 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 with echo" 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:

Sources: