The two most-cited failures of AI-agent database access have the same root cause: a guard that trusted a promise instead of checking the statement. Here's the class of attack, and how to build a guard you can prove — in a browser, in one command.
In 2026, letting an AI agent query a production database is no longer exotic — the Model Context Protocol (MCP) made it a checkbox. What it did not make safe is the part everyone assumed was easy: read-only. Twice now, in the two most-cited MCP database stories, "read-only" turned out to mean "read-only if nothing goes wrong."
Anthropic's reference PostgreSQL MCP server wrapped each query in BEGIN TRANSACTION READ ONLY. Reasonable — except the Postgres driver happily accepts multiple semicolon-separated statements in one string. So an attacker (or a prompt-injected agent) sends:
COMMIT; DROP SCHEMA public CASCADE;
The COMMIT closes the read-only transaction; everything after runs with the connection's full privileges. Datadog Security Labs reported it; Anthropic archived the server in May 2025. The read-only guarantee was real in prose and absent in code.
Simon Willison's term for the three ingredients of an agent data breach: access to private data + exposure to untrusted content + a way to communicate out. Hold any two and you're fine; grant all three and a prompt injection needs no exploit. The Supabase MCP case was the textbook example: a support ticket carried hidden instructions, an agent triaging tickets ran with a role that bypassed row-level security, and it copied a secrets table into a ticket reply the attacker could read. (write-up)
The obvious guard checks a statement's shape: is it a SELECT? No INSERT/UPDATE/DELETE/DROP keyword? That stops the beginner cases and misses three whole classes:
Class A — statement stacking & transaction escape. COMMIT; DROP … is a perfectly shaped pair of statements. A guard that scans one statement's shape never sees the second.
Class B — functions that read like reads but aren't. This is the deep one. Every database ships built-ins that make a syntactically flawless SELECT touch the world:
| Looks like a read | Actually |
|---|---|
SELECT pg_read_file('/etc/passwd') | reads a server file |
SELECT lo_export(…, '/tmp/x') | writes a server file |
SELECT * FROM dblink('host=…', …) | opens an outbound connection — exfiltration |
SELECT set_config('…read_only','off',false) | switches the read-only session off |
SELECT * FROM OPENROWSET(BULK …) | reads a host file (SQL Server) |
EXEC xp_cmdshell 'whoami' | shell execution (SQL Server) |
SELECT load_extension('evil.so') | loads a library — arbitrary code (SQLite) |
None of these carry a write keyword. A shape-only guard waves every one through. And the SQL Server names matter here specifically: the ERPs this tool targets — Logo Tiger, Netsis, Mikro — all run on SQL Server, where xp_cmdshell and OLE automation (sp_OACreate) are a shell away.
Class C — the parser's blind spot. You might reach for an AST: parse the SQL, walk the tree, reject bad nodes. Good — but OPENROWSET(BULK …) doesn't parse in most SQL parsers. If your guard fails open on a parse error ("couldn't read it, let it through"), the one input that defeats the parser is exactly the one that defeats the guard.
The guard in this project answers each class explicitly, in four layers — and the design rule throughout is fail closed and check the statement, never trust the environment:
SELECT/WITH. No comments, no write keyword, no write-escalating lock hint. Crucially, the scan blanks string literals first, so SELECT 'please delete this note' is correctly a read — a guard that cries wolf gets switched off.OPENROWSET won't parse, checked lexically too.SET SESSION TRANSACTION READ ONLY, SQLite query_only) — plus a per-statement timeout so pg_sleep(100000) can't tie up a connection.For SQL that didn't come from the operator — the agent path — there's a fifth stance: strict mode default-denies every function the parser can't name. The parser's own function registry becomes the allowlist: it knows SUM, COALESCE, ROW_NUMBER, and nothing that reads a file or opens a socket. All the bundled ERP profiles pass it — they call no unrecognised function at all — so default-deny costs nothing real.
A security claim you can't check is marketing. So the guard ships an attack corpus — the two incidents above by name, plus the file-read / exfiltration / shell / extension cases across four dialects, plus the legitimate reads that must still pass (a guard that blocks real work is useless). One list, shared by three things:
erp-report-engine trust-benchmark — so anyone who installs the package reproduces the number;Because it's one source, the website can't claim a result the tests don't hold. And a number only means something next to a baseline, so the same corpus is run through the shortcuts these guards usually lean on: a starts-with-SELECT check refuses just 6 of the 28, a write-keyword blocklist 9 — and the blocklist even blocks a legitimate read whose string literal contains the word "delete". The statement guard refuses all 28 and blocks none, and a test pins that it beats every shortcut. The full comparison →
The strongest form of "trust me" is "don't" — so the guard also runs, unchanged, in your browser: paste your nastiest payload and watch the real guard.py refuse it, nothing sent anywhere.
pipx install erp-report-engine && erp-report-engine trust-benchmark
The reason both famous incidents happened to database MCP servers is that a database is the lethal trifecta's natural home: it holds the private data, agents feed it untrusted content, and SQL itself is an exfiltration channel (dblink, OPENROWSET, a crafted error message). If you're pointing an agent at a production database — ERP, CRM, anything — the question isn't "is it read-only?" but "can you show me?" That's the bar this was built to clear, and the bar the whole category is going to be held to.