Home / Blog / Governance and AI

Governance and AI

Read-only on the SQL tree, not on the first keyword

Contents
  1. What the first word does not say
  2. The guard we kept
  3. What the guard does not replace

Written and tested with akko-mcp-trino 0.6.0. The current version is 0.7.2. See what changed.

An MCP server that exposes a SQL engine to agents almost always makes the same promise: it is read-only. The promise is easy to state and hard to keep. Most implementations we read keep it with a filter on the first keyword: if the query starts with SELECT, SHOW or EXPLAIN, it goes through; otherwise it is refused. We started the same way in akko-mcp-trino, then we tried to break our own guard. Here are the six defects we met, in our code or in someone else’s, and the shape the guard took afterwards.

What the first word does not say

A prefix filter answers the question “what does the text start with?”. The right question is “what will the engine do if I send it this text?”. The two questions only share an answer in the simple cases. Each defect below is a case where they part ways.

1. Stacked statements. SELECT 1; DROP TABLE t starts with SELECT. Depending on the driver and the engine, the second statement runs, is refused, or is silently dropped. A guard must not depend on that behaviour. It requires exactly one statement and refuses the rest.

2. A write inside a CTE. WITH w AS (DELETE FROM t) SELECT 1 starts with WITH, which the filter allows, and its root really is a SELECT. We found this case on 12 September on our own guard, with the adversarial proof we replay on a real cluster: the guard only looked at the root node of the tree, and the wrapped write reached Trino. It is not valid Trino SQL: in Trino’s grammar, the body of a CTE is a read query, and Trino refused it as a syntax error. But sqlglot parses it, with a SELECT at the root. A guard must not rely on the parser behind it: it walks the whole tree.

3. A write inside a subquery. SELECT * FROM (DROP TABLE t) x is the same idea in another form. It is not valid Trino SQL either, and Trino refuses it. sqlglot cannot parse it, so the guard refuses it under the principle of case 6. The conclusion is the same: walk the whole tree, and refuse as soon as a write node appears, wherever it sits.

4. EXPLAIN ANALYZE executes. On Trino, EXPLAIN plans a query without running it, but EXPLAIN ANALYZE runs it to measure the plan. EXPLAIN ANALYZE INSERT INTO t VALUES (1) starts with EXPLAIN and writes a row. We found this case on 13 September while reading another server’s guard, and ours had the same hole. The rule became: EXPLAIN is allowed if and only if the explained statement is itself a read, and ANALYZE is always refused.

5. Commands that are not called writes. GRANT, SET SESSION, CALL do not modify a table and do not appear in the usual list of forbidden verbs. GRANT changes who can read what. SET SESSION changes how the session’s next queries behave. CALL runs a procedure whose effects nobody knows in advance. A read-only guard refuses all three.

6. What the filter cannot read. /* note */ DELETE FROM t starts with /*. Text the parser does not understand, or that matches no known shape, must be refused, not handed to the engine in the hope that it decides. That is the fail-closed principle: uncertainty closes, it never opens.

The guard we kept

The current version fits in one function. It parses the text with sqlglot in the Trino dialect, requires a single statement, then walks the whole tree and refuses as soon as a node belongs to the family of writes and side effects (Insert, Update, Delete, Merge, Create, Drop, Alter, Truncate, Grant, Set, generic command). SHOW and DESCRIBE, which sqlglot does not structure, are accepted by their leading keyword, and EXPLAIN is re-analysed on the statement it wraps. Any parser exception is a refusal. It also refuses reads outside the governed tables: table functions and the system catalog. Here is the core of is_read_only_sql, as it stands in release 0.6.0.

statements = [st for st in sqlglot.parse(s, read="trino") if st is not None]
if len(statements) != 1:  # exactly one statement, never stacked queries
    return False
st = statements[0]
if isinstance(st, _READ_TYPES):
    # A read at the root is not enough: refuse any write nested anywhere.
    if any(isinstance(node, _WRITE_TYPES) for node in st.walk()):
        return False
    return not _reads_outside_governed_tables(st, session_catalog)

Each of the six cases above is a test in the repository, and the adversarial proof replays part of them on a real cluster at every release. We do not consider the list closed. That is why we published it.

What the guard does not replace

A read-only guard on the server is a second line. The first line remains the engine, which applies its own permissions to the person the agent works for. That is what akko-mcp-trino does by carrying the user’s identity all the way to Trino; we described it in our first article. If the engine receives every query under the right identity, a write that slipped past the guard would still be refused to anyone without the right to write. If the engine receives everything under a service account, the best guard in the world protects a door that no longer has a lock.

The guard has blind spots too. A connector’s metadata tables, such as "orders$files" or "orders$partitions" for Iceberg, read like ordinary tables of the same catalog, and the guard lets them through. The engine’s policy is where they must be covered.

The code is on GitHub under the Apache 2.0 licence, and the package installs with pip install akko-mcp-trino. If you break the guard, open an issue: that is exactly what we are looking for.

Share this articleReact on LinkedIn ↗
AKKO

AKKO is a French project building a sovereign data and AI platform, installed on your infrastructure. Every example in our articles is tested before publication.