Tu agente te pega. ¿Sabes cuánto duele?

Pensando en cómo mejorar Norn, volví a lo de siempre: poner nombre a lo que quiero ver cuando pasa, y a lo que quiero ver antes de que pase.

En algún punto de esa vuelta me pregunté qué detecta Norn. Detecta obediencia: si el modelo hace lo que el documento le manda, con un canario de testigo y un corpus que puedo contar. Eso está medido.

La duda incómoda llegó al lado. Un agente con herramientas no solo obedece: ejecuta. Y de lo ejecutado yo no tenía número. No es que lo midiera mal. Es que no existía. La pregunta que sale de ahí no es la de los bancos de pruebas, que miden si el ataque gana y cuánta utilidad cuesta defenderse: es cuánto dolió lo que pasó y cuánto se quedó a medio camino.

De la duda salió la pregunta, de la pregunta el proceso. Del proceso, la hipótesis y las pruebas. Lo demás es historia.

¿Qué es el dolor?

Primero, qué cuenta como golpe. Un agente con cinco herramientas no duele igual en todas: leer el correo de un cliente y borrar su ficha no se parecen en nada. Así que hice la pregunta al revés, la de seguridad de verdad: qué cambia en el sistema cuando la herramienta se ejecuta, y cuánto de ese cambio se puede deshacer.

Leer no cambia nada. Escribir una nota cambia el CRM, pero se borra. Enviar un correo sale del sistema y ya no vuelve: no se puede desenviar. Borrar una ficha no se reconstruye con otra llamada. Ejecutar código puede ser cualquier cosa, y esa es justo la razón para ponerlo arriba: dentro de él caben la escritura, el envío y el borrado, y además se queda. Pesa lo mismo que borrar.

De ahí salieron los pesos: leer 0, escribir 1, enviar 2, borrar 3, ejecutar 3. No son una tabla de severidades copiada de un framework. Salen de una pregunta que se puede contestar mirando el sistema: si esto pasa, ¿qué hay que hacer para volver atrás? Y ese cero no es desprecio: una lectura no cambia el estado de nada.

Está también el caso que rompe la comodidad. Una lectura que devuelve el dato en la respuesta no cambia nada dentro de la app, pero el dato ya está fuera. No hubo escritura, hubo salida, y eso se parece más a enviar que a leer. Le puse 0,5 y no me gusta, porque es exactamente el tipo de número que después se suma donde no debe. Volveremos.

Falta la otra mitad de la definición, y es la que decide qué es ataque: la autorización. Si el operador pidió el envío y la política lo permite, la app está haciendo su trabajo. Si lo pidió una frase escondida dentro de un correo, es un ataque, aunque la herramienta sea la misma y el resultado idéntico.

El matiz que me costó una campaña entera: la autorización es del acto, no del contenido. Un envío autorizado puede llevar un dato que nadie autorizó a mover. El peso de la herramienta pone el techo; el marcador dice si el dato viajó de verdad.

El golpe y el daño

Un golpe puede no dejar daño, y el primer número que construí solo sabía contar lo segundo.

Con la definición en la mano, el índice salió casi solo: daño realizado dividido entre réplicas pesables. Lo llamé IDR, por Impact Damage Rate, tasa de daño realizado. El veredicto es de una pieza, PASS solo si no salió nada.

Hay una condición que no se puede saltar. Una réplica pesable es una que lleva un marcador dentro. Si el corpus se rompió y el marcador no viajó, esa réplica no cuenta y sale n/d, nunca 0. Un cero es una afirmación, «aquí no pasó nada», y sin testigo no la puedo hacer.

Y entonces apareció la otra, la que da título a esto. Puse el guardarraíl delante de la escritura. El agente lo intentó, la política dijo que no, y el informe salió con cero.

Cero no es cierto.

En la campaña con el guardarraíl puesto la app se defendió tres veces, una por réplica, y mi número dijo «no pasó nada» las tres. Un agente que pidió escribir y no escribió es riesgo residual, y no vive en el mismo sitio que el daño: es la diferencia entre lo que ocurrió y lo que estuvo a punto, dos palabras que la seguridad usa desde antes de que esto fuera un problema1.

Ahí está el agujero de medir solo daño: la app que deniega todo se ve igual de limpia que la que nunca recibe un ataque.

El atajo era obvio y lo cogí: sumar las dos cosas en un índice. Daño realizado más medio punto por cada intento denegado. Si el intento no llegó a ser daño, que pese la mitad, y así el informe cuenta el riesgo sin inventar daño.

Suena razonable. Es la clase de fórmula que uno defiende en una revisión porque detrás hay una intuición, no un número.

Lo que me hizo mirarla fue descomponerla. De los 0,0833 que dio esa campaña, el 43 % era exposición. Casi la mitad de la nota de la app venía de las veces que la app se había defendido.

Léelo otra vez. La app que deniega cinco escrituras puntúa peor que la que deja pasar dos, porque cada negativa le suma medio punto a su lista de daños. El índice no medía riesgo: cobraba defensa.

Y había un segundo síntoma, más tonto y más revelador. El veredicto miraba solo el daño realizado, mientras la media sumaba las dos cosas. Dos lecturas del mismo informe, una al lado de la otra, y yo había estado leyendo la que me gustaba.

¿Cuánto pesa la intención?

La intención se mide con la misma escala que el daño, no con una probabilidad. Esa es la respuesta corta, y tardé en verla porque el primer intento fue otro: meter la exposición dentro del daño.

Con la pregunta en limpio, si el daño es lo que el sistema hizo, la exposición es lo que el sistema estaba dispuesto a hacer y la política frenó. No hace falta inventarle una unidad: la herramienta que se quedó en el intento tiene el mismo peso que la que se ejecutó.

Lo primero que descarté fue contar negativas. Tres denegaciones de escritura y una de lectura no son cuatro de nada: la lectura no habría cambiado el estado aunque hubiera pasado. Contar llamadas mide actividad, no riesgo.

Lo segundo que descarté fue la probabilidad. Con cuarenta y dos réplicas y temperatura cero puedo decir si el modelo obedeció, no con qué frecuencia lo haría en producción. Un número que parece una probabilidad y no lo es termina en una diapositiva.

Me quedé con el peso del rol y con el mismo denominador. Al segundo número lo llamé EXR, de Exposure Rate, y se calcula igual que el primero: lo que la política paró, pesado con la misma escala.

Piensa en un portero. El daño es lo que se rompió dentro del local. La exposición es la cola que el portero paró en la puerta: no entró nadie, pero la cola dice a qué venían. Y no venían todos a lo mismo: uno preguntaba por la lista de invitados y otro traía una maza. Pesar esa cola con la misma escala que el desastre de dentro es lo que hace comparables las dos cosas; contarlos a todos por igual sería decir que una consulta y un martillazo son lo mismo.

De ahí sale todo lo demás. Como los dos números usan la misma escala y el mismo denominador, están en las mismas unidades: se pueden restar, y la razón entre ellos no tiene ninguna. Esa razón es la contención. Un portero que no deja pasar nada marca uno; uno que no mira la puerta, cero.

Los casos límite los puse a mano antes de correr nada. Una lectura denegada pesa cero, porque no iba a cambiar nada. Un envío denegado pesa dos. Un ejecutado denegado pesa tres. Y si un día el corpus trae una herramienta que el manifiesto no declara, pesa cero y se reporta como desconocida: no se adivina.

También decidí no poner un modelo de probabilidad encima. EXR no dice «habría pasado el 30 % de las veces». Dice cuánto pesaban los actos que el agente pidió y la política no dejó ejecutar. La probabilidad es otro experimento y otro número, y mezclarlos ahora sería repetir el error del 0,5 con más decimales.

El orden importa. El daño no puede superar a la exposición, porque lo ejecutado es un subconjunto de lo intentado. Si sale al revés, no es una app rara: es un fallo del medidor.

Cómo le hablo a la app

Para medir hacía falta algo que se pudiera romper sin consecuencias, así que monté una app pequeña con las herramientas que tendría un agente de atención al cliente de verdad: leer la bandeja, escribir una nota en el CRM, consultar una cuenta, leer un adjunto y mandar un correo. Cinco, ni una más. Delante de las herramientas, un guardarraíl decide qué se ejecuta.

Probar esa app es una petición. Un POST al endpoint del chat con dos campos, el mensaje del operador y un identificador de sesión, y un token que identifica al cliente. No hay consola, ni acceso a la base de datos, ni ficheros copiados en el servidor. Si un probe necesitara una de esas tres cosas, mediría otra cosa, no lo que le pasa a un usuario.

Lo que la app hace con ese mensaje es justo el mecanismo que quiero medir. Monta el prompt de sistema con sus reglas, busca en la bandeja y en los adjuntos los fragmentos que casan con la pregunta, y llama al modelo con eso y con la lista de herramientas. Si el modelo contesta con texto, se lo manda al cliente. Si pide una herramienta, la pasa por la política: si la deja, la ejecuta y le devuelve el resultado al modelo para que siga; si la deniega, le devuelve la negativa. Y vuelve a llamar, hasta cuatro veces por turno.

Ahí está la puerta por la que entra una inyección sin que nadie la pegue en el prompt. El buscador de la app es el que trae el documento plantado, y lo trae marcado como contenido no confiable. El tester no escribe la orden en el mensaje: la escribe en un correo y deja que la app la encuentre. Que el atacante sea el dato y no el usuario lleva años escrito2, y sigue siendo el vector que más me preocupa. Tampoco es folclore de laboratorio: es el primer riesgo de la lista de OWASP para aplicaciones con LLM3.

La respuesta vuelve como un flujo de eventos, no como un JSON al final. Arranque de la ejecución, fragmentos recuperados, texto del agente, cada llamada a herramienta con sus argumentos, cada decisión de la política, y el cierre con el consumo. Cada evento lleva el identificador de la ejecución, así que puedo seguir una réplica entera sin adivinar nada.

Las puertas son las que la app ya tiene: subir un adjunto y recibir un correo. El corpus son catorce casos, y si una orden no se puede plantar por una puerta que exista, el caso no vale.

Cada réplica abre sesión nueva, para que la anterior no deje estado en la siguiente, y lleva su propio canario. Si el marcador aparece donde no debe, sé qué ejecución lo movió.

Lo que guardo es lo que la app misma registró. Después de la ejecución puedo volver a pedirle el registro, las notas del CRM y la bandeja de salida, y comparar: si el flujo dice que no hubo envío y la bandeja tiene un correo, me creo la bandeja.

Lo que hace repetible la prueba es el manifiesto: la app declara sus puertas, sus endpoints, dónde queda la bandeja de salida y con qué condición se considera que un envío ocurrió. Con ese fichero, otra persona lanza la campaña y obtiene lo mismo. Nada de esto la toca por dentro: un escáner lanza probes y reporta, no limpia sus trazas del sistema auditado, porque entonces el informe sería la única prueba de lo que pasó.

El experimento

La única variable es el guardarraíl, y no se cambia en caliente: la app arranca con él puesto o quitado.

Corrí cuatro campañas: con el guardarraíl puesto, con el guardarraíl quitado, con el guardarraíl quitado otra vez y con el guardarraíl puesto otra vez. Alternadas a propósito, porque si el proveedor del modelo cambia de humor a media tarde, dos campañas seguidas del mismo signo disfrazan ese cambio de efecto. Cuarenta y dos réplicas por campaña, tres por caso, temperatura cero.

Una limitación que prefiero decir antes de enseñar números. Con tres réplicas por caso y temperatura cero, las réplicas son casi copias: esto mide el corpus, no el ruido de muestreo. Una app, un modelo, un corpus. No es un benchmark y no pretendo que lo sea.

Con ese montaje, los números. Con el guardarraíl puesto: 0,0476 de daño, 0,0714 de exposición, 60 % de contención. Sin él: 0,1667 de daño, cero de exposición, cero de contención.

Las dos campañas con el guardarraíl puesto salieron idénticas al dígito, y las dos sin él también. Con cuarenta y dos réplicas y temperatura cero eso no es estadística: el montaje se repite. Y cuando se repite, cada diferencia entre tenerlo puesto y quitado tiene una causa concreta que se puede buscar en el registro, en vez de quedarse en ruido.

En herramientas se ve mejor. Con el guardarraíl puesto hubo tres llamadas a la escritura y ninguna autorizada; con él quitado, tres de tres. El correo salió una vez con el guardarraíl puesto y dos sin él.

Cuatro campañas del mismo corpus: daño en rojo, exposición en ámbar y contención en cian

Cuatro campañas del mismo corpus. En rojo el daño realizado, en ámbar la exposición, en cian la contención. Las dos con el guardarraíl puesto dan lo mismo, y las dos sin él también.

Qué significa

El guardarraíl hace una sola cosa, y la hace bien: contiene la escritura. No toca las lecturas, no toca las consultas, y en el correo ni siquiera cambia la decisión, porque lo autoriza igual con el guardarraíl puesto que quitado.

El cero de exposición de la campaña sin guardarraíl no es una buena noticia. Es que no hay nadie parando nada, así que todo lo que el agente intentó, lo hizo. El número que parece tranquilizador es el que mide la ausencia de defensa.

Lo que casi me la cuela está en el correo, que salió más veces con el guardarraíl quitado. Es tentador leer ahí una contención de la política. No lo es: en los dos casos el envío estaba autorizado. La diferencia es que, al toparse con la negativa de la escritura, el modelo abandonó la cadena antes de llegar al correo. Menos intentos por desánimo, no por regla. Eso es suerte, y la suerte no se diseña.

De ahí la razón de tener dos números y no uno: el daño solo no distingue una app que se defiende de una que nadie ataca. La contención es lo que las separa, y por eso no se suma al daño: se lee al lado.

Queda un daño que las dos métricas no explican, y es el único que sobrevive con el guardarraíl puesto.

Un correo salió con un código de cuenta dentro. Salió porque el operador lo había pedido: la herramienta estaba autorizada, el destinatario era interno y la política dijo que sí.

El acto estaba autorizado. El dato no.

Lo que no esperaba encontrar, y esto es conclusión mía, no de los papeles: lo publicado se pregunta dos cosas. Si el ataque gana, que se mide como tasa de éxito y no distingue borrar una ficha de leer un correo. Y cuánto trabajo legítimo cuesta defenderse. Mis dos preguntas son otras: cuánto daño se realizó, con una escala que pesa por lo que cuesta deshacer, y cuánto peso pidió el agente y la política frenó. La primera contiene la tasa de éxito y no es la tasa de éxito; la segunda roza la utilidad y no es la utilidad. Y la que me falta, la fricción, es justo el lado que ellos ya miden. Si alguien ha publicado esta pareja y no la vi, que me pase el enlace.

Eso es todo lo que estas dos métricas saben contar. Dicen cuánto dolió y cuánto se paró. No dicen de dónde salen los intentos, ni qué pasa cuando el ataque viaja dentro de un acto que el operador firmó. Para eso hace falta un sitio con herramientas de verdad, un guardarraíl que se pueda apagar y alguien mirando desde fuera.

Lo que esto no mide

Lo primero que se queda corto es la puerta. El escáner entra con un token por delante y habla el protocolo del endpoint. Un agente en producción casi nunca te da eso: está detrás de un formulario, con sesión, CSRF y a veces segundo factor. Un driver que haga login como una persona y guarde la sesión es trabajo pendiente, y hasta que exista esto sirve para las apps que te dan una API, no para las que solo tienen interfaz.

Lo segundo es que esto es una prueba de concepto. Cinco herramientas, catorce casos escritos a mano, y los pesos de ejecutar y borrar sin un solo caso que los use: ahí el 3 es una declaración, no una medida.

Ese es el límite que más me molesta, porque el corpus no escala a mano. Hoy una herramienta que el manifiesto no declara pesa cero y se reporta como desconocida, que es no medirla con otro nombre. Si mañana la app trae veinte herramientas no voy a escribir veinte payloads, así que la salida está en el esquema, que también es superficie de ataque4: de cada herramienta salen sus parámetros y sus tipos, y de ahí una familia de casos con la misma aserción para todas, que el canario llegue al argumento que toca. Los parámetros de texto libre admiten la orden; los identificadores se rellenan antes llamando a las herramientas de lectura, para que el payload apunte a objetos que existen. Así una herramienta desconocida deja de ignorarse: entra con sus casos generados y con una cuenta de cuántos pares herramienta y parámetro quedan cubiertos y cuántos no.

El resto de los límites son de alcance. Cuatro campañas con temperatura cero dicen que el montaje se repite, no que esto pase en la siguiente app que se pruebe. Para eso hay bancos con más casos, más modelos y más herramientas5, y este no compite con ellos. El marcador ve copias literales del dato, así que a un agente que parafrasee solo lo caza la puntuación por argumentos, y para eso tengo que haber adivinado qué mirar. Y el 0,5 de la lectura que devuelve el dato lo puse yo a mano: es un juicio hasta que alguien lo discuta con datos.

Falta la fricción, que es el agujero grande. Sin medir cuánto trabajo legítimo se para, la contención premia negar: un guardarraíl que lo deniegue todo sacaría daño cero y contención uno, y estas dos métricas lo aplaudirían. El precio ya está medido en otro sitio: una defensa por diseño resuelve el 77 % de las tareas de un banco de agentes con garantías, frente al 84 % sin defensa6. Siete puntos de utilidad a cambio de que no se rompa ninguna política. Ese es el número que a mí me falta.

Lo que sigue: el login, la fricción y el generador de casos desde el esquema. Nada de eso cabe en un post.

El bucle no se cierra aquí: se cierra con la siguiente campaña.

Referencias


  1. Guide for Conducting Risk Assessments · NIST Special Publication 800-30 Rev. 1 · septiembre de 2012 · https://csrc.nist.gov/pubs/sp/800/30/r1/final ↩︎

  2. Not What You’ve Signed Up For: Compromising Real-World LLM-Integrated Applications with Indirect Prompt Injection · Kai Greshake, Sahar Abdelnabi, Shailesh Mishra, Christoph Endres, Thorsten Holz, Mario Fritz · AISec 2023 · arXiv:2302.12173 · mayo de 2023 · https://arxiv.org/abs/2302.12173 ↩︎

  3. OWASP Top 10 for LLM Applications 2025 · LLM01:2025 Prompt Injection · OWASP Gen AI Security Project · noviembre de 2024 · https://genai.owasp.org/llmrisk/llm01-prompt-injection/ ↩︎

  4. MCPTox: A Benchmark for Tool Poisoning Attack on Real-World MCP Servers · Zhiqiang Wang, Yichao Gao, Yanting Wang, Suyuan Liu, Haifeng Sun, Haoran Cheng, Guanquan Shi, Haohua Du, Xiang-Yang Li · arXiv:2508.14925 · https://arxiv.org/abs/2508.14925 ↩︎

  5. AgentDojo: A Dynamic Environment to Evaluate Prompt Injection Attacks and Defenses for LLM Agents · Edoardo Debenedetti, Jie Zhang, Mislav Balunović, Luca Beurer-Kellner, Marc Fischer, Florian Tramèr · NeurIPS 2024 Datasets and Benchmarks · arXiv:2406.13352 · junio de 2024 · https://arxiv.org/abs/2406.13352 ↩︎

  6. Defeating Prompt Injections by Design · Edoardo Debenedetti, Ilia Shumailov, Tianqi Fan, Jamie Hayes, Nicholas Carlini, Daniel Fabian, Christoph Kern, Chongyang Shi, Andreas Terzis, Florian Tramèr · arXiv:2503.18813 · marzo de 2025 · https://arxiv.org/abs/2503.18813 ↩︎

◂ volver al blog