The read-only guard, measured against its own attacks

erp-report-engine 0.7.0 · a reproducible security benchmark

"Read-only" is a claim about a guard, and a guard that inspects a statement's shape while ignoring the functions it calls has not earned it. Every attack below is a perfectly well-formed SELECT — and every one reads a file, opens a socket, runs more SQL, or flips the session. A shape-only guard waves them all through.

28/28
attacks refused
8/8
legitimate reads allowed
4
SQL dialects

Every case behaves as claimed.

Run it yourself. The number above is computed from the guard, not asserted in prose — so you can reproduce it in one command:
pipx install erp-report-engine
erp-report-engine trust-benchmark
The same corpus is enforced by CI on every commit, so this page can never claim a result the tests don't hold. Or — no install — paste your own SQL into the guard, in your browser →

The same corpus, three ways to guard it

Most tools enforce "read-only" by reading a statement's shape (is the first word SELECT?) or scanning its text for write keywords. Here is how each fares against the exact cases below — computed in this same run, so the contrast is measured, not asserted.

ApproachAttacks refusedReads allowed
starts-with-SELECT6/288/8lets 22 attacks through
write-keyword blocklist9/287/8lets 19 attacks through, and blocks 1 legitimate read
erp-report-engine — this guard28/288/8checks what each statement calls — refuses every attack, blocks no read

A keyword scan is the worst of both: it still misses the file-reading and socket-opening functions (they contain no write word), yet it blocks a legitimate SELECT 'please delete this note' because the word is in the string. The only guard that clears every attack and every read is the one that inspects what each statement actually calls.

Attacks — 28 well-formed SELECTs that are not reads

SeverityFunctionDialectWhat it actually doesGuard
criticalcommit_droppostgresTHE famous one: the read-only transaction ESCAPE that got Anthropic's reference Postgres MCP server archived (Datadog, 2025) - refused as a second statementrefused
criticalcte_insertpostgresa write hidden inside a CTE, wrapped in a SELECTrefused
criticaldblinkpostgresopens an outbound connection: SSRF and a data-exfiltration channelrefused
criticaldroppostgresdestroys a tablerefused
criticallo_exportpostgresWRITES a server file - a SELECT that mutates the filesystemrefused
criticalload_extensionsqliteloads a shared library - arbitrary code executionrefused
criticalload_filemysqlreads a server file into the result setrefused
criticalopenrowsettsqlreads a host file; sqlglot cannot even parse it, so the lexical net catches itrefused
criticalpg_read_filepostgresreads an arbitrary server file straight off diskrefused
criticalset_configpostgresswitches the read-only session backstop OFF from inside a SELECTrefused
criticalsp_oacreatetsqlOLE automation: another SQL Server path to arbitrary coderefused
criticalsys_execmysqla MySQL UDF that shells out - command execution where the UDF is installedrefused
criticaltrifecta_exfilpostgresthe Supabase 'lethal trifecta' write leg - injected content steering an agent to copy secrets into an attacker-readable row; a guard denies the write a read-only role might notrefused
criticaltwo_statementspostgresa second statement smuggled behind a readrefused
criticalupdatepostgresthe obvious case - mutates rowsrefused
criticalxp_cmdshelltsqlSQL Server shell execution - and Logo, Netsis and Mikro all run on SQL Serverrefused
highcomment_obfuscationpostgrespg_read_file with a comment spliced in to dodge a name scan - the comment guard refuses it firstrefused
highcopy_topostgresCOPY ... TO writes a server file; it isn't a SELECT/WITH, so it never clears the head checkrefused
highinto_outfilemysqlSELECT ... INTO OUTFILE writes the result to a server file - a MySQL exfiltration primitiverefused
highlock_hinttsqlan exclusive lock hint escalates a read into a blockerrefused
highopendatasourcetsqlad-hoc connection to an attacker-named serverrefused
highopenquerytsqlruns SQL on a linked server - a pivot off the reporting boxrefused
highquery_to_xmlpostgresre-enters the executor with an arbitrary SQL stringrefused
highselect_intotsqlSELECT ... INTO creates and populates a new tablerefused
mediumbenchmarkmysqlCPU-burning denial of servicerefused
mediumcommentpostgresa trailing comment: refused as injection hygienerefused
mediumpg_sleeppostgresties up a connection indefinitely - denial of servicerefused
mediumsleepmysqldenial of service; MySQL had no statement timeout to stop itrefused

Legitimate reads — 8 that must be allowed

A guard that blocks real work is useless, so these earn a place in the benchmark too.

QueryDialectWhy it's a readGuard
readaggregatepostgresgrouped aggregate - the bread and butter of a reportallowed
readcase_coalescemysqlCASE and COALESCE - recognised functions, allowedallowed
readcte_windowtsqla CTE with a window function - reads, despite the machineryallowed
readdate_bucketpostgresdate_trunc + COUNT - the time-bucketing at the heart of a weekly reportallowed
readjointsqla two-table joinallowed
readliteral_keywordpostgresa write keyword INSIDE a string literal is data, not codeallowed
readtop_orderedtsqlTOP with ORDER BY - a ranked read, allowedallowed
readunionpostgresa UNION of two readsallowed

Why this is the benchmark that matters

Pointing an autonomous tool — or an AI agent — at a production ERP database is the "lethal trifecta": untrusted input, a tool that can act, and a channel to exfiltrate through. Wrapping queries in a read-only transaction is not enough on its own: a session flag can be flipped (set_config), a transaction can be escaped, and a function can read a file or dial out without writing a single row. The only honest way to claim a guard holds is to measure it against the functions that defeat the shape-only version — which is what this page does, on every commit.

The guard here checks the functions a statement calls, by parse tree and lexically (some of these don't even parse), fails closed when it can't read a query, and — on the agent path — default-denies every function it doesn't recognise. Full model: SECURITY.md.