The notebook brought its own MCP server
CVE-2026-75149 is a code injection bug in marimo: a notebook is a single Python file, that file carries its own configuration in a comment block, and until version 0.23.15 the configuration could declare an MCP server. Open the notebook in edit mode and marimo launched the attacker's command as a subprocess before a single cell ran. The patch says the rest. It drops five whole categories of environment that a downloaded document was allowed to set, at higher precedence than the operator's own config. In Bromure Agentic Coding the list of programs your agent may launch is a host-side panel, written into the VM at boot, and nothing in the workspace can add to it.
A repository can reconfigure your coding agent. We knew that. The size of the thing that can do it has changed. It is now one file, forwarded to you in Slack, that you open to have a look at.
Someone sends you a notebook. It is a marimo notebook, so it is an ordinary
.py file rather than a bundle of JSON with base64 outputs in it, which is most
of why people like the format. You can read it, you can diff it, and you can put
it in git without a pre-commit hook to strip the outputs.
So you read it, it looks fine, and you open it in edit mode to poke at a cell.
A subprocess starts. You have not run a cell yet; this is something the file asked for on its own, before the notebook finished loading.
That is CVE-2026-75149, published on August 19 and picked up by The Hacker News this week. CVSS 8.7, CWE-94, credited to Gregory Tan, fixed in marimo 0.23.15. VulnCheck's advisory puts the mechanism in one line: the notebook contains a crafted MCP server entry, and opening it in edit mode launches that entry's command as a local subprocess.
The configuration was in a comment
marimo notebooks carry their own settings using
PEP 723, the Python convention for inline
script metadata. It is a small TOML table living inside a comment block at the
top of the file, and it exists so a script can declare its dependencies and its
Python version without a separate project file next to it. uv reads it. So
does marimo, which extends it with a tool.marimo table for notebook
configuration.
Two properties of that table did the damage, and neither is a bug on its own.
The first is that marimo merged notebook metadata at the highest precedence, above the configuration the person running marimo had set. That is the correct default for a formatting preference. A notebook that wants two-space indentation should win over your global setting, because the author knows what their file looks like and you do not.
The second is that the merge was almost unfiltered. Before the fix, marimo's
sanitizer removed exactly one key from notebook-supplied config:
tool.marimo.runtime.auto_instantiate. Everything else in tool.marimo came
through, including tool.marimo.mcp. That is where you list Model Context
Protocol servers, and a list of MCP servers is a list of commands to launch.
Put those together and a comment at the top of a downloaded file could name a program and have marimo start it. Nothing here is an exploit. The file used the feature the way it was designed to work, and the design assumed you had written the file yourself.
The patch is the disclosure
The fix, PR #10281, is titled "additional pep 723 sanitization", and it swaps a denylist for an allowlist. Notebook-supplied config is now restricted to cosmetic and editor sections: formatting, save, display, keymap, diagnostics, lint, snippets, datasources, language servers, sharing, venv, runtime, package management.
The dropped sections are the interesting half: ai, mcp, completion,
secrets, server.
That is five whole sections, not five keys, and a document you downloaded could
set all of them. The PR's rationale also spells out a second payload that never
needed a subprocess. The ai section carries API base URLs, so a notebook could
repoint the model endpoint at a host of its choosing, which in the maintainers'
words "could enable exfiltration of the operator's API keys to
attacker-controlled endpoints."
We covered the base-URL version of this attack in July, when
a repository could set ANTHROPIC_BASE_URL
and Claude Code mailed its own key to the address the repo picked. It is the
cheapest attack in this category, because no code runs and the victim's own
client does the sending. The marimo variant shrinks the delivery vehicle from a
repository to a comment.
The unit of trust keeps getting smaller
This bug class has a short history, and in that history the attacker needs less of your machine each time.
In April, Wiz Research reported
CVE-2026-12957
in Amazon Q Developer, CVSS 8.5, patched in May and disclosed in June. Amazon Q
read .amazonq/mcp.json from an opened workspace and launched the servers it
defined. Those processes inherited the developer's full environment: AWS keys,
cloud CLI tokens, API secrets, SSH agent sockets. Amazon's fix was to ask before
running an MCP server from an untrusted workspace.
In August, ChainDrop
committed agent config into the repositories it walked:
a SessionStart hook in .claude/settings.json and a folderOpen task in
.vscode/tasks.json, so that cloning the project and opening it was enough.
Both of those needed a repository. Somebody had to publish or compromise a project, you had to clone it, and a directory of dotfiles sat there in the tree where a suspicious person could look at it.
Marimo needs a file. One file, which renders as a document, and which arrives the way documents arrive: a Slack thread, an email attachment, a gist someone linked in a standup, a Kaggle download. You do not clone a notebook. You open it, because opening it is how you find out what it is.
The mcp block has spread into most developer tooling over the last eighteen
months, and those blocks live in files that travel between people: repository
roots, editor directories, and now document metadata. Each one is a list of
programs something on your machine is willing to start, written by whoever last
touched the file. Registry scanners do not read them, lockfiles do not cover
them, and nobody has proposed a provenance attestation that would apply to a
comment.
Where MCP servers come from in a profile
Bromure Agentic Coding answers the provenance question where it can be answered: on the host, before the VM boots.
Each profile has an MCP panel in its settings, alongside Agents, Credentials, Guardrails and Supply Chain. It holds a list of servers. Each entry is toggled on or off independently and uses one of two transports: HTTP, a remote URL with an optional bearer token, or stdio, a local command launched inside the VM. Bromure translates that list into whatever format the active agent expects, JSON for Claude Code and TOML for Codex, then injects it into the VM at boot.
Read the direction of travel in that last sentence. The config goes into the guest, from the host, at boot, out of a list you maintain in a settings panel. A file that shows up in the workspace afterwards is not a place MCP servers come from, so there is no merge to get the precedence wrong on and no sanitizer to keep in sync with a config schema that keeps growing sections. A document cannot add an entry to a list it cannot reach.
Now open the notebook anyway
None of that helps on the day you are running a version that has the bug. So take the pre-0.23.15 marimo, hand the attacker the win, and walk a hostile notebook through a profile.
The subprocess starts in someone else's computer. Session work in Bromure
Agentic Coding happens in kitty tabs inside the profile's Ubuntu VM, one
hypervisor away from macOS. marimo edit runs there, and so does the command in
the comment. Under Resources → Storage the profile is three layers: a
per-profile /home/ubuntu with Erase home…, a workspace system disk with
Reset to base… that re-clones from the shared image, and a read-only base OS
underneath that nothing in the guest can touch. One menu item takes away
whatever the entry installed.
The environment it inherits is a set of decoys. The Amazon Q bug hurt
because the launched server was a child of the developer's real shell and
inherited the developer's real credentials. In a Bromure profile there is
nothing behind those variable names. Generic API keys are brm_… placeholders
exported into the VM and swapped for real values by the host's proxy on the way
out. The kubeconfig is synthetic, with throwaway client certs. AWS requests are
re-signed host-side, so a process that routes around the proxy gets
InvalidSignatureException rather than access. ~/.docker/config.json holds a
fake base64 blob. Private SSH keys
are never in the VM to begin with,
because the host signs through a per-profile agent. Turn on Require approval
to use for a given credential and each swap becomes a host dialog with a
time-bounded grant: five minutes, an hour, the rest of the session.
The base-URL trick collects a placeholder. The proxy substitutes a real
credential only on outbound requests to that credential's own host. A notebook
that repoints an AI endpoint at attacker.example collects what is sitting in
the VM's environment, which is a brm_… string that unlocks nothing.
The server has to reach the network. An MCP entry that spawns something
useful to an attacker needs egress, whether to fetch a later stage or to send
back what it found. Guardrails → Outbound connections is a pf-style rule
table: an action, a protocol (tcp, udp, web, any), a host or CIDR, a
port list, and for web a list of HTTP methods, evaluated top to bottom with
first match winning, plus an Unmatched traffic setting of Allow or Deny. Set
it to Deny, list the hosts your work needs, and the entry's destination is not
on the list. The table is enforced on the host, in the virtual switch and the
proxy, so nothing the guest does to its own routing changes the answer.
The file has to be in the VM at all. A profile shares at most eight Mac
folders, each one picked by hand in the Folders panel and mounted at
/home/ubuntu/<basename>. A notebook that landed in ~/Downloads is not in the
profile until you put it there, and the rest of your Mac is not one directory
walk away from whatever the notebook started.
And you can see it happen. The Security Log window (Window → Security Log…) is a single host-side chronological table: package verdicts, firewall decisions, credential swaps, prompt-injection detections. Guest code cannot edit it, because guest code cannot reach it. A blocked outbound connection from a process you did not start shows up there as a line, which beats finding out from a cloud bill six weeks later.
Turn it on
Update marimo to 0.23.15 or later; the allowlist there is a good patch. Then go
look at every other place your tooling reads an mcp block from, and ask who
gets to write that file.
In a profile, the settings worth two minutes are the same short list as always. Guardrails → Outbound connections, with Unmatched traffic on Deny and an allow list for the hosts your work needs. Credentials → Require approval to use on anything that can spend money or delete data. Supply Chain → OSV vulnerability check, and socket.dev or Delpi filtering if you have a key, on top of the two-day age gate that is on by default. Prompt Injection → the CLAUDE.md and AGENTS.md scanner, which treats the files that claim authority over your agent as exactly that.
Notebooks are going to keep being forwarded, and config formats are going to keep growing sections, because both of those things are useful. What you can change is which machine is listening when a document asks for a program to be started. Install Bromure Agentic Coding, keep your server list in the profile editor, and let the next helpful comment block configure a VM you can erase from a menu.