The prompt is not a security boundary. Anything you write in the system prompt ("only query tenant_id = 41") is a suggestion the model can override, ignore or reformat. The filter has to be applied by code between the model and the database.
The pattern that works: the model submits SQL, the tool parses it, checks it against the policy, rewrites it, and only then sends it to the database. The General SQL Parser walkthrough describes this exactly: parse into an abstract syntax tree, reject anything that is not a single SELECT, remove columns outside the allowlist, add the tenant predicate as a node with correct parenthesization, regenerate the SQL, and re-parse before execution. It also names the trap: naive string appending can produce status = 'OPEN' OR status = 'PENDING' AND tenant_id = ?, which SQL evaluates as OPEN OR (PENDING AND tenant), leaking the other tenant's open rows.
Here is the shape of the check in Python, using a parser that returns an AST:
ALLOWED_STATEMENTS = {"select"}
def enforce(sql: str, principal: str, conn) -> str:
tree = parse(sql) # one statement only
if len(tree.statements) != 1:
raise PolicyError("one statement")
stmt = tree.statements[0]
if stmt.kind not in ALLOWED_STATEMENTS:
raise PolicyError("select only")
if stmt.has_forbidden_nodes(): # DDL, functions, INTO
raise PolicyError("forbidden construct")
columns = load_allowlist(conn, principal) # {(table, column), ...}
for col in stmt.output_columns():
if (col.table, col.name) not in columns:
raise PolicyError("column not allowed")
for f in stmt.from_items(): # joins, subqueries included
if f.table not in {t for t, _ in columns}:
raise PolicyError("table not allowed")
for f in stmt.from_items():
pred = load_row_policy(conn, principal, f.table)
if pred:
stmt.where = and_expr(stmt.where, eq(lit(pred.tenant_column), bind(pred.value)))
return tree.to_sql()
Two details are worth calling out. First, the filter is applied to every FROM item, not just the outer query. A subquery that selects from orders without the predicate is the same leak as a top-level one. Second, the tenant value goes in as a bind parameter, not inline text, so the value is never part of the string the model produced.
If your database supports row-level security, you can push the predicate into the database instead of rewriting. PostgreSQL's row security policies evaluate the policy expression for every row before the user's own conditions, and enable them with ALTER TABLE ... ENABLE ROW LEVEL SECURITY. The catch, as the stack overflow question on RLS with session variables shows, is that the table owner and BYPASSRLS roles skip policies, so the agent must connect as a role that is subject to them. Session variables set with set_config work but, per the role comparison, SET leaks through transaction-mode poolers while SET LOCAL does not, so set the tenant inside the transaction and nowhere else. The pgedge writeup on session variables shows the set_config('myapp.tenant', '41', true) call and the current_setting('myapp.tenant') read a policy can use.
The choice between rewriting in the tool and RLS in the database comes down to where the allowlist lives.
| Criterion | Tool-side rewrite | Database row-level security |
|---|
| Where the policy lives | Your policy tables | CREATE POLICY in the database |
| Column allowlist | Enforced during rewrite | Grant column privileges separately |
| Survives if the tool is bypassed | No, only applies to tool traffic | Yes, for any connection as that role |
| Handles joins and subqueries | You walk the AST | The database applies it everywhere |
| Operates cleanly behind PgBouncer | Yes | Only with SET LOCAL per transaction |
| Auditability | You log every decision | Check pg_policies and logs |
| Effort | Parser and test suite | Policy SQL and role discipline |
Most teams that start here use both: the database policy is the backstop, and the tool rewrite lets the agent produce readable errors and keeps the column allowlist in one place.