I attacked my own AI SQL agent: 5 ways an LLM bypasses row-level security
You give an LLM read access to your Postgres, turn on row-level security, scope it to tenant_id, and feel safe. You shouldn’t. RLS scopes rows by a setting the query can change. The prompt can’t stop it — the connection has to.
Here are five queries my own agent produced that walked straight past RLS, and the fix for each. Every one has a test in the repo that goes red without the fix: github.com/rostyslab21/pg-rls-agent.
The setup
agent role, RLS policy USING (tenant_id = current_setting('app.tenant_id')), I SET app.tenant_id = 'acme' per request. Looks airtight.
01the tenant id is a USERSET GUC
SET app.tenant_id = 'globex';
SELECT * FROM invoices;app.tenant_id is a USERSET GUC — the same role that reads it can rewrite it. RLS then legally returns Globex’s rows. A READ ONLY transaction does not block SET.
the model’s SQL only ever runs as DECLARE ... CURSOR FOR <query>. A cursor accepts a single query expression — SET isn’t one, so it’s rejected before RLS is even consulted. → test: tenant-id-guc-override.
02multi-statement smuggling
SELECT 1; SET app.tenant_id = 'globex'; SELECT * FROM invoices;Batch the SET between innocent statements.
the cursor path takes exactly one statement; the extended-query protocol rejects the batch. → test: multi-statement-batch.
03a write disguised as a read
WITH gone AS (DELETE FROM invoices WHERE tenant_id='acme' RETURNING *)
SELECT * FROM gone;A data-modifying CTE is a SELECT on its face.
two independent gates — the transaction is READ ONLY (blocks the write) and the cursor rejects it. Defense in depth, not one check. → test: data-modifying-cte.
04reading around RLS via unprotected surfaces
SELECT tablename FROM pg_tables;
SELECT * FROM some_table_without_rls;RLS is per-table. Catalogs, views, and any table where nobody ran ENABLE ROW LEVEL SECURITY are open doors for enumeration.
least privilege. The agent role is GRANTed only the RLS-protected tables it needs; schema introspection runs in a separate privileged step, not under the agent role. → test: least-privilege-introspection.
05role / authorization escape
RESET ROLE; -- or
SET ROLE postgres; -- or
SET SESSION AUTHORIZATION dba;If any of these land, RLS is moot.
agent is a member of nothing privileged, so SET ROLE to a superuser fails on its own — and the cursor rejects SET/RESET regardless. → test: role-escalation.
Bonus — cost, not confidentiality
An LLM can emit a query that never bypasses RLS but scans forever and burns tokens. Not an isolation break, but a real DoS. The repo caps output tokens per process; the paid version meters per-tenant spend.
What actually held
Notice the pattern: not one of the fixes is a prompt. Every guarantee is structural — cursor-only execution, READ ONLY, least-privilege grants. The model is treated as hostile input, because that’s what it is.
The free MIT core with all five tests is on GitHub.
Wiring this into a real multi-tenant system?
The hardened version — many tenants, guarded writes, a cost dashboard, the full bypass suite — is what I'm building next.