In April an AI agent deleted a company's production database and HN spent 1,000 comments on it. The details matter: the agent was in Cursor, it found an API token that could do anything to staging and production, and it sent one curl that deleted the volume. The backups lived on the same volume.
HN's verdict wasn't "the model was bad". It was: you can't prompt an agent into safety. The limit has to live in something the agent can't talk its way past. Scoped credentials, a permission layer, a door with a lock on it.
That lesson has a quieter SQL version that doesn't make HN, because nothing gets deleted.
The dangerous query is a SELECT
Point an agent at a shared Trino cluster with a read-only user and you've handled the headline risks: no DROP, no DELETE. What's left is SQL that is perfectly valid and perfectly read-only:
- a missing partition filter that turns into a full scan and ties up every worker;
- a join on the wrong column, or no column at all, that builds hundreds of millions of rows in worker memory while everyone else's queries wait behind it;
- a retry loop that runs it again because the first attempt "timed out".
On self-hosted Trino nobody gets a $500 bill for that. What happens is worse to explain: the dashboards are slow, the other team's pipeline misses its window, and the cause was a question someone asked a chatbot.
"Doesn't Trino already limit this?"
It does, and you should keep those limits. But look at when they act:
| Acts | Catches a cross join that reads little | |
|---|---|---|
query.max-scan-physical-bytes |
terminates the query once it has scanned that much | no: it counts bytes read, not rows built |
query.max-execution-time |
terminates it after it has run that long | only after it held the cluster that long |
Resource groups (hardPhysicalDataScanLimit, softMemoryLimit, …) |
queue the group's next queries once it's over its share | no: the running query continues |
All three act once the query is already on the cluster. And a scan cap can't see the worst shape at all. Here's one measured on Trino 476:
SELECT o.orderkey, l.partkey
FROM tpch.tiny.orders o CROSS JOIN tpch.tiny.lineitem l
LIMIT 10
By Trino's own plan it reads 676,575 bytes, so any scan limit above 0.7 MB lets it through. It would build 902,625,000 rows. The LIMIT 10 doesn't help: the rows are built before anything is returned.
Pricing the query before it runs
I built Lagaam to be the lock on that door. It's an MCP server, and the agent's only way to reach the engine is through its three tools (list_catalogs, describe_table, query_data). Before any SQL runs, Lagaam asks the engine what it would cost:
-
EXPLAIN (TYPE IO)for the bytes it would scan, -
EXPLAIN (TYPE LOGICAL)for the widest row count any step would build, which is the number a cross join blows up and aLIMITcan't hide.
Over budget, it's refused. What the agent gets back for the query above:
This query would build 902,625,000 rows at its widest step, over your budget of 50,000,000. These are rows the engine materializes internally, not rows returned, so a LIMIT will not help — join on a column with more distinct values, or filter each side before the join.
That last sentence is the point. The agent can read the refusal, fix the SQL and try again. It isn't a stack trace for a human to decode at 2am.
The rest is the boring gate you'd want anyway:
- Read-only in the syntax tree. Exactly one
SELECT, no DDL/DML, noSELECT *, and aLIMITinjected when missing. Validated SQL is re-rendered, so what runs is exactly what was checked. - A per-agent table grant. Tables outside it are refused, and it won't start without a grant.
- Fail closed. No table statistics means no estimate, and no estimate means no query.
- An audit line per call: who asked what, allowed or denied, and why.
Apache Pinot gets the same gate. It was harder, because Pinot's planner has no cost estimate (every scan says rowcount 100), so the Pinot quote is built from segment metadata and the broker's own segment-pruning counts. That's a post of its own.
Does it catch what agents actually write?
I wrote 11 queries an agent plausibly sends (full scans, SELECT *, a DROP and a DELETE, multi-statement injection, a write hidden in a CTE, oversized and self-doubling joins, a table outside the grant) plus one well-scoped control query. A raw MCP wrapper that forwards SQL to Trino submits every one of them. Lagaam stops 11 of 11 before execution, and the control query still runs. The table and the script to reproduce it are in the repo.
What it doesn't do
- It wouldn't have saved that production database: that was an API token, not SQL. Lagaam guards the query path, and your credentials are still your problem.
- It prices from the planner, so it's only as good as your statistics. Run
ANALYZE; tables without stats are refused rather than guessed. - It's a gate, not a scheduler: keep resource groups as the backstop for everything that does run.
- It doesn't rewrite or retry anything itself. The agent decides what to do with a refusal.
Try it
It's on PyPI and in the official MCP Registry (io.github.lagaam-ai/lagaam):
TRINO_HOST=your-trino LAGAAM_ALLOWED_TABLES=hive.sales.orders uvx lagaam
In Claude Code, install it as a plugin:
/plugin marketplace add lagaam-ai/lagaam
/plugin install lagaam@lagaam
Or in any MCP client:
{ "mcpServers": { "lagaam": { "command": "uvx", "args": ["lagaam"],
"env": { "TRINO_HOST": "your-trino", "LAGAAM_ALLOWED_TABLES": "hive.sales.orders" } } } }
Apache 2.0, self-hosted, Python. If you're letting agents near a shared Trino or Pinot cluster, I'd like to hear what broke. Issues or DMs are open.
Top comments (0)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.