the gapwhat I doworkblogbook a call rostyslab21@gmail.com

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

sqlwhat the model emitted
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.

Fix

the model’s SQL only ever runs as DECLARE ... CURSOR FOR <query>. A cursor accepts a single query expressionSET isn’t one, so it’s rejected before RLS is even consulted. → test: tenant-id-guc-override.

02multi-statement smuggling

sqlwhat the model emitted
SELECT 1; SET app.tenant_id = 'globex'; SELECT * FROM invoices;

Batch the SET between innocent statements.

Fix

the cursor path takes exactly one statement; the extended-query protocol rejects the batch. → test: multi-statement-batch.

03a write disguised as a read

sqlwhat the model emitted
WITH gone AS (DELETE FROM invoices WHERE tenant_id='acme' RETURNING *)
SELECT * FROM gone;

A data-modifying CTE is a SELECT on its face.

Fix

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

sqlwhat the model emitted
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.

Fix

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

sqlwhat the model emitted
RESET ROLE;                     -- or
SET ROLE postgres;              -- or
SET SESSION AUTHORIZATION dba;

If any of these land, RLS is moot.

Fix

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.

the hardened version

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.

stay in the loop

get new posts by email