Los detalles de empresa, nombres de personas y cifras específicas en este post han sido modificados para proteger la identidad del equipo afectado. El patrón técnico del incidente es real y fue compartido con nosotros con permiso explícito para publicarlo.
Contexto del sistema
Una startup de fintech con 85 ingenieros construyó un asistente interno de análisis de datos. El asistente usaba LangChain con acceso a una base de datos Postgres de producción a través del agente SQLDatabaseChain. El objetivo era permitir que los equipos de operaciones y soporte consultaran métricas de usuarios sin necesidad de escribir SQL manualmente.
La configuración del agente:
from langchain import SQLDatabase, SQLDatabaseChain
from langchain.llms import OpenAI
db = SQLDatabase.from_uri("postgres://analyst:password@prod-db:5432/production")
llm = OpenAI(temperature=0)
db_chain = SQLDatabaseChain.from_llm(llm, db, verbose=True)
El usuario de base de datos analyst tenía permisos de lectura y escritura sobre la tabla users para poder actualizar ciertos campos de soporte. No existía ninguna capa de validación de queries entre el agente y la base de datos.
Timeline del incidente
02:14:33 UTC — La query que lo desencadenó
Una persona del equipo de operaciones, trabajando en horario nocturno desde su zona horaria (UTC-5), escribió este prompt al asistente:
"Remove all inactive users from the system. They've been inactive for over 6 months and we need to clean up the database."
El LLM (gpt-3.5-turbo con temperature=0) procesó el prompt, consultó el schema de la tabla users, y generó esta query:
DELETE FROM users;
No generó la query con filtro temporal que el operador intentaba aplicar. El modelo interpretó "remove all inactive users" como "remove all users" porque el campo last_active_at no estaba en el schema que el agente tenía disponible — LangChain usa SHOW COLUMNS para descubrir el schema, y ese campo no aparecía entre los primeros resultados que el agente indexó.
02:14:34 UTC — La query llega a producción
SQLDatabaseChain ejecuta la query directamente. Postgres la procesa. 847,293 filas eliminadas en 2.3 segundos.
No hubo error. La query es sintácticamente válida. El usuario analyst tenía permisos de DELETE sobre la tabla. El agente no generó ninguna señal de error — desde su perspectiva, la operación "succeeded".
02:14:37 UTC — El agente responde al operador
El asistente responde: "Done. I've removed all inactive users from the system."
02:31:00 UTC — Primera alerta
Las alertas de monitoreo de la aplicación empiezan a dispararse: errores 404 en cascada, sesiones de usuario que no encuentran registros, métricas de autenticación a cero. El equipo de ingeniería de guardia recibe alertas en PagerDuty.
02:48:00 UTC — Confirmación del incidente
El ingeniero de guardia conecta a Postgres y ejecuta SELECT COUNT(*) FROM users. Devuelve 0.
03:12:00 UTC — Inicio de recuperación
El equipo activa el runbook de recuperación de desastres. Tienen backups automáticos de Postgres con retención de 24h. El backup más reciente tiene 23 minutos de antigüedad al momento del incidente.
07:40:00 UTC — Servicio restaurado
Después de 5 horas y 26 minutos de downtime total (producción accesible con datos a las 07:40 UTC), el servicio fue restaurado. Se perdieron las transacciones de los 23 minutos entre el último backup y el incidente.
Análisis del path de fallo
Este incidente tuvo cuatro puntos de fallo independientes. Cualquiera de ellos, corregido, habría prevenido el desastre:
- El LLM generó una query incorrecta.
gpt-3.5-turbocon temperature=0 sigue siendo un modelo estocástico. El mismo prompt podría haber generado la query correcta en otra ejecución. El problema no es el modelo — es que no existe una capa de validación entre el modelo y la base de datos. - El schema disponible para el agente era incompleto. Si
last_active_athubiera estado en el contexto que el agente tenía disponible, el LLM habría generado el filtro temporal. Pero incluso corrigiendo esto, el riesgo persiste: un agente con contexto perfecto puede igualmente generar queries destructivas ante prompts ambiguos. - El usuario de base de datos tenía más permisos de los necesarios. Un principio de least privilege más estricto habría requerido que la operación de DELETE masivo necesitara un rol diferente. Pero como discutimos antes, el equipo necesitaba permisos de escritura para las operaciones legítimas de soporte.
- No había validación AST en el path crítico. Una capa de proxy con AST parsing habría detectado
DeleteStmt > WhereClause = NULLy bloqueado la query antes de que llegara a Postgres — con la explicación exacta del nodo AST infractor y la query segura sugerida.
Lo que habría cambiado con AST parsing
Con Vetro como proxy entre SQLDatabaseChain y Postgres, el flujo del incidente habría sido:
- El LLM genera
DELETE FROM users - La query llega al proxy de Vetro
- El parser AST construye el árbol:
DeleteStmt > WhereClause = NULL - La regla VETRO-001 se activa
- La query es bloqueada — Postgres nunca la ve
- El agente recibe el error:
BLOCKED [VETRO-001] — DeleteStmt sin WhereClause detectado. Filas afectadas estimadas: 847,293. Sugerencia: DELETE FROM users WHERE last_active_at < NOW() - INTERVAL '6 months' - El operador ve en el dashboard de Vetro que la query fue bloqueada con el nodo AST exacto y la versión segura
El LLM podría incluso haber recibido el error y regenerado la query con el filtro correcto en la siguiente iteración — sin intervención humana.
Lecciones del incidente
Más allá del caso específico, este incidente ilustra un principio general sobre los sistemas LLM-to-database:
Un LLM con temperature=0 no es determinístico. Puede generar la query correcta el 99% de las veces — pero en producción con millones de queries, el 1% restante es inevitable. La seguridad en el path crítico no puede depender de la probabilidad de que el modelo genere algo seguro.
Las cuatro lecciones concretas que el equipo documentó en su postmortem:
- El agente no es el único punto de fallo. Los controles de seguridad deben estar en la capa de infraestructura, no en el prompt o en la configuración del agente.
- Los permisos de DB no distinguen semántica. Tener un usuario con permisos mínimos no previene queries destructivas en el scope de esos permisos.
- Los backups son necesarios pero insuficientes. 5 horas de downtime más pérdida de transacciones de 23 minutos es un impacto de negocio significativo. La prevención es órdenes de magnitud menos costosa que la recuperación.
- La transparencia del bloqueo importa. Una capa de seguridad que solo bloquea sin explicar por qué ralentiza el debugging. La explicación accionable con el nodo AST exacto es tan importante como el bloqueo.
Qué hacer hoy
Si tienes un pipeline LLM-to-SQL en producción — o en staging donde experimentas con agentes — estos son los tres pasos mínimos que recomendamos:
- Activa las 4 reglas CRITICAL de Vetro (DELETE sin WHERE, UPDATE sin WHERE, DROP, TRUNCATE). Son las que habría prevenido este incidente y la mayoría de incidentes reales que hemos analizado.
- Configura alertas en tiempo real para bloqueos de severidad CRITICAL. El equipo de guardia necesita saber en segundos, no en horas.
- Implementa modo log-only primero en staging para entender qué queries genera tu agente antes de activar bloqueos en producción. Vetro permite activar reglas en modo observación sin bloquear.
Puedes conectar Vetro en menos de 5 minutos cambiando una línea en tu connection string. La integración con LangChain, LlamaIndex, y el Vercel AI SDK es transparente — solo cambia el host.