Guardrails para text-to-SQL: cómo dejar que un LLM consulte tu base de datos sin que la rompa

Un pedido ambiguo, una instrucción inyectada, una respuesta llena de datos personales: los fallos típicos de un LLM que escribe SQL, qué guardrail frena cada uno y cómo agregar el que falta sin trabar al agente.

10 min de lectura
TEXT-TO-SQL GUÍA

Tu equipo agrega un asistente al panel interno: alguien escribe una pregunta, un LLM la traduce a SQL y la respuesta aparece en segundos. Funciona tan bien que pronto le piden algo más que preguntas: «marca como reembolsados los pedidos de marzo del cliente 42». El modelo escribe un UPDATE, pierde el filtro por el camino y esta es la sentencia que llega a la base:

UPDATE orders SET status = 'refunded';

Nadie atacó nada y el modelo no tiene mala intención. Escribió SQL válido para un pedido que entendió a medias, y nada entre el modelo y la base revisó la sentencia antes de que se ejecutara. El escenario es ilustrativo, pero el patrón está documentado en casos públicos.

La documentación de LangChain lo advierte en su tutorial de agentes SQL: ejecutar SQL generado por un modelo tiene riesgos inherentes, así que hay que limitar los permisos de la conexión tanto como se pueda y agregar una validación propia antes de ejecutar ese SQL (LangChain, «Build a SQL agent»). Este artículo trata de esa validación: qué tiene que frenar, qué guardrails se usan hoy y dónde se queda corto cada uno, y cómo agregarla donde tu agente ya ejecuta SQL.

Nuestra posición. Construimos Vericto, un firewall SQL que revisa cada sentencia antes de que llegue a la base. Por eso comparamos enfoques, no marcas, y decimos también qué queda fuera de una capa SQL.

Cuatro formas en que falla un text-to-SQL

1. El pedido ambiguo. «Borra los pedidos de prueba», «actualiza el precio», «limpia las sesiones viejas». El modelo traduce la intención y a veces se queda sin la condición: un DELETE o un UPDATE sin WHERE, o con un WHERE 1=1, que es lo mismo. Si la herramienta puede escribir, cambia todas las filas de la tabla.

2. La instrucción inyectada. El texto que lee el modelo no siempre lo escribe tu usuario: puede venir en la pregunta, en un ticket o en una fila de la base. Una instrucción escondida ahí puede convertir una consulta en … OR 1=1, en un DROP TABLE o en la lectura de una tabla que nadie pidió. No es teórico: SQLDatabaseChain de LangChain (CVE-2023-36189) y los motores text-to-SQL de LlamaIndex (CVE-2024-23751) recibieron CVEs por esto, e investigadores de INESC-ID caracterizaron estos ataques «prompt-to-SQL» en aplicaciones con LangChain (arXiv 2308.01990).

3. La respuesta que expone datos. «¿Qué clientes se quejaron la semana pasada?» Para responder, el modelo lee nombres, emails y teléfonos, y esos valores entran en su contexto, en los logs de la conversación y en la respuesta que ve quien preguntó, tenga o no permiso para verlos. Ninguna sentencia destructiva, ningún permiso de la base violado, y aun así es un incidente de datos.

4. La lectura sin límite. Un SELECT * sin LIMIT sobre la tabla de pedidos funciona en desarrollo y en producción trae la tabla entera: carga sobre la base, una ventana de contexto llena y, otra vez, muchos más datos de los que la pregunta necesitaba.

Los guardrails habituales y dónde se quedan cortos

Ninguno de estos enfoques sobra. La pregunta es cuál de los cuatro fallos frena cada uno.

Guardrail Qué frena Dónde se queda corto
Instrucciones en el prompt («solo SELECT», «nunca borres») Ayudan a que el modelo acierte la mayoría de las veces Son un pedido, no un control: un pedido ambiguo o una instrucción inyectada pueden ganarles. OWASP recomienda decidir la autorización en los sistemas que reciben la acción y no dejar que el LLM decida si está permitida (LLM06:2025, Excessive Agency)
Rol de base de datos read-only Toda escritura y todo DDL, si de verdad es read-only (fallo 1) No decide qué tablas o columnas lee el agente dentro de lo que el rol ve, ni cuántas filas (fallos 3 y 4). Y muchos agentes útiles necesitan escribir
Filtro de palabras clave o regex («tiene que empezar con SELECT») Lo obvio: un DROP al principio del texto Decide sobre el texto, no sobre la estructura: un DELETE apilado después de un SELECT en el mismo string o un OR 1=1 pasan, y no sabe qué tablas ni qué columnas se leen
Un segundo LLM que revisa la query Errores que otro modelo puede razonar Es probabilístico: la misma query puede recibir respuestas distintas, y el texto inyectado que engañó al primer modelo también lo lee el segundo
Una revisión determinista de cada sentencia Los cuatro fallos: lo destructivo, las tautologías, las columnas sensibles y las lecturas sin límite, decididos sobre el árbol sintáctico de la sentencia Solo ve SQL: lo que el agente ejecuta por otra vía (Python, una API) queda fuera. Y protege lo que pasa por ella: la garantía completa exige que la base solo acepte conexiones a través del control

OWASP también incluye, entre los ejemplos de LLM05:2025 (Improper Output Handling), consultas SQL generadas por un LLM que se ejecutan sin la parametrización adecuada y terminan en SQL injection. La combinación que funciona es defensa en profundidad: un rol con privilegios mínimos debajo y, encima, un control que revisa cada sentencia fuera del modelo y fuera del alcance del agente. Las instrucciones del prompt quedan como lo que son: una ayuda para que el modelo acierte.

Un guardrail que el agente puede usar

Un control que solo dice «no» tiene un costo: el agente se traba, el usuario recibe un error opaco y alguien termina apagando el guardrail para que la función vuelva a andar. Lo que sirve es un veredicto que el modelo pueda leer: qué regla rompió la sentencia y cómo se escribe la versión segura.

Esto es lo que recibe un agente de LangChain cuando Vericto bloquea el UPDATE del principio. No es una excepción que tumba la conversación: es el resultado de la herramienta SQL, en un texto que el modelo entiende:

Error: Vericto blocked this query (VERICTO-030, severity critical). It was NOT executed.
Suggested fix: UPDATE orders SET {col} = $1 WHERE id = $2
Rewrite the query following the suggested fix and try again.

El modelo vuelve a intentarlo con la condición que había perdido, y esta vez la sentencia pasa:

UPDATE orders SET status = 'refunded'
 WHERE customer_id = 42
   AND created_at >= '2026-03-01' AND created_at < '2026-04-01';
-- ALLOWED

La misma sentencia recibe siempre el mismo veredicto, porque la decisión sale de reglas sobre la estructura de la query y no de un modelo. Eso permite confiar en el control y, al mismo tiempo, explicar cada decisión a quien tenga que revisarla.

Cómo lo resuelve Vericto

Vericto revisa cada sentencia SQL antes de que llegue a la base: la parsea a su árbol sintáctico, la evalúa contra las reglas de tu workspace y devuelve un veredicto (ALLOWED, FLAGGED o BLOCKED) con la regla, el nodo del árbol que la disparó y una corrección sugerida. Para un text-to-SQL, eso cubre los cuatro fallos:

  • Lo destructivo se bloquea. DELETE y UPDATE sin WHERE o con un WHERE siempre verdadero (VERICTO-001, 003, 030 y 042), DROP, TRUNCATE y el resto de las 42 reglas estándar, aunque la sentencia venga detrás de un COMMIT; en el mismo string.
  • Las tautologías de una inyección se bloquean. Un … OR 1=1 en cualquier nivel de la query da BLOCKED con VERICTO-090.
  • Los datos sensibles llegan enmascarados. En el dashboard etiquetas las columnas con datos personales o de pago con mask, block o flag. Con mask, el agente recibe a***@example.com en lugar del email real, y el dato nunca entra en el contexto del modelo (VERICTO-085).
  • Cada agente queda en su allowlist. Las tablas y columnas que su tarea necesita, en lectura o en lectura y escritura; todo lo demás se deniega con VERICTO-087. Una instrucción inyectada que pide integration_tokens no llega a leer nada.
  • Las lecturas sin límite se marcan. Un SELECT sin LIMIT (VERICTO-050) o un SELECT * sin WHERE (VERICTO-051) se ejecutan y quedan marcados, para que veas qué agente lee tablas enteras.

Donde tu agente ya ejecuta SQL

  • En la herramienta SQL del agente: los SDKs de Python y Node. vericto.langchain y vericto.llamaindex (Python 3.10+) y @vericto/sdk/langchain (LangChain.js) reemplazan la base de datos SQL del framework por una que consulta a Vericto antes de cada ejecución. Un bloqueo vuelve al modelo como el mensaje de arriba y una columna enmascarada se ejecuta reescrita. Paquetes en PyPI y npm.
  • En cualquier otro stack: la Runtime API. Tu código envía la query a POST /api/v1/proxy/query con una API key por agente y la ejecuta solo si la respuesta lo permite, usando rewritten_query cuando viene. En Postgres, MySQL, Oracle, SQL Server, Snowflake y SQLite.
  • En la conexión: el proxy TCP. En Postgres y MySQL cambias el host del connection string y nada más. Es el modo que está en línea, así que lo que decide es lo que la base recibe: un bloqueo llega como un error nativo del driver y una columna enmascarada llega enmascarada. Si la base solo acepta conexiones desde el proxy, el agente no tiene cómo saltarse el control.
  • Mientras lo construyes: MCP, CLI y GitHub Action. Si un agente de código escribe las queries o las migraciones de tu función, conéctalo al servidor MCP (https://mcp.vericto.com/mcp) para que valide el SQL con check_sql antes de proponerlo. El SQL que queda en el repositorio se revisa en cada pull request con vericto check o la acción vericto/check-sql@v1.
  • En el dashboard. Cada veredicto queda en el audit trail con la regla que lo disparó. Ahí etiquetas las columnas sensibles, armas las allowlists y decides cuándo pasar de observar a bloquear.

Si Vericto no responde, el SDK no ejecuta la query: es fail-closed por defecto, y con fail_mode="open" la ejecuta sin evaluar y lo avisa. Los valores de los parámetros nunca se envían a Vericto.

Empieza observando

Un guardrail nuevo en una función que ya está en uso da miedo por una razón: bloquear algo legítimo. Por eso un workspace puede empezar en modo observe: Vericto registra todo y muestra lo que habría bloqueado, sin bloquear nada. A los 7 días la protección se activa sola, o antes, cuando tú decidas. Las allowlists por agente siguen el mismo camino: observe, una simulación contra las queries reales del agente y enforce.

El plan Free es permanente y no pide tarjeta de crédito: incluye las 42 reglas estándar, la Runtime API, los SDKs y el servidor MCP. Las columnas sensibles y las allowlists por agente están en Team y Enterprise, y Team tiene 14 días de prueba.

Antes de lanzar un text-to-SQL

  1. Un rol con privilegios mínimos. Read-only si la función solo responde preguntas; nunca la credencial de la aplicación ni un superusuario.
  2. Una identidad por agente. Un usuario de base o una API key propia, para saber quién ejecutó qué y darle a cada uno su allowlist.
  3. Cada sentencia revisada antes de ejecutarse, fuera del modelo. Sobre su estructura, no sobre el texto, para que WHERE 1=1 reciba el mismo veredicto que la falta de WHERE.
  4. Columnas sensibles enmascaradas o bloqueadas antes de que lleguen al contexto del modelo.
  5. Lecturas acotadas. LIMIT en lo que el agente lee y solo las tablas que la función necesita.
  6. Un bloqueo que el modelo pueda usar. La regla y la corrección sugerida, para que el agente se corrija en lugar de trabarse.
  7. Cada decisión registrada, incluidos los bloqueos: lo que un agente intenta después de una negativa es justo lo que quieres ver.

Pruébalo en una base de staging

  1. Crea una cuenta gratis y un workspace, y elige el modo observe: nada se bloquea mientras miras.
  2. Registra la base que usa tu función y crea para el agente una API key con el scope proxy:evaluate.
  3. Instala el SDK y cambia la base de datos del agente:
    pip install "vericto[langchain]"
    export VERICTO_API_KEY=vtro_...            # API key with the proxy:evaluate scope
    export VERICTO_DATABASE_ID=<database-id>   # the database you registered
    from langchain_community.agent_toolkits import SQLDatabaseToolkit, create_sql_agent
    from vericto.langchain import VerictoSQLDatabase
    
    # Was: db = SQLDatabase.from_uri("postgresql+psycopg://app@db/app")
    db = VerictoSQLDatabase.from_uri("postgresql+psycopg://app@db/app")
    agent = create_sql_agent(llm, toolkit=SQLDatabaseToolkit(db=db, llm=llm))
  4. Pídele al agente algo ambiguo, como «marca los pedidos como reembolsados», y mira el veredicto en el dashboard: la regla, el nodo del árbol y la corrección sugerida.
  5. Cuando el registro esté limpio, activa la protección. En Team, etiqueta las columnas sensibles y dale al agente su allowlist.

¿Usas LlamaIndex, LangChain.js u otro stack? La guía de integración tiene el código para cada uno, y la arquitectura de referencia para agentes y text-to-SQL muestra cómo combinar el proxy, la Runtime API y MCP.

El objetivo no es decirle que no al text-to-SQL. Es poder decirle que sí: dejar que el modelo consulte datos reales sabiendo que cada sentencia pasa por un control que no depende de que el modelo acierte.