Skip to main content

A provably read-only SQL guard for AI agents - checks what a statement calls, not just its shape. Postgres, MySQL, SQL Server, SQLite.

Project description

readonly-sql-guard

A provably read-only SQL guard for AI agents — drop it into any MCP server or database tool.

PyPI CI License: MIT

🇹🇷 Türkçesi: README.tr.md

Most database MCP servers and text-to-SQL tools enforce "read-only" by checking a statement's shape — does it start with SELECT? does it contain the word DELETE? That is not enough. A perfectly well-formed SELECT can read a file, open a socket, run a shell, escape a read-only transaction, or flip the session:

SELECT pg_read_file('/etc/passwd')                       -- reads a server file
SELECT * FROM dblink('host=attacker','SELECT 1') AS t(x int)  -- opens an outbound socket
SELECT * FROM orders; EXEC xp_cmdshell 'whoami'          -- SQL Server shell
COMMIT; DROP SCHEMA public CASCADE;                      -- escapes the read-only transaction

The first two of those, in real MCP servers, got Anthropic's reference Postgres server archived and turned Supabase's MCP into the textbook "lethal trifecta". This library checks the functions a statement calls, not just its shape — and proves it.

Read-only is a number here, not an adjective

readonly-sql-guard benchmark runs 28 well-formed-SQL attacks (including the two above by name) and 8 legitimate reads, and runs the same corpus through the shortcuts other tools ship, so you can see the gap:

Guard Attacks refused Legit reads allowed
starts-with-SELECT check 6 / 28 8 / 8
write-keyword blocklist 9 / 28 7 / 8 (blocks a read whose string contains "delete")
this guard 28 / 28 8 / 8

Every number is computed from a live run. Paste your own attack into the in-browser playground (the same guard, via Pyodide, nothing sent anywhere).

readonly-sql-guard benchmark output: 28 well-formed SQL attacks all refused — including the COMMIT; DROP transaction escape and the Supabase lethal-trifecta write leg — and all 8 legitimate reads allowed, next to the 6/28 and 9/28 scored by shape-only checks

The whole benchmark, one command, no database required: readonly-sql-guard benchmark.

Install

pip install readonly-sql-guard      # or: uv add readonly-sql-guard

Its only dependency is sqlglot.

Use

from readonly_sql_guard import assert_read_only, ReadOnlyViolation

assert_read_only("SELECT customer, SUM(net_total) FROM orders GROUP BY customer", dialect="postgres")
# returns None — it's a read

assert_read_only("SELECT pg_read_file('/etc/passwd')", dialect="postgres")
# raises ReadOnlyViolation: function pg_read_file() is not a read ...

For SQL you did not write yourself — anything an agent or a user supplied — use strict mode, which additionally default-denies every function the parser cannot name:

try:
    assert_read_only(agent_sql, dialect="postgres", strict=True)
except ReadOnlyViolation as e:
    return f"refused: {e}"          # never reaches the database
rows = cursor.execute(agent_sql)    # now safe to run

dialect accepts postgres / mysql / tsql (or mssql/sqlserver) / sqlite, and a few aliases; None parses dialect-agnostically. There is also is_read_only(sql, ...) -> bool if you prefer not to catch.

In an MCP server

@server.tool()
def query(sql: str) -> dict:
    assert_read_only(sql, dialect="postgres", strict=True)   # raises before we touch the DB
    return {"rows": run_query(sql)}

How it holds

Read-only is enforced in layers, and each is precise about the guarantee it carries:

  1. Lexical — over the statement with string literals blanked out (a keyword inside a quoted value is data, not code): single statement, SELECT/WITH head, no comments, no write/DDL keyword, no write-escalating lock hint, no call to a side-effecting function.
  2. Parse-tree (sqlglot) — the statement must parse, or it is refused (a guard that cannot read a query cannot vouch for it — and OPENROWSET is precisely what fails to parse). It must be a single read query with no INSERT/UPDATE/DELETE/DDL/EXEC/INTO node, and call no denylisted function.
  3. Strict mode — additionally refuses every function sqlglot cannot name. Its function registry is the allowlist: it knows the portable analytic functions and nothing that reads a file or dials out.

The guard is fuzzed (hypothesis), so a denylisted function is refused however it is cased, spaced, or argumented — it does not pass by memorising the corpus.

This is defence in depth, not a substitute for a least-privilege database login. A denylist is never provably complete; run your queries under a read-only DB role as well. The session layer (a read-only transaction, the login itself) is a property of the connection, so it is out of scope for a pure SQL-string guard — this library is the layer that inspects the statement.

Reproduce it

readonly-sql-guard benchmark          # the table above, computed live
readonly-sql-guard check "SELECT 1" --dialect postgres

Where this comes from

This is the read-only guard extracted from erp-report-engine — a read-only reporting and MCP layer for the database behind an ERP — generalised so any tool can adopt it. That project is the flagship instantiation; this is the reusable primitive.

Its sibling ossie-guard applies the same idea one layer up: it lints Apache Ossie semantic models for metrics whose dialects silently disagree, and for expressions that are not pure reads — reusing this denylist to do it.

License

MIT © Eren Gülmez

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

readonly_sql_guard-0.1.0.tar.gz (222.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

readonly_sql_guard-0.1.0-py3-none-any.whl (16.2 kB view details)

Uploaded Python 3

File details

Details for the file readonly_sql_guard-0.1.0.tar.gz.

File metadata

  • Download URL: readonly_sql_guard-0.1.0.tar.gz
  • Upload date:
  • Size: 222.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.13

File hashes

Hashes for readonly_sql_guard-0.1.0.tar.gz
Algorithm Hash digest
SHA256 f4572bfce8298386bccfd83de4f729acf3427e7f2e538ad8502704a189e24467
MD5 a5b3e898d25c2e66f43061fabf377bcd
BLAKE2b-256 8dc486f74983ec26d35e8b21a4bd144badfba44c5ce3c459f5614c2f4c6f1cd8

See more details on using hashes here.

Provenance

The following attestation bundles were made for readonly_sql_guard-0.1.0.tar.gz:

Publisher: publish.yml on gulmezeren2-byte/readonly-sql-guard

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file readonly_sql_guard-0.1.0-py3-none-any.whl.

File metadata

File hashes

Hashes for readonly_sql_guard-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 ca468427627075868c6c4a1018b7e436718ad8a08fcb77ac0509f5992e069320
MD5 21f3c9f3267c88beae170dac79b1be62
BLAKE2b-256 a39609e33229a815036377eaa1a99177083ae9dee25aa8bdb7eca442d1d11f18

See more details on using hashes here.

Provenance

The following attestation bundles were made for readonly_sql_guard-0.1.0-py3-none-any.whl:

Publisher: publish.yml on gulmezeren2-byte/readonly-sql-guard

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page