mutating-sql
Mutating SQL blocked
The sections below are generated from the CLI (methodology 0.3-draft, CLI 0.3.0); the same text is printed by sealmark explain mutating-sql.
- pack
db- weight
- critical
- scope
- server (tested as a role that can write)
- decided by
- db-stateresponse
- since
- 0.1-draft (methodology 0.3-draft)
- config keys
checks.mutating-sql.corpus.addchecks.mutating-sql.corpus.removechecks.mutating-sql.enabledchecks.mutating-sql.severity
Probes
stacked statements (DROP)SELECT 1; DROP TABLE audit_probe corestacked statements (INSERT)SELECT 1; INSERT INTO probe_t (id, v) VALUES (9002, 'stacked') coreCOMMIT then write (read-only transaction escape)COMMIT; INSERT INTO probe_t (id, v) VALUES (9005, 'commit-escape') coreROLLBACK then write (read-only transaction escape)ROLLBACK; INSERT INTO probe_t (id, v) VALUES (9014, 'rollback-escape') coreEND then write (read-only transaction escape)END; INSERT INTO probe_t (id, v) VALUES (9015, 'end-escape') coreABORT then write (read-only transaction escape)ABORT; INSERT INTO probe_t (id, v) VALUES (9016, 'abort-escape') corecase and comment obfuscation (INSERT)/*x*/ iNsErT INTO probe_t VALUES (9008,'obf') coredata-modifying CTE (DELETE)WITH d AS (DELETE FROM probe_t WHERE id = 9003 RETURNING id) SELECT count(*) FROM ddata-modifying CTE (INSERT)WITH i AS (INSERT INTO probe_t (id, v) VALUES (9004, 'cte') RETURNING id) SELECT * FROM ifunction with side effects (defeats any prefix filter by construction)SELECT sm_mutate()anonymous DO blockDO $$ BEGIN INSERT INTO probe_t VALUES (9007,'do'); END $$TRUNCATETRUNCATE probe_tDDL (CREATE TABLE)CREATE TABLE sm_ddl_probe (x int)DDL via SELECT INTOSELECT 1 INTO sm_ddl_probePREPARE then EXECUTEPREPARE p AS INSERT INTO probe_t VALUES (9011,'prep') -> EXECUTE psession state (disable read-only, then write)SELECT set_config('default_transaction_read_only', 'off', false) -> INSERT INTO probe_t (id, v) VALUES (9006, 'session')PREPARE and EXECUTE in one callPREPARE p2 AS INSERT INTO probe_t VALUES (9012,'prep1'); EXECUTE p2function with side effects in FROMSELECT * FROM sm_mutate()EXPLAIN ANALYZE runs the statementEXPLAIN ANALYZE INSERT INTO probe_t (id, v) VALUES (9013, 'explain')
core marks the canonical probes of the check; the others are extra coverage. How they decide the verdict is stated under How the verdict is established.
Settings and thresholds
Every check also takes checks.mutating-sql.enabled and checks.mutating-sql.severity; they are described once in the configuration reference.
This check has no numeric threshold.
| Key | Default | Standard | Meaning |
|---|---|---|---|
checks.mutating-sql.corpus.add | [] | locked | Extra probes for mutating-sql, validated against the pack's corpus schema. Appends across layers. Any addition makes the run CUSTOM. |
checks.mutating-sql.corpus.remove | [] | locked | Probe ids or exact strings to drop from the mutating-sql corpus. Appends across layers. Any removal makes the run CUSTOM. |
How the verdict is established
- Scoring. critical and scored: pass = 1 point, part = 0.5, fail = 0. A fail caps the pack's grade at D, whatever the other checks scored.
What to fix
Parse the statement (do not prefix-match); reject stacked statements, every transaction terminator that can end a read-only wrapper (COMMIT, ROLLBACK, END and ABORT, each followed by a write), data-modifying CTEs, DO blocks and SELECT INTO; run reads in a READ ONLY transaction as a role without write grants. Filtering the word COMMIT alone leaves the other three open. A SQL tool that has another required parameter is graded with it filled; if the control read fails, name the parameter under sql_tools: [{tool, arg, args: {<param>: <value>}}].
False positives
Only a change the harness finds in the database counts as executed. There is one transaction-escape case per terminator keyword Postgres accepts (COMMIT, ROLLBACK, END, ABORT); COMMIT WORK and COMMIT TRANSACTION share the keyword a filter would match, and ... AND CHAIN starts a new transaction with the same read-only characteristic, so neither is an escape and neither is a case. The side-effect function cases (in the select list and in FROM) defeat any prefix filter by construction, so they are not core cases and only cost half a point; so do the single-call PREPARE and EXECUTE and the EXPLAIN ANALYZE of a write.
False negatives
The corpus is finite and published. A server that blocks every listed form can still be bypassed by one that is not listed.
Notes
A database MCP server that calls itself read-only usually enforces that with a text filter or a read-only transaction. Both have well-known bypasses. This check sends 16 mutation forms as the admin role and reads the database afterwards to see whether any of them took effect.
Probe details#
Run as sm_admin, once per SQL tool. Before every case the harness resets the scratch state over its own connection (truncates probe_t, restores tenant names, drops sm_ddl_probe, recreates audit_probe if a probe dropped it). The same admin control read as in read-only must pass first. Where a case has several steps, each step is its own tool call, run in order. The statements are in the Probes list above, in this order; the numbers below match it, and the comment lines the SQL carries are not sent.
After each case the harness reads the database for the effect below. The server's response text is recorded in the evidence but does not decide.
| # | Case | Effect looked for |
|---|---|---|
| 1 | stacked statements (DROP) | audit_probe no longer exists |
| 2 | stacked statements (INSERT) | probe_t row 9002 exists |
| 3 | COMMIT then write (read-only transaction escape) | row 9005 exists |
| 4 | case and comment obfuscation (INSERT) | row 9008 exists |
| 5 | data-modifying CTE (DELETE) | row 9003 is gone (seeded first) |
| 6 | data-modifying CTE (INSERT) | row 9004 exists |
| 7 | function with side effects | probe_t has a row with v = 'fn' |
| 8 | anonymous DO block | row 9007 exists |
| 9 | TRUNCATE | row 9009 is gone (seeded first) |
| 10 | DDL (CREATE TABLE) | table sm_ddl_probe exists |
| 11 | DDL via SELECT INTO | table sm_ddl_probe exists |
| 12 | PREPARE then EXECUTE | row 9011 exists |
| 13 | session state (disable read-only, then write) | row 9006 exists |
| 14 | PREPARE and EXECUTE in one call | row 9012 exists |
| 15 | function with side effects in FROM | probe_t has a row with v = 'fn' |
| 16 | EXPLAIN ANALYZE runs the statement | row 9013 exists |
Cases 1 to 4 are the core cases: the canonical bypasses of a "read-only SQL tool".
Notes on the corpus:
- Cases 1 and 2 need multi-statement support, which only the simple-query protocol provides. A server that uses the extended protocol rejects multiple statements, so it legitimately "blocks" them.
- Case 3 works only if the server wraps queries in a transaction and does not stop
COMMIT. - Case 13 relies on a session setting outliving the call, which a rollback undoes, and on both steps reaching the same database session. A server that opens a new connection per call blocks it by accident, and the corpus cannot tell the difference.
sm_mutate()is a volatile SQL function in the seed that inserts(9100, 'fn')intoprobe_t. Case 7 calls it in the select list and case 15 in theFROMclause: a filter that blocks function calls in the select list can still let the second form through.- Cases 12 and 14 are the same statement sent two ways. A server that opens a new connection per call blocks the two-call form by accident (the prepared statement does not exist on the next connection) but not the single-call form.
- Case 16 is a read form:
EXPLAIN ANALYZEexecutes the statement it explains, so a filter that allows everything starting withEXPLAINlets a write through.
Thresholds#
| Verdict | When |
|---|---|
| pass | all 16 forms had no effect, on every SQL tool |
| partial | the four core forms were blocked, but an indirect form changed the database |
| fail | a core form (stacked statement, COMMIT escape or obfuscated write) changed the database |
| inconclusive | the server stopped answering part-way through the corpus, and no core case had taken effect |
When the server goes quiet part-way, the remaining cases are not run. A core effect seen before that still gives fail. A non-core effect seen earlier stays in the evidence, but the verdict is inconclusive.
Why the split: stacked statements, a COMMIT that ends a read-only wrapper, and case or comment obfuscation defeat naive filters and read-only wrappers alike. A server that fails them is not read-only in any meaningful sense. Cases 5 to 16 are blocked reliably only by a read-only transaction or a read-only role, not by text filtering, so a miss there indicates a weaker enforcement design rather than a bypass of an advertised filter. The split is a judgment call and may change before 1.0.
Limits#
- The corpus is finite:
COPY ... TO PROGRAM, large objects,pg_read_file, sequences (nextval), advisory locks, extensions and privilege escalation are not tried, and the strings are published, so a server that matches the harness's exact strings is not caught. - A tool you list under
sql_toolsis judged as a read tool, so a server with a legitimate write tool listed there fails. - The state reader is a separate connection, so a false positive from a side route the server cannot control is very unlikely. A different Postgres version may behave differently.