← Back to all posts
Published on · by Renaud Deraison

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.
One login URL, two hand-offs to the hostauthorization_endpoint: https://evil.test/$([IO.File]::WriteAllText(…))opencode 1.18.32 in WSLLinux guestopencode MCP OAuth[email protected]Windows hostpowershell -EncodedCommandStart "…" "https://…$(…)"$(…) expands firstcommand runs as your Windows userbrowser launch fails, error discardedThe URL becomes part of a command line on the host.An agent in a Bromure machineLinux VMxdg-open, $BROWSER→ bromure-openOutbox filethe URL as one line of textno shell reads itYour Machttp or https, public host onlyNSWorkspace.open(url)The URL stays a URL from the VM to the browser. $(…) stays plain text.
The same login URL on two machines. In WSL, the opener inside the Linux guest hands the URL to powershell.exe on the Windows host, which expands the $(...) subexpression and runs it with your Windows account's rights. In Bromure Agentic Coding, the guest's opener writes the URL to a file for the Mac. The Mac accepts it only as an http or https address with a public host and passes it to macOS's own URL launcher, which starts no shell.

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.

Remote MCP in Bromure Agentic CodingLinux VMAgent MCP configserver URL, written at bootno token, no sign-in to runKeys and tokensstand-ins onlyxdg-open → bromure-openURLs leave as textYour MacOAuth brokerdiscovery, PKCE sign-in, tokenkept on the MacProxyanswers the agent's discoveryadds the real token on the wireallow web mcp.linear.appallow web mcp.notion.comallow web api.anthropic.comdefault denymcp.linear.applisted, reached with the real tokenmcp-linear[.]appnot listed: refused at the switchdeny row in the Security Timeline
An MCP server added in Bromure. The Mac's OAuth broker signs in and keeps the token. The proxy answers the agent's discovery requests, so the agent never runs its own handshake and never opens a server-supplied URL. The allow-list on the Mac limits which MCP hosts the VM can reach at all, and every refusal is logged.

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.