Five Ways to Disagree With bash: Roo Code Gets Five Approval-Bypass CVEs and No Patch Is Possible

At 19:18Z on 13 October 2026, VulnCheck published five CVEs against Roo Code — CVE-2026-110202, 110203, 110204, 110205 and 110206. All five are scored 7.7 on CVSS 4.0 (AV:N/AC:L/AT:P/PR:N/UI:P/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N) and 8.8 on CVSS 3.1. Four carry CWE-436 (Interpretation Conflict); CVE-2026-110205 carries CWE-184. All five are credited to Minqiang Gui and Siyu Lou, and all five say the same thing in five different ways: the command-approval gate re-implements bash’s grammar in regular expressions, and the shell does not agree with it.

The affected range on every record is through 3.54.0. That phrasing usually means “until someone ships 3.54.1”. Here it does not. We checked the repository through the GitHub API: RooCodeInc/Roo-Code returns archived: true. Its last release, v3.54.0, was published on 15 May 2026; its last commits, three hours later the same day, are titled “Redirect roocode.com to roomote.dev” and “Remove roocode.com web app”. The repository is read-only with 1,033 issues still open, 24,281 stars and 3,426 forks, under Apache-2.0. The researcher’s own write-ups open by quoting GitHub’s banner verbatim: “This repository was archived by the owner on May 16, 2026. It is now read-only.” There is no fixed version, and there will not be one. Five months after the lights went out, a VS Code extension with a quarter of a million-odd installs acquired five permanently unpatched approval bypasses.

The gate, and the two files it lives in

When “always approve execute” is enabled, Roo Code decides on its own whether to run a shell command the model proposed. The decision is getCommandDecision in src/core/auto-approval/commands.ts. We read it at tag v3.54.0. It splits the command into sub-commands, checks each against an allowlist and a denylist, and returns one of three verdicts:

// Parse into sub-commands (split by &&, ||, ;, |)
const subCommands = parseCommand(command)

const decisions: CommandDecision[] = subCommands.map((cmd) => {
    // Remove simple PowerShell-like redirections (e.g. 2>&1) before checking
    const cmdWithoutRedirection = cmd.replace(/\d*>&\d*/, "").trim()
    return getSingleCommandDecision(cmdWithoutRedirection, allowedCommands, deniedCommands)
})

The splitting is done by parseCommand in src/shared/parse-command.ts, which masks shell constructs into placeholders (__PARAM_n__, __SUBSH_n__, __QUOTE_n__, __VAR_n__) with a sequence of regular expressions, hands the masked string to the shell-quote library, then walks the resulting tokens looking for separators. Every one of the five CVEs is a place where that masking, or that token walk, parts company with the shell that will actually run the string.

It matters that this is not a missing control. It is a control that was built, and built with some care — which is why the failure mode is interesting rather than merely embarrassing.

The five disagreements

CVE-2026-110206 — substitution in command position. parseCommand pulls the inside of a $(...) out and checks it as its own sub-command. That is right when a substitution supplies an argument. It is wrong when the substitution is the command: bash runs the inner command and then executes its standard output as a new command. So $(echo touch /tmp/x) is checked as echo touch /tmp/x, matches an echo allowlist entry, and is approved — after which the shell runs touch. An allowlist containing only echo, a command chosen precisely because printing is harmless, yields arbitrary execution.

CVE-2026-110202 — the redirect strip. The line above, cmd.replace(/\d*>&\d*/, ""), is commented as removing fd-duplications such as 2>&1. But \d* matches zero digits, so it also strips a bare >&, which is not an fd-duplication at all — it is “send stdout and stderr to this file”. Strip the operator and its target word is promoted into command position. >&echo touch /tmp/x is checked as echo touch /tmp/x and approved; bash reads it as “redirect to a file named echo, then run touch”. Note where this one lives: the tokenizer gets it right and preserves the redirect, and the decision function then throws it away.

CVE-2026-110204 — the funsub. The parameter-expansion masker is /\$\{[^}]+\}/g, documented in-source as covering ${var}, ${var:-default}, ${#var} and friends. It also matches ${ command; } — the command substitution that ksh93 has had for decades and that bash gained in 5.3. The command inside is masked into __PARAM_0__ and never checked, so echo ${ touch /tmp/x; } is approved as plain echo. The regex predates the bash feature; the shell on the developer’s machine does not. For reference, the bash on our own test host is 5.3.15, which supports the construct.

CVE-2026-110203 — quote-state drift. Double-quoted spans are masked with /"[^"]*"/g, which pairs quote characters by counting them. bash does not count; it tracks state. A backslash-escaped \" outside quotes is a literal character that opens nothing, and a " inside '…' or ANSI-C $'…' is likewise literal. When one of those appears, the regex pairs two characters bash considers literal and swallows everything between them — including a ; separator and whatever follows it — into one placeholder. The separator never becomes a token, so parseCommand returns a single echo … sub-command while bash runs two commands.

CVE-2026-110205 — the glob token. This is the subtlest of the five and the one we checked by hand, because it depends on a third-party library’s output rather than on Roo Code’s own regexes. The substitution-extraction branch only fires when the __SUBSH_n__ placeholder survives tokenisation as a bare string token. Put a * or ? next to the substitution and it does not. We installed shell-quote@1.8.3 — the same version the researcher’s reproduction pins — and ran the parse directly:

parse("echo __SUBSH_0__")   -> ["echo","__SUBSH_0__"]
parse("echo *__SUBSH_0__")  -> ["echo",{"op":"glob","pattern":"*__SUBSH_0__"}]

The placeholder has moved into a pattern field. Roo Code’s token loop handles any non-separator operator token with a single line — currentCommand.push(token.op), under the comment “Other operators (>) are part of the command” — which pushes the literal string "glob" and discards pattern entirely. The sub-command reported to the gate is echo glob. The stored touch /tmp/x is never checked by anything. Remove the * and the identical substitution surfaces and is denied.

The second defence catches the exotic and misses the ordinary

Here is the part worth dwelling on. getCommandDecision does not rely on the parser alone. After the allow/deny pass it runs a dedicated guard:

if (containsDangerousSubstitution(command)) {
    return "ask_user"
}

We read containsDangerousSubstitution in full. It is not lazy work. It tests for ${var@P} and the other @-transform operators, with a comment explaining that @P “interprets escape sequences and executes embedded commands”. It tests for parameter assignments carrying octal, hex or unicode escapes. It tests for indirect expansion ${!var}. It tests for here-strings with substitution, <<<$(…). It tests for zsh process substitution =(…) and for zsh glob qualifiers with embedded code, *(e:whoami:). Somebody sat down and enumerated the clever ways a shell can be made to execute something that does not look like a command.

None of its patterns match a plain $(…) in command position, a bare >&, a ${ cmd; } funsub, an escaped quote, or a substitution glued to a *. The guard was aimed at the exotic and the five bypasses are, with the partial exception of the funsub, thoroughly ordinary shell. This is a recognisable shape: a denylist assembled from the attacks its author had heard of, sitting downstream of a parser whose failures its author had not enumerated. The enumeration is the whole mechanism, so the mechanism ends exactly where the author’s imagination did.

Mistral Vibe, in the same batch, from the other direction

The same VulnCheck batch carries CVE-2026-109429 against Mistral Vibe, scored 8.7 CVSS 4.0 and 8.8 CVSS 3.1, CWE-436, affecting versions before 2.25.4, credited to Minqiang Gui. Vibe did the thing Roo Code did not: it classified commands with tree-sitter-bash, a real grammar rather than a stack of regexes. It was defeated anyway. Per the advisory, an attacker hides a command substitution inside a single-quoted variable value — inert to any static parse, because in single quotes it is just text — and then re-expands it with ${var@P}, the prompt-transform operator, whose promptvars behaviour executes the hidden command at run time. Only the trailing allowlisted command is what the classifier sees.

The symmetry is almost too neat to be accidental, and it is the reason we have put these records in one briefing. ${var@P} is the first thing on Roo Code’s dangerous-substitution list. The tool that used a real parser fell to the trick the tool with the regex denylist explicitly catches; the tool with the denylist fell to five constructs any grammar would have tokenised correctly. Neither approach is the answer, because neither is the shell. A static classifier and a run-time interpreter are two different programs reading the same string, and every gap between them is a bypass.

One correction to the public record while we are here. Vibe’s fix is real and predates the CVE: commit 19b5b74, merged as PR #1084 on 12 September 2026 at 18:39Z, shipped v2.25.4 with two new files — _shell_command_policy.py (276 lines) and _shell_permission_analysis.py (165 lines) — plus 406 added lines in tests/tools/test_bash.py. But the CHANGELOG entry for 2.25.4 attributes that work to a different set of records: “Shell permission checks now require approval for risky syntax and command options that could bypass workspace and denylist controls (CVE-2026-87984, CVE-2026-87985, CVE-2026-87986, CVE-2026-87987, and residual CVE-2026-87988 variants).” CVE-2026-109429 is not named there, and the 879xx series traces to HiddenLayer advisories from 11 September. So 109429 is a separate, later record pointing at an existing release — the fix landed 31 days before the CVE that cites it. Vibe is on v2.26.1 as of 9 October; anyone on a current build has had this fixed for a month without knowing the CVE number.

What to do

For Mistral Vibe the remediation is ordinary: run 2.25.4 or later, which means any build from mid-September onward.

For Roo Code there is no upgrade, so the advice has to be structural. If you still run the extension, turn off “always approve execute”; that single setting is the precondition for all five records, and with it off the gate is not making the decision. If you cannot, understand that an allowlist of echo is sufficient for arbitrary execution via at least three of the five paths, so the allowlist’s contents buy you very little — the usual instinct of allowlisting only “harmless” commands is the exact instinct these findings punish. Denylists fare no better: CVE-2026-110202 was demonstrated with touch explicitly denied. Treat a forked or vendored copy as carrying all five unless you have patched the two files yourself, and note that the archived upstream will not be issuing a GHSA to tell you.

The general lesson generalises past these six records, and we have now written it three days running about three different products. If a control suppresses a confirmation prompt, find the code that parses the subject and the code that executes it, and establish that they are the same program. Where they are not — a regex gate in front of bash, a static classifier in front of an interpreter, a Node tokenizer in front of a shell — the question is not whether a divergence exists but how many a motivated reader will find. Two researchers found five in one extension and one in a frontier lab’s coding agent, in the same week, and filed them on the same afternoon.

What we verified, and what we did not

All CVE identifiers, publication timestamps, CVSS 3.1 and 4.0 vectors, CWE assignments, affected ranges and assigner fields were read first-hand from the NVD 2.0 API. Credits and advisory text were read first-hand from the VulnCheck advisory pages. Both Roo Code source files were fetched at tag v3.54.0 and every excerpt above is quoted verbatim from them; the archived flag, release dates, commit titles, star, fork and open-issue counts come from the GitHub API. The shell-quote tokenisation was reproduced locally against version 1.8.3. The Mistral Vibe commit metadata, file list, release tags and CHANGELOG text were read first-hand from the GitHub API at commit 19b5b74.

We did not install Roo Code or Mistral Vibe, did not run either approval gate, did not execute any proof of concept, did not start an agent, and sent no request to any third-party deployment. We did not evaluate the researchers’ reproduction scripts beyond reading them, and the execution claims in their write-ups — that the constructs run on bash 5.3.15 and 3.2.57 — are reported as theirs, not reproduced by us.

Sources: