The login link was a command
On October 19, Pillar Security disclosed a remote code execution flaw in opencode on Windows and WSL. A remote MCP server could put a PowerShell subexpression inside an ordinary https:// login URL, and opencode's browser opener ran it during the OAuth handshake. The fix shipped as a refactor, with no CVE and no advisory. In Bromure Agentic Coding, the Mac runs MCP sign-ins itself, and a URL from the VM reaches the Mac as text that no shell ever reads.
The server picked the login page, and the login page's address had a command in it. The agent opened it, and PowerShell ran the command before any browser appeared.
You add a remote MCP server to your coding agent. MCP, the Model Context Protocol, is how agents reach outside tools: an issue tracker, a database, a documentation index. The server asks you to sign in, so the agent runs the OAuth handshake. It asks the server where its login page is, gets an address back and opens that address in your browser. You never touch the address. Saving you that copy-paste is the point of the handshake.
On October 19, Pillar Security published Open and Shut: Remote Code Execution in opencode via MCP OAuth, by Eilon Cohen. opencode is an open-source coding agent with about 3 million weekly npm downloads. On Windows and in WSL, versions 1.0.137 through 1.18.32 ran a command chosen by the MCP server during that handshake, with no prompt and no click. Pillar proposes a CVSS score of 9.8. Infosecurity Magazine covered it on October 20.
From a discovery document to PowerShell
The server controls the address. In the OAuth handshake, the server's
discovery document names an authorization_endpoint, a free-form string,
and the client sends you there. Pillar traced its path through opencode
1.18.32:
server's discovery doc → MCP SDK → oauth-provider.ts → mcp/index.ts → browser.ts → open() → PowerShell
Nothing on that path checked the scheme or compared the address with the
server you configured. open is a popular npm package that launches a
browser, with about 184 million downloads a week. On macOS and Linux it
passes the URL to open or xdg-open as a single argument, so no shell
reads it. On Windows, and in WSL, where it reaches out to the Windows host,
version 10.1.2 builds a PowerShell command line. It escapes the
application name and leaves the URL as it is, inside double quotes.
PowerShell expands $(...) inside double-quoted strings. Pillar's
payload:
https://evil.test/$([IO.File]::WriteAllText('C:\Users\Public\pwned.txt','RCE'))
PowerShell evaluates the subexpression before it tries to launch anything. By the time the browser launch fails, the file is on disk, and opencode discards the error.
The URL starts with https://. After an identical bug in mcp-remote
(CVE-2025-6514,
July 2025), the standard advice was to accept only http: and https:
links. The MCP TypeScript SDK blocks javascript:, data: and
vbscript:. opencode's own patch added an http/https check. Pillar's
payload passes all three. What closed the hole in opencode 1.18.33 was the
upgrade to open 11.0.4, which wraps the URL in single quotes, where
PowerShell expands nothing.
Two more details make this one hard to manage:
- The server can turn hostile after you add it. The payload arrives in an HTTP response at connection time. A server that was fine the day you configured it can be compromised later, and so can its domain, its registry entry or a typosquatted copy of it.
- No scanner will flag it. Pillar reported the bug through a private GitHub advisory on August 29. Anomaly, opencode's developer, said malicious MCP servers were outside its threat model, merged the fix on September 25 as a refactor of browser-launch code, and released 1.18.33 on September 28. It requested no CVE and published no advisory, so dependency bots have nothing to match. Pillar's advice is to inventory opencode installs by hand.
WSL and Bromure, one design choice apart
WSL turns this bug into a host compromise. It runs Linux on top of Windows, and when a program inside it wants a browser, the request goes to the host. A Windows shell does the opening, so a command hidden in the URL runs on your Windows machine, with your Windows account, outside Linux.
Bromure Agentic Coding has the same layout. It runs Claude Code, Codex, Grok, Kimi and omp in Linux VMs on your Mac, and a login page from inside the VM opens in your Mac's browser. The two part ways at the hand-off.
In every Bromure machine, xdg-open and $BROWSER point at a small
script called bromure-open. The open package on Linux calls xdg-open,
and so do most CLIs that "open a browser". bromure-open does one thing:
it writes the URL, as a single line of text, into a file in a folder shared
with the Mac. It doesn't execute or expand anything in it.
The Mac picks the file up, deletes it and reads the line as a URL. Only
http and https addresses with a public host get through. mailto:,
file:, custom schemes, localhost and private addresses are dropped.
What remains goes to NSWorkspace.open, the macOS call that hands a URL to
your default browser. No shell is involved, on either side of the VM
boundary.
Pillar's URL reaches your browser as a URL, with
$([IO.File]::WriteAllText(…)) sitting in its path as plain text. The
browser opens a page at evil.test, or fails to, and no command runs.
The Mac does the MCP sign-in
With the agents Bromure ships, the login URL doesn't reach the VM in the first place. You add remote MCP servers once, in the machine's MCP pane. Bromure translates each one into the configuration format of Claude Code, Codex, Grok, Kimi and omp and hands it to the VM at boot.
For an HTTP server, Authorize with OAuth… runs the sign-in on the Mac.
Bromure's broker fetches the server's discovery document, registers itself
as Bromure AC, builds the authorization URL with Apple's URL APIs and
opens it with NSWorkspace.open, then catches the callback on a loopback
port on the Mac. The token stays on the Mac. The agent's configuration
carries no credential, and the proxy adds the authorization header to
requests bound for that server.
The proxy also answers the server's OAuth discovery requests itself. The
agent sees a server it is already signed in to, so it never starts a
handshake of its own, never asks the server for an authorization_endpoint
and never hands one to an opener. The code path Pillar exploited doesn't
run.
The VM decides nothing about where it connects
Pillar warns that a server can turn hostile after you add it. Bromure controls which servers the VM can reach, from the Mac. Every connection a machine makes passes through a virtual network switch and a proxy, both outside the VM. In the machine's Guardrails pane, set Unmatched traffic to Deny and list the MCP servers you use:
allow web mcp.linear.app
allow web mcp.notion.com
allow web registry.npmjs.org
allow web github.com
allow web api.anthropic.com
default deny
A server that isn't on the list, such as a typosquat or an entry someone
pasted from a forum, can't serve a discovery document, so there is no
authorization_endpoint to open. The switch checks each connection by IP
and by the hostname it saw in DNS, and the proxy checks it again by TLS
server name. Each refusal is a red deny row in the Security Timeline
(Window → Security Timeline…) with the host, the port and the
machine. Rules apply as soon as you click Save, and nothing inside the
VM can edit them.
Inside the VM, every key is a stand-in. ANTHROPIC_API_KEY, the gh
token, the AWS secret and MCP static tokens hold fake values, and the proxy
swaps in the real one only on a request to the host it was minted for.
If a command collects those values and sends one
to a host it doesn't belong to, the compromise detector answers HTTP 451,
forwards nothing and pauses the machine with This environment may have
been compromised.
Finding the version no scanner flags
With no CVE, Pillar's advice is to inventory opencode installs yourself. In Bromure, the records already exist.
Find the version. The Security Timeline records every package fetch,
whatever the supply-chain settings, with the package and version in the
Condition column. Type opencode-ai in Filter events… and any row
reading npm · [email protected] or older names the machine. Export…
saves the rows as CSV. On Macs enrolled with bromure.io, each fetch is also
a supply_chain.fetch event, so an administrator can search every
developer's machines for opencode-ai from one console instead of asking
each team.
Find the servers. bromure-cli trace hostnames lists every host a
machine contacted, with request counts. The MCP hosts your agents
talk to are in that list, including any that never made it into the
MCP pane. Those are the ones to vet, or to leave off the allow-list.
Upgrade in the VM. npm install -g opencode-ai@latest in the
machine's terminal installs 1.18.33 or later, and the new fetch shows up in
the Timeline as proof.
URLs leave the VM as text
xdg-open and $BROWSER write the URL to a file for the Mac. The Mac
accepts only http or https with a public host and opens it without a
shell.
MCP sign-in on the Mac
The broker runs OAuth and keeps the token. The proxy answers the agent's discovery, so the agent never opens a server-supplied login URL.
An allow-list for MCP hosts
With Unmatched traffic set to Deny, only the servers you list are reachable. Every refusal is a row in the Security Timeline.
An inventory without a CVE
Every package fetch is recorded with its version, and every host a machine contacted is one command away.
Every field is untrusted
Pillar's closing lesson is for people who build MCP clients: every field a server sends is untrusted input, and most of it is parsed long before the model reads anything. In this bug the model never saw the payload: a browser launcher ran it.
You can't audit every launcher in every agent you install, and this one was fixed without anyone being told. Bromure moves the decisions out of the VM. The Mac runs the sign-ins, opens the URLs and decides which hosts the VM can reach. The Mac holds the real credentials too. For an earlier case where the MCP server itself was the attacker, read The model was their server. For agent configuration that trusted the wrong source, read You trusted the workspace, not the servers.