Read-only-in-prose is not read-only

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.

A build note from erp-report-engine · by Eren Gülmez

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."

Two incidents, one root cause

1. The transaction that walked out of read-only

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.

2. The lethal trifecta

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 common thread: both systems delegated "read-only" to a configuration — a transaction mode, a database role — and something reached past it. The lesson isn't "those teams were careless." It's that a read-only guarantee has to be a property of the statement, checked before execution, not a property of the environment you hope holds.

Why a shape-only guard is not enough

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 readActually
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.

A guard you can prove

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:

  1. Lexical. One statement only (Class A dies here — a second statement is a hard refusal). Must begin 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.
  2. Parse-tree, fail-closed. The statement must parse; if it can't, it's refused, not waved through (Class C). It must resolve to a single read query with no write/DDL node — catching writes hidden inside CTEs.
  3. Function check (Class B), by AST and lexically. A denylist of side-effecting built-ins across PostgreSQL, SQL Server, MySQL and SQLite, checked on the parse tree — and, because OPENROWSET won't parse, checked lexically too.
  4. Read-only session where the engine allows it (PostgreSQL read-only transaction, MySQL 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.

The honest limit, stated plainly: a denylist is never provably complete, and SQL Server has no session-level read-only switch. So the outermost layer is always a least-privilege, read-only database login — a role that cannot write, ideally a physical read replica. The guard is defence in depth; the grant is the layer that holds if the guard has a hole. Saying so is the difference between a security tool and a security claim.

"Provable" has to mean reproducible

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:

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.

See for yourself.
The trust benchmark — every attack, its severity, and what it actually does.
Break the guard yourself — the real guard, client-side, via Pyodide.
▶ One command: pipx install erp-report-engine && erp-report-engine trust-benchmark

Why this generalises past one tool

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.