DEV Community

Cover image for Your text-to-SQL agent picks tables before security runs. Here’s the fix.

Your text-to-SQL agent picks tables before security runs. Here’s the fix.

Ashish sinha on September 07, 2026

I build text-to-SQL agents on Oracle and Postgres for a living. Every one of them had the same bug, and it wasn’t in my code. It was in the order o...
Collapse
 
ahmetozel profile image
Ahmet Özel •

Apply identity at selection rather than at execution is the same lesson RAG teams learn the hard way: filter before retrieval, not after, or the permission boundary holds while the answer still leaks. Here it leaks as a confident no records found, which is arguably worse than an error because the user believes it. Two adjacent cases worth handling explicitly. Column-level restrictions inside a table the user can otherwise read - showing the schema with the salary column in it means the model will happily reference it, so the redaction has to happen in the schema the model sees, not just in the result set. And schema metadata itself is disclosure: a table named hr_compensation_2027_layoffs tells the reader something even when zero rows come back. Absence rather than low ranking is the right call for both.

Collapse
 
ashish_sinha_5241c7673d93 profile image
Ashish sinha •

Both points are right, and the second one is why "absent, not de-ranked" is the rule — a table name is a disclosure on its own. Column-level was a real gap when you wrote this; it isn't now: cat.restrict_column("hr_employee", "home_address", ["hr_admin"]) removes the column from the DDL the model sees (not commented, not renamed) for callers without the role, and the MCP describe_object honours it too. Landing in 0.1.1 this week. Thanks for the push.

Collapse
 
ahmetozel profile image
Ahmet Özel •

Removing home_address from both the DDL and describe_object closes the specific disclosure path I raised. I would exercise those two outputs in alternating role contexts, rather than only testing each role against a fresh catalogue.

That catches metadata cached under an authorized caller and later reused for a restricted one. It is also worth checking foreign-key descriptions and sample queries for references to hidden columns; the visible table definition can be correct while another schema-discovery surface still names the removed field.

Thread Thread
 
ashish_sinha_5241c7673d93 profile image
Ashish sinha •

Good points, and two of them are already covered:

  • A foreign-key line that names a hidden column is dropped from the DDL, not just the column itself.
  • Since 1.2.0 (released today), the ontology's meaning lines get the same check: if a definition mentions a hidden table or column, the line is left out for that caller. run_query now also refuses a hidden column, by name or through SELECT *.

The alternating-roles test is the one I don't have: authorized caller, then restricted caller, on the same catalogue object, to catch anything cached in between. Adding it.

Thread Thread
 
ahmetozel profile image
Ahmet Özel •

Thanks for clarifying that the foreign-key references and ontology meaning lines are already filtered. The run_query check also makes the execution boundary much stronger than hiding schema descriptions alone.

For the alternating-role test, I would run both orders: authorized then restricted, and restricted then authorized. The first detects a disclosure carried through cached metadata; the second can reveal stale restrictions incorrectly suppressing a legitimate query. Using the same catalogue instance in both sequences should make any caller-specific state that escaped its intended scope easier to observe.

Thread Thread
 
ashish_sinha_5241c7673d93 profile image
Ashish sinha •

Done, both orders, on one catalog instance: github.com/ashishsinha1602/schemag...

Authorised then restricted checks for disclosure through anything the first call warmed. Restricted then authorised checks for a restriction going stale and hiding a table it shouldn't. A third test compares each caller's result against fresh catalogs, so order can't shape the answer.

To be sure they test the right thing, I broke the code on purpose: a select() cache keyed by the question only, not the caller. Both ordering tests failed. Thanks for the push on the second direction; I would have tested only the first.

Collapse
 
p_o_26e854a54d851cd606f08 profile image
P O •

The schema boundary is a good place to fail closed. I’d also keep the generated SQL and selected tables in the audit record, because that makes a bad join or overbroad request much easier to replay.

Collapse
 
ashish_sinha_5241c7673d93 profile image
Ashish sinha •

Agreed — that's the record that makes a bad join replayable. select() now returns .to_dict(): who asked, the roles, and exactly which objects and columns the model was shown with the reason each was chosen. Log it next to the generated SQL and you can replay any answer. In 0.1.1.