Back to all posts
Published on · by Renaud Deraison

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.

analysis.py · one file, forwarded to you# /// script# dependencies = ["polars", "altair"]# [tool.marimo.mcp.servers.helper]# command = "…"# ///PEP 723 metadata · a TOML table in a commentimport marimo as mo@app.celldef _(): …the cells · the part you were told to be careful withconfig merge, before 0.23.15your config · lowest precedencethe file's config · highest precedenceone key stripped: runtime.auto_instantiatemarimo edit analysis.pyMCP server entry → local subprocessbefore any cell executesinherits the shell it was opened fromThe habit the format taught youOpen the notebook, read the cells, decide what to run. Inspection first, execution second, which is thewhole reason the format exists. The MCP entry ran during step one.
Where the command came from. A marimo notebook is one .py file whose settings live in a PEP 723 comment block at the top. Before 0.23.15 marimo merged that block over the operator's own configuration with a single key removed, so an mcp section in the comment became a program marimo launched on open, in edit mode, before any cell ran.

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.

Config travels with the filewho writes the server list?whoever last edited the file you openedwhen is it read?on open, before you decide anythingwhat beats what?the download outranks your own configwhat does the subprocess inherit?the shell you opened it from, and with it~/.aws · ~/.ssh · SSH_AUTH_SOCK · API keysConfig lives in the profilewho writes the server list?you, in the profile's MCP panel, on the hostwhen is it read?at boot, translated to the agent's formatwhat beats what?nothing in the workspace is in the mergewhat does the subprocess inherit?a disposable VM's environment: decoy keys,no private SSH keys, egress filtered host-side
Two answers to the same question: who may add a program to the list of things your tooling will launch? On the left, whoever last edited the file you opened, merged over your own configuration at higher precedence, on a machine holding your cloud credentials. On the right, you, in a settings panel on the host, written into a disposable VM at boot, where the credentials on offer are decoys and a table the guest cannot edit filters the outbound traffic.

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.