Módulo 7: Seguridad y confiabilidad del agente
4. Límites de confianza: qué puede hacer el agente sin permiso
Descripción
Al terminar esta lección vas a poder responder con precisión la pregunta que decide si un agente entra o no a producción —¿qué es lo peor que este sistema puede hacer?— porque vas a tener las cuatro palancas concretas con las que se limita la capacidad de un agente en n8n: la credencial con la que corre la tool, la operación que el nodo tiene habilitada, qué parámetros puede rellenar el modelo y cuáles son fijos, y qué agente del equipo tiene conectada cada tool. Y vas a saber escribir la matriz de permisos que hace esa respuesta verificable en vez de opinable.
Esto importa porque las tres lecciones anteriores terminaron todas en el mismo lugar. La lección 2: el filtro bloqueó dos de cuatro ataques, y los otros dos se defienden más adentro. La lección 3: la defensa que de verdad cambió el resultado no fue filtrar mejor sino que el agente secuestrado no tuviera la tool. Esa frase —no tenía la tool— es lo único en todo este módulo que no depende de que el modelo decida bien. Es hora de convertirla en método.
Conexión con el módulo, y la frontera con el Módulo 4. Esto hay que decirlo con claridad porque el título se parece. En el Módulo 4, lección 5 ya trabajaste "contratos de herramientas y límites de confianza", y ahí aprendiste dos cosas que esta lección da por sabidas y no repite: cómo se escribe el contrato de una tool —Name, Description y cada parámetro con su propia descripción vía $fromAI()— para que el modelo elija la tool correcta y la llame con los datos correctos; y el criterio de negocio para decidir qué acciones necesitan aprobación humana —la tabla de reversibilidad, impacto financiero y compromiso frente al cliente—. Todo eso sigue vigente y es prerrequisito de hoy.
Lo que agrega esta lección es la otra mitad, que en el Módulo 4 no correspondía: allí el ángulo era diseño —que el agente use bien lo que tiene—, aquí el ángulo es seguridad —qué puede hacer el agente aunque decida mal, aunque lo hayan secuestrado, aunque el contrato que escribiste no lo haya frenado—. Un contrato es una instrucción; lo de hoy son capacidades. Concretamente: cómo se recorta el privilegio de una credencial, cómo se elimina la superficie de una operación, cómo se decide qué parte de una llamada puede decidir el modelo y qué parte es tuya, y cómo se reparte todo eso entre los agentes del equipo del Módulo 5. La lección 5 toma la última pieza —lo que ni con permisos recortados se le puede confiar a nadie— y le pone una persona delante.
Las llaves del edificio
Piensa en un edificio de oficinas con un sistema de llaves bien pensado. La persona de limpieza tiene una llave que abre los pasillos, los baños y las salas de reunión, pero no las oficinas cerradas ni el cuarto de servidores. Quien maneja la caja chica tiene la llave del cajón donde está el dinero, y esa llave no abre ninguna otra cosa. El del cuarto de servidores no puede entrar a contabilidad. Y hay una llave maestra que abre todo, que existe, y que está en la caja fuerte de administración porque nadie la carga encima "por si acaso".
Nadie diseña ese sistema por desconfianza. Se diseña porque las llaves se pierden, porque la gente rota, porque un error de alguien con la llave maestra tiene un tamaño distinto al de un error de alguien con la llave de los pasillos. El principio se llama mínimo privilegio y dice algo muy simple: cada actor recibe exactamente el acceso que necesita para su trabajo, y ni un poco más.
Un agente de IA es el actor más raro que has contratado para tu edificio: trabaja rápido, no se cansa, y hace caso a lo que lee. Darle la llave maestra "porque así funciona todo sin problemas" es exactamente la decisión que hace que un injection exitoso sea un desastre en vez de una molestia.
Ahora, ¿dónde están las llaves en n8n? No hay una pantalla de "permisos del agente". Hay cuatro palancas, en cuatro lugares distintos, y conviene conocerlas todas porque cada una corta un tipo de daño diferente.
Palanca 1 — La credencial. Es lo que la cuenta puede hacer en el sistema de destino, independientemente de n8n. Un usuario de Postgres con GRANT SELECT sobre una vista no puede escribir ni aunque la query se lo pida. Una cuenta de Gmail con permisos de solo lectura no puede enviar. Es la llave física: si no abre la puerta, no importa cuánto se lo pidan.
Palanca 2 — La operación del nodo. Es qué subconjunto de lo que la credencial permite está realmente disponible. El nodo de Gmail tiene decenas de operaciones; el que conectas como tool tiene una configurada. Un nodo Gmail con Get Many no puede enviar correos aunque su credencial tenga permiso de envío, porque ese nodo no hace eso.
Palanca 3 — Los parámetros: fijos contra $fromAI(). Es qué parte de la llamada decide el modelo. Un campo to con un valor literal es tuyo; un campo to con $fromAI() es del modelo, y por transitividad, de quien logre influir en el modelo. Esta es la palanca más fina y la que más gente deja abierta sin darse cuenta.
Palanca 4 — El reparto entre agentes. Es qué tools están conectadas al puerto ai_tool de qué agente. Es la palanca de la lección 3, formalizada: un agente no puede llamar una tool que no tiene conectada, punto.
Las cuatro se combinan. Y hay una jerarquía útil: cuanto más abajo apliques el límite, más difícil es saltárselo. Un límite en el System Message se salta con un buen texto. Un límite en $fromAI() requiere que el modelo genere algo que tú no aceptas. Un límite en la operación del nodo no se salta desde el chat de ninguna manera. Y un límite en la credencial no se salta ni aunque alguien edite tu workflow.
Ejemplo trabajado
Vamos a tomar una sola tool de TuTienda —lookup_order, la más inocente del sistema, la que solo consulta pedidos— y a mirarla dos veces.
Versión insegura. Es como suele quedar cuando uno la arma rápido para que funcione:
# Nodo: Postgres Tool — Name: lookup_order
#
# Credential: postgres_main
# user: n8n_app
# # Este usuario es el mismo que usan los otros workflows de la
# # empresa. Tiene permisos de lectura y escritura sobre todo
# # el esquema public, porque en algún momento alguien necesitó
# # que un workflow insertara filas y era más rápido así.
#
# Operation: Execute Query
#
# Query: {{ $fromAI("sqlQuery",
# "A SQL query to look up order information", "string") }}
Se ve razonable. Funciona muy bien: el agente arma la consulta que necesite, y como sabe SQL, resuelve casos que no anticipaste. Es incluso elegante.
Ahora mira lo mismo con las cuatro palancas en la mano:
- Credencial: un usuario con lectura y escritura sobre todo el esquema. La llave maestra.
- Operación:
Execute Query— la operación más amplia que existe en ese nodo. No es "leer pedidos", es "hacer lo que quieras con la base". - Parámetros: el modelo escribe la query completa. No un
order_id: la sentencia entera. - Reparto: conectada a
order_specialist, que recibe encargos derivados del texto del cliente.
¿Qué es lo peor que puede hacer esta tool? Todo lo que el usuario n8n_app puede hacer en esa base de datos. Un SELECT * FROM customers. Un UPDATE orders SET status = 'delivered'. Un DELETE. Y no hace falta un ataque sofisticado: basta con que el modelo, ante un cliente que dice "ya no quiero ese pedido, bórralo", genere la sentencia que le parezca útil. El contrato de tool del Módulo 4 —una buena Description que diga "úsala solo para consultar"— reduce mucho la probabilidad de que eso pase. No cambia en nada lo que es posible.
Versión con las cuatro palancas aplicadas. Primero, lo que ocurre fuera de n8n, en la base de datos:
-- Se ejecuta una sola vez, por la persona que administra la base.
-- No es parte del workflow.
-- 1. Una vista que expone SOLO lo que el agente necesita ver.
-- Nota qué NO está: el correo del cliente, su teléfono, el
-- método de pago, el costo interno. El agente no los necesita
-- para responder "¿dónde está mi pedido?", así que no los ve.
CREATE VIEW agent_order_status AS
SELECT
o.id AS order_id,
o.customer_id,
o.status,
o.created_at,
o.shipped_at,
o.carrier_tracking_code
FROM orders o;
-- 2. Un usuario dedicado al agente. No se reutiliza el de los
-- demás workflows: si mañana hay que revocarlo, se revoca
-- esto y nada más.
CREATE USER n8n_agent_ro WITH PASSWORD '...';
-- 3. Solo lectura, y solo sobre la vista. Ni siquiera sobre la
-- tabla orders: si alguien agrega una columna sensible a
-- orders mañana, el agente no la ve, porque la vista no cambió.
GRANT SELECT ON agent_order_status TO n8n_agent_ro;
-- 4. Y explícitamente nada más.
REVOKE ALL ON SCHEMA public FROM n8n_agent_ro;
GRANT USAGE ON SCHEMA public TO n8n_agent_ro;
Y ahora el nodo:
# Nodo: Postgres Tool — Name: lookup_order
#
# Credential: postgres_agent_readonly
# user: n8n_agent_ro
# # Palanca 1: aunque la query pidiera un DELETE, la base lo
# # rechaza. No es una regla nuestra, es un permiso del motor.
#
# Operation: Select
# # Palanca 2: no Execute Query. La operación Select del nodo
# # arma la consulta a partir de campos, no de texto libre.
# # Ya no existe la superficie "escribe la sentencia que quieras".
#
# Table: agent_order_status
# # Fijo. El modelo no elige tabla.
#
# Return All: false
# Limit: 5
# # Un cliente pregunta por sus pedidos, no por los 40.000 de la
# # tienda. Si un injection pide "trae todos", el techo son 5.
#
# WHERE conditions:
# customer_id = {{ $('Chat Trigger').item.json.customer_id }}
# # Palanca 3, la parte importante: este campo NO es $fromAI().
# # Sale del identificador que el canal ya verificó (el número de
# # WhatsApp autenticado, o la sesión del chat web). Ningún texto
# # que escriba el cliente puede cambiarlo, porque no pasa por
# # el modelo en ningún momento.
#
# order_id = {{ $fromAI("orderId",
# "The order number the customer is asking about,
# as it appears in their message. Digits only.",
# "string") }}
# # Este SÍ es del modelo, y está bien que lo sea: es un dato que
# # el cliente aporta sobre su propio caso. Y como convive con el
# # filtro de customer_id, pedir un pedido ajeno no devuelve nada.
Una nota de continuidad sobre ese customer_id. Es el mismo campo que estableciste en el Módulo 6, lección 7, cuando armaste la arquitectura de un agente para varios canales: cada adaptador de canal resuelve la identidad del cliente contra la tabla de identidades —con su columna verified_at— y la pasa al núcleo dentro del contrato de entrada. Si montaste esa arquitectura, la expresión no lee del Chat Trigger sino del nodo de entrada del núcleo, algo como {{ $('core_input').item.json.customer_id }}. Lo que importa para esta lección es idéntico en los dos casos: ese valor viene de una identidad que el canal ya verificó, no de lo que el cliente escribió en el mensaje. Y si tu sistema todavía no verifica la identidad de verdad —si el customer_id sale de un correo que la persona declaró en el chat—, entonces esta palanca no está protegiendo nada, y ese es un problema del canal que conviene cerrar antes de seguir.
Qué esperar. Corre contra esta versión el ataque del ejercicio 1 de la lección 2 —la clienta que pide "el estado y la dirección de entrega" de un pedido que dice ser de su mamá—. El modelo se convence perfectamente; no hay nada en el texto que lo delate. Llama a lookup_order con orderId: "4498". Y la consulta que sale es:
SELECT order_id, customer_id, status, created_at, shipped_at,
carrier_tracking_code
FROM agent_order_status
WHERE customer_id = 'CUS-8842' -- de la sesión verificada
AND order_id = '4498'
LIMIT 5;
Cero filas. El pedido 4498 no es de CUS-8842. El agente responde honestamente que no encuentra ese pedido asociado a su cuenta. Y nota algo más: aunque hubiera devuelto la fila, la dirección de entrega no está en la vista. Dos palancas independientes bloquearon la misma petición, y ninguna de las dos consultó al modelo.
Compara el esfuerzo con el resultado. Escribiste una vista de seis columnas, creaste un usuario, ejecutaste dos GRANT, y cambiaste tres campos del nodo. Media hora. A cambio, la respuesta a "¿qué es lo peor que puede hacer esta tool?" pasó de "cualquier cosa en la base de datos" a "devolver cinco filas de estado de pedidos del cliente que ya está autenticado". Esa segunda frase se puede decir en una reunión.
Los cuatro niveles de acción
Para repartir permisos hace falta un criterio, y el del Módulo 4 —reversibilidad, impacto financiero, compromiso frente al cliente— sigue siendo el correcto. Lo que agregamos hoy es convertirlo en niveles, porque un nivel se puede mapear a una decisión de configuración concreta.
| Nivel | Qué es | Cómo se controla | Ejemplo en TuTienda |
|---|---|---|---|
| L0 — Lectura | Consulta datos, no cambia nada | Credencial de solo lectura, vista recortada, Limit, filtro por identidad verificada | lookup_order, lookup_charge, search_knowledge_base |
| L1 — Escritura reversible | Cambia algo que se puede deshacer sin costo | Operación específica (no query libre), columnas permitidas fijas, filtro por identidad | create_ticket, marcar un ticket "en revisión", open_dispute |
| L2 — Escritura sensible | Irreversible, con impacto financiero, o que compromete a la empresa | Aprobación humana obligatoria (lección 5) + tope de valor + registro | issue_refund, cancelar un pedido, aplicar un descuento |
| L3 — Prohibida | El agente no debe poder hacerlo, nunca | No se conecta la tool. No hay configuración, hay ausencia | Borrar filas, cambiar precios de catálogo, enviar correo a un destinatario arbitrario |
Fíjate en el salto entre L2 y L3, porque es donde se decide de verdad la seguridad de un sistema. L2 es "puede, con permiso". L3 es "no puede". Y la tentación permanente es mover cosas de L3 a L2 porque "sería útil que pudiera, y total va con aprobación". A veces es correcto. Pero cada vez que lo haces, agregas una acción que un injection puede intentar, y le agregas un mensaje más de aprobación a una persona que ya recibe varios — que es exactamente el problema de fatiga que vas a ver en la lección 5.
Un criterio práctico para decidir entre L2 y L3: si no puedes nombrar el caso de uso legítimo concreto y frecuente que justifica esa tool, es L3. "Por si acaso" no es un caso de uso.
Ejemplo trabajado
La matriz de permisos completa del sistema de TuTienda, tal como quedaría al final de este módulo. Este es un documento, no un nodo — y es el artefacto que respondes cuando alguien te pregunta qué puede hacer tu sistema.
┌─ MATRIZ DE PERMISOS — TuTienda ───────────────────────────────────┐
│ │
│ AGENTE: triage_agent Canal: web + WhatsApp │
│ Expuesto a contenido no confiable: SÍ (mensaje del cliente) │
│ ┌──────────────────────┬─────┬────────────────────────────────┐ │
│ │ Tool │ Niv │ Control │ │
│ ├──────────────────────┼─────┼────────────────────────────────┤ │
│ │ order_specialist │ — │ AI Agent Tool (delegación) │ │
│ │ billing_specialist │ — │ AI Agent Tool (delegación) │ │
│ └──────────────────────┴─────┴────────────────────────────────┘ │
│ Datos sensibles: NO · Canal de salida: NO │
│ → No cumple la trifecta. Riesgo: delegación equivocada. │
│ │
│ AGENTE: order_specialist │
│ Expuesto a contenido no confiable: SÍ (encargo del triage) │
│ ┌──────────────────────┬─────┬────────────────────────────────┐ │
│ │ lookup_order │ L0 │ cred. read-only sobre vista │ │
│ │ │ │ agent_order_status; customer_id│ │
│ │ │ │ de sesión verificada; Limit 5 │ │
│ │ check_return_ │ L0 │ cred. read-only; solo lee la │ │
│ │ eligibility │ │ política y la fecha de compra │ │
│ │ create_ticket │ L1 │ operación Insert fija; columnas│ │
│ │ │ │ fijas; customer_id de sesión │ │
│ └──────────────────────┴─────┴────────────────────────────────┘ │
│ Canal de salida: NO │
│ │
│ AGENTE: billing_specialist │
│ Expuesto a contenido no confiable: SÍ (encargo del triage) │
│ ┌──────────────────────┬─────┬────────────────────────────────┐ │
│ │ lookup_charge │ L0 │ cred. read-only sobre vista │ │
│ │ │ │ agent_charges; customer_id de │ │
│ │ │ │ sesión verificada; Limit 10 │ │
│ │ open_dispute │ L1 │ Insert fijo en disputes; │ │
│ │ │ │ reversible por el equipo │ │
│ │ issue_refund │ L2 │ HUMAN REVIEW obligatorio │ │
│ │ │ │ (lección 5) · tope $2,000 · │ │
│ │ │ │ amount nunca > total del pedido│ │
│ └──────────────────────┴─────┴────────────────────────────────┘ │
│ │
│ AGENTE: inbox_reader_agent Trigger: Schedule 15 min │
│ Expuesto a contenido no confiable: SÍ (correos de cualquiera) │
│ ┌──────────────────────┬─────┬────────────────────────────────┐ │
│ │ read_support_inbox │ L0 │ sub-workflow: máx 10 correos, │ │
│ │ │ │ recorte 500 car., Sanitize, │ │
│ │ │ │ sin parámetros $fromAI() │ │
│ └──────────────────────┴─────┴────────────────────────────────┘ │
│ Datos sensibles: NO · Canal de salida: NO │
│ → Deliberadamente desarmado. Solo devuelve una clasificación. │
│ │
│ AGENTE: ticket_agent Entrada: JSON validado │
│ Expuesto a contenido no confiable: NO │
│ ┌──────────────────────┬─────┬────────────────────────────────┐ │
│ │ create_ticket │ L1 │ Insert fijo │ │
│ │ lookup_customer │ L0 │ read-only; Limit 1; customer_id│ │
│ │ │ │ del JSON validado, no $fromAI()│ │
│ │ notify_support_team │ L1 │ Gmail Send; destinatario FIJO │ │
│ │ │ │ (soporte@tutienda.example) │ │
│ └──────────────────────┴─────┴────────────────────────────────┘ │
│ │
│ NIVEL L3 — TOOLS QUE NINGÚN AGENTE TIENE │
│ · DELETE sobre cualquier tabla │
│ · UPDATE sobre products (precios, nombres, stock) │
│ · Gmail Send con destinatario desde $fromAI() │
│ · Postgres con operación Execute Query │
│ · Cualquier tool con credencial de escritura sobre el esquema │
│ │
└───────────────────────────────────────────────────────────────────┘
Qué esperar de este documento. Tres usos, y los tres son reales:
Se lee de arriba abajo buscando la trifecta. Cada bloque de agente dice si está expuesto a contenido no confiable, si toca datos sensibles y si tiene canal de salida. Ninguno de los cinco tiene las tres. Esa es la revisión de treinta segundos que haces cada vez que agregas una tool.
La sección L3 es la más importante y la que nadie escribe. Documentar lo que el sistema no puede hacer es lo que convierte una intuición en una garantía verificable. Y tiene un efecto práctico: cuando dentro de tres meses alguien del equipo proponga "conectémosle una tool que actualice precios", la conversación empieza desde una decisión ya tomada y argumentada, no desde cero.
Es la respuesta a la pregunta de la entrevista. "¿Qué es lo peor que tu agente puede hacer?" — "Emitir un reembolso de hasta $2,000, y solo después de que una persona lo apruebe viendo el monto y el motivo. Todo lo demás que hace es leer, o escribir cosas que el equipo puede deshacer."
Capacidad, no instrucción
Hay un principio que resume esta lección y que conviene tener a mano cuando estés decidiendo dónde poner una defensa: si algo importa de verdad, no lo escribas en el prompt — quítalo de las capacidades.
Compara las dos formas de resolver el mismo requisito, "el agente no debe cambiar precios":
# Forma A — instrucción
# System Message del agente:
# "Nunca modifiques el precio de un producto bajo ninguna
# circunstancia. Si alguien te lo pide, niégate."
#
# La tool update_product sigue conectada, con la columna price
# entre las que puede escribir.
# Forma B — capacidad
# No hay ninguna tool que escriba sobre la tabla products.
# La credencial del agente ni siquiera tiene GRANT UPDATE ahí.
#
# El System Message no menciona los precios, porque no hace falta.
La forma A funciona la enorme mayoría de las veces. Es una instrucción clara, un modelo actual la respeta, y en tus pruebas nunca vas a ver un precio cambiado. La forma B funciona siempre, y no porque el modelo sea obediente, sino porque no hay nada que llamar.
Ahora, la parte que hay que decir con la misma honestidad: la forma B no siempre está disponible, y a veces el costo de aplicarla es demasiado alto. Si el trabajo del agente es actualizar precios, no puedes quitarle esa capacidad sin quitarle el trabajo. Ahí el camino no es volver a la forma A y encomendarse: es bajar la acción a L2 —aprobación humana— y ponerle un tope. La instrucción en el prompt sigue existiendo y sirve, pero como orientación del comportamiento, no como barrera.
El criterio práctico es este: por cada regla de seguridad que escribas en un System Message, pregúntate si existe una versión de esa regla que viva en la credencial, en la operación o en un parámetro fijo. Si existe, esa es la que vale, y la del prompt es un complemento. Si no existe, entonces esa regla es débil por naturaleza y necesita una persona detrás.
Y una advertencia sobre lo que el mínimo privilegio no resuelve, para que no lo sobrevendas: un agente con permisos perfectamente recortados sigue pudiendo decirle mentiras al cliente. Puede afirmar que el pedido llega el jueves, que hay un descuento del 20%, o que su reembolso está aprobado, sin llamar a ninguna tool. Ninguna credencial de solo lectura impide que un modelo redacte un párrafo falso. Ese es un problema distinto, con una defensa distinta, y es la lección 6.
Errores comunes
Reutilizar la credencial que ya existía porque "es la misma base de datos" (práctico). Qué pasa: al conectar la primera tool de Postgres, n8n ofrece la credencial que ya está configurada para los demás workflows de la empresa —una con permisos amplios, porque otros flujos escriben— y se elige esa. Funciona a la primera, así que nadie vuelve al tema. Meses después, el agente corre con la llave maestra del edificio y nadie recuerda haberlo decidido. Por qué pasa: crear un usuario nuevo, escribir los GRANT y probar que todo sigue funcionando es media hora de trabajo que no produce ninguna funcionalidad visible; reutilizar es un clic. Cómo detectarlo: abre cada credencial que usan tus tools y pregúntate qué pasaría si esa credencial se usara para el peor comando posible — si la respuesta es grave, ese es tu límite real, no lo que dice el nodo. Cómo corregirlo: una credencial dedicada por agente, con el mínimo GRANT que necesite; y si el sistema de destino no permite recortar permisos (algunos APIs son todo o nada), entonces compensa con la palanca 2 y la 3, que sí están en tus manos.
Dejar $fromAI() en un campo que define alcance o destino (práctico). Qué pasa: el campo limit de una consulta, el to de un correo, el table de una operación o el customer_id de un filtro quedan con $fromAI() porque "el modelo sabe qué poner". Y sí sabe — hasta que alguien le sugiere otra cosa. Un limit desde $fromAI() convierte "consulta el pedido del cliente" en "trae los 200 registros" con una sola frase bien puesta, que es exactamente el paso 3 del ataque de la lección 3. Por qué pasa: $fromAI() es el mecanismo correcto y natural para los datos que el cliente aporta sobre su caso, y es fácil aplicarlo por costumbre a todos los campos del nodo sin distinguir cuáles son de esa clase. Cómo detectarlo: por cada $fromAI() de tu sistema pregúntate si ese valor describe el caso del cliente (número de pedido, monto que él menciona, fecha) o el alcance de la operación (cuántos, a quién, sobre qué tabla, con qué permiso); lo segundo nunca es del modelo. Cómo corregirlo: alcance y destino se fijan con un valor literal o con una expresión que lea un dato ya verificado del canal, como {{ $('Chat Trigger').item.json.customer_id }}.
Escribir la matriz de permisos después de construir el sistema (conceptual). Qué pasa: alguien termina el sistema completo y al final se sienta a documentar quién puede qué. Descubre que inbox_triage_agent tiene las tres condiciones de la trifecta, que dos agentes comparten la misma credencial amplia y que hay un $fromAI() en un campo de destino — y arreglarlo implica partir agentes, rehacer sub-workflows y volver a probar todo. Por qué pasa: la matriz parece documentación, y la documentación se hace al final. Cómo detectarlo: si al agregar una tool nueva no tuviste que abrir ningún documento para decidir a qué agente conectarla, es que no tienes matriz. Cómo corregirlo: la matriz se escribe junto con las fichas de rol del Módulo 5, en la misma sesión de diseño, y cada tool nueva se agrega ahí antes de conectarla en el canvas — es la forma barata de descubrir que una tool convierte a un agente en el eslabón peligroso.
Ejercicios
Ejercicio 1 — Recorta una tool. TuTienda tiene esta tool conectada a billing_specialist. Aplica las cuatro palancas y reescríbela.
# Nodo: Postgres Tool — Name: lookup_charge
# Credential: postgres_main (user n8n_app, lectura y escritura
# sobre todo el esquema public)
# Operation: Execute Query
# Query: {{ $fromAI("query", "SQL to find charges", "string") }}
Ver solución
-- Fuera de n8n, una sola vez:
CREATE VIEW agent_charges AS
SELECT
c.id AS charge_id,
c.customer_id,
c.order_id,
c.amount,
c.currency,
c.charged_at,
c.status
FROM charges c;
-- No incluimos: los últimos dígitos de la tarjeta, el
-- identificador del gateway, ni el token de pago. El agente
-- puede responder "¿qué es este cargo?" sin ver nada de eso.
CREATE USER n8n_billing_ro WITH PASSWORD '...';
GRANT SELECT ON agent_charges TO n8n_billing_ro;
REVOKE ALL ON SCHEMA public FROM n8n_billing_ro;
GRANT USAGE ON SCHEMA public TO n8n_billing_ro;
# Nodo: Postgres Tool — Name: lookup_charge
#
# Credential: postgres_billing_readonly (user n8n_billing_ro)
# # Palanca 1
#
# Operation: Select
# # Palanca 2 — se acabó el SQL libre
#
# Table: agent_charges
# Return All: false
# Limit: 10
#
# WHERE conditions:
# customer_id = {{ $('Chat Trigger').item.json.customer_id }}
# # Palanca 3 — de la sesión, no del modelo
#
# charged_at >= {{ $fromAI("chargeDate",
# "The date of the charge the customer is asking
# about, in YYYY-MM-DD. If the customer gives no
# date, use today minus 90 days.",
# "string") }}
# # Este sí es del modelo: es un dato del caso del cliente.
# # Y aunque devuelva un rango amplio, el filtro de customer_id
# # y el Limit 10 acotan el daño.
Qué es lo peor que puede hacer ahora: devolver hasta diez cargos del cliente que ya está autenticado, sin datos de tarjeta. Eso cabe en una frase, y esa es la prueba de que el recorte quedó bien.
Por qué funciona: las cuatro palancas actúan en capas independientes. Aunque el modelo generara un valor absurdo en chargeDate, el filtro de customer_id no viene de él; aunque alguien lograra cambiar el customer_id, la vista no expone datos de tarjeta; y aunque la query pidiera escribir, la credencial no puede.
Ejercicio 2 — Clasifica y decide. Para cada acción, asigna un nivel (L0/L1/L2/L3), di a qué agente del sistema de TuTienda la conectarías (o a ninguno) y con qué control:
(a) Consultar cuántas unidades quedan en stock de un producto. (b) Suscribir el correo del cliente a la lista de novedades. (c) Cambiar la dirección de envío de un pedido que aún no salió. (d) Enviar un correo con el resumen del caso a la dirección que el cliente indique. (e) Reenviar una factura ya emitida al correo registrado del cliente.
Ver solución
(a) L0. A order_specialist o a un sales_specialist. Credencial de solo lectura sobre una vista de catálogo, Limit bajo, product_id desde $fromAI() — es un dato del caso del cliente y no identifica a nadie.
(b) L1. A sales_specialist. Es reversible (se puede dar de baja) y de bajo impacto. Un detalle importante: el correo no va desde $fromAI() sino desde el registro verificado del cliente — si sale del modelo, el agente puede suscribir a cualquiera, y eso deja de ser L1.
(c) L1 o L2, y depende de un dato que el agente puede verificar. Si el pedido está en estado pending y no ha salido, es reversible y barato: L1, con la condición dura de que la tool solo acepte pedidos en ese estado —eso se fija en la propia consulta, no se le pide al modelo—. Si ya salió, cambiar la dirección implica coordinar con la transportadora: L2, aprobación humana. La forma limpia de resolverlo es que la tool sea update_shipping_address_if_pending y falle sola cuando el estado no lo permita.
(d) L3. No se conecta. Es exactamente el patrón de exfiltración de la lección 3: un destinatario controlable desde el texto convierte cualquier tool de lectura en una fuga. Si el caso de uso legítimo existe —"mándame el resumen a mi correo"—, se resuelve como (e).
(e) L1. A billing_specialist. La diferencia con (d) es total y vale la pena verla: el destinatario es fijo por expresión, {{ $json.customer_email }} leído del registro del cliente autenticado. El agente decide si enviar; no decide a dónde. Mismo caso de uso aparente, riesgo completamente distinto.
Por qué funciona: (d) y (e) parecen la misma funcionalidad desde la perspectiva del cliente —"mándame esto por correo"— y están en extremos opuestos de la matriz. Lo que las separa no es qué hace la tool, es qué parte de la llamada decide el modelo.
Ejercicio 3 — El agente que actualiza precios. Un cliente te pide un agente que ajuste precios del catálogo según reglas de negocio que él le va a dar por chat. Escribir precios es L3 en tu matriz. Diseña una solución que le sirva sin que ningún agente tenga esa capacidad directa, y di qué renuncias con tu diseño.
Ver solución
La solución no es conectar update_product_price con una Description muy estricta. Es partir la acción en dos y meter una frontera en el medio:
# Agente: pricing_agent
# Tools:
# lookup_product_prices L0 read-only sobre vista de catálogo
# propose_price_change L1 INSERT en la tabla
# price_change_proposals
# NO tiene ninguna tool que escriba sobre products.
#
# Salida de propose_price_change:
# { product_id, current_price, proposed_price, reason,
# status: "pending" }
#
# ▼
#
# Workflow separado — NO es un agente:
# Schedule Trigger (cada hora)
# └─► Postgres: SELECT de price_change_proposals WHERE pending
# └─► Code: validaciones deterministas
# · proposed_price > costo unitario
# · variación <= 15% respecto del precio actual
# · el producto existe y está activo
# └─► Slack: Send and Wait for Response (lección 5)
# "Cambio propuesto: SKU-4410 de $890 a $790 (-11%).
# Motivo: liquidación de temporada. ¿Aprobar?"
# └─► [Aprobado] ─► Postgres: UPDATE products SET price = ...
# (credencial DISTINTA, que el agente no usa)
# [Rechazado] ─► Postgres: UPDATE proposals SET status
El agente propone; un workflow determinista valida; una persona aprueba; una credencial que el agente no tiene ejecuta. El precio se actualiza igual, que es lo que el cliente pidió.
Lo que renuncias, dicho sin adornos:
- Inmediatez. El cambio no ocurre en la conversación; ocurre en la siguiente corrida del workflow, después de una aprobación. Si el cliente esperaba "le digo al bot y el precio cambia", esto no es eso.
- Flexibilidad. Las validaciones son código, no criterio del modelo. Un caso legítimo que el código no contempló —una liquidación real del 40%— se rechaza y hay que ajustar el código. Esa rigidez es el precio de que un injection tampoco pueda pasar.
- Simplicidad. Son dos workflows, una tabla intermedia y un canal de aprobación en vez de una tool. Es más para mantener.
Y lo que ganas: la respuesta a "¿qué pasa si alguien secuestra el agente de precios?" es "consigue insertar una fila en una tabla de propuestas que nadie va a aprobar".
Por qué funciona: el patrón —proponer en vez de ejecutar, con validación determinista y aprobación humana en el medio— es el que se usa para cualquier acción L3 que el negocio necesite de verdad. No convierte L3 en L2 por decreto; construye la infraestructura que hace que la acción sea segura, y esa infraestructura vive fuera del agente.
Resumen y siguiente paso
El mínimo privilegio en n8n se aplica con cuatro palancas, y conviene tenerlas presentes en ese orden porque van de la más difícil de saltar a la más fácil: la credencial con la que corre la tool, la operación que el nodo tiene habilitada, qué parámetros son fijos y cuáles vienen de $fromAI(), y qué tools están conectadas a qué agente. Sobre esas palancas se apoya una clasificación de cuatro niveles —L0 lectura, L1 escritura reversible, L2 sensible con aprobación, L3 no conectada— y una matriz de permisos que documenta agente por agente qué puede hacer, y sobre todo qué no. El principio que lo resume: si algo importa de verdad, no lo escribas en el prompt, quítalo de las capacidades.
Antes de avanzar a la lección 5 deberías poder: nombrar las cuatro palancas y decir cuál actúa fuera de n8n; distinguir un $fromAI() legítimo —un dato del caso del cliente— de uno peligroso —alcance o destino de la operación—; escribir la sección L3 de tu propia matriz, que es la que casi nadie escribe; y responder en una frase qué es lo peor que tu sistema puede hacer hoy.
Queda una pieza. Todo lo de hoy funciona porque le quitas capacidades al agente, y hay acciones que el negocio necesita de verdad y que no se pueden quitar: issue_refund existe porque TuTienda a veces tiene que devolver dinero. Para esas —las L2— la respuesta no es un permiso más fino, es una persona en el medio. La lección 5 monta ese mecanismo con las dos formas reales que n8n ofrece para pausar un workflow y esperar aprobación: la revisión humana en el conector Tools del agente, que ya viste de lejos en el Módulo 4, y la operación de enviar y esperar respuesta en un nodo de canal. Con el detalle que decide si el mecanismo sirve o estorba: qué ve exactamente la persona que aprueba.
Recursos
- Postgres node — n8n Docs — las operaciones disponibles y la diferencia entre
Selectcon condiciones yExecute Query, que es la palanca 2 de esta lección. - Credentials — n8n Docs — cómo se crean y se comparten credenciales en n8n, base de la palanca 1 y de la recomendación de una credencial dedicada por agente.
- Use AI for parameters — n8n Docs — referencia de
$fromAI(), para decidir con criterio qué campos son del modelo y cuáles son tuyos. - AI Agent node — n8n Docs — el puerto
ai_tool, que es donde se materializa la palanca 4: qué agente tiene qué tool. - Building Effective AI Agents — Anthropic — el argumento de dar al agente la superficie mínima necesaria, y la advertencia sobre agregar capacidades "por si acaso".