He pasado suficientes años protegiendo bases de datos de producción como para estremecerme cada vez que alguien dice "solo dale al agente acceso de lectura a la tabla de clientes". Esa frase es donde comienzan la mayoría de las filtraciones de datos. Por eso, cuando empecé a jugar con Pi, el agente de codificación minimalista de pi.dev, lo primero que quise probar no fue si podía escribir código. Fue si podía evitar que entregara números de tarjetas de crédito reales a otro agente que no tenía por qué verlos. Esto comenzó como un experimento mental. Ahora es una prueba de concepto en funcionamiento, y los números al final de este artículo provienen de haber hecho un grep real al tráfico (wire).
Pi es un buen lugar para probar esto precisamente porque es pequeño. Cuatro herramientas —read, write, edit, bash— y menos de mil tokens de instrucciones. Sin MCP, sin sub-agentes, sin ventanas emergentes de permisos integradas. La propuesta es "adapta Pi a tus flujos de trabajo, no al revés", y lo personalizas con skills, extensiones, plantillas de prompts y un SYSTEM.md. Ese minimalismo es importante para lo que sigue. Cuando el harness no está haciendo mucho, el límite que construyes es el límite que existe. No hay un middleware oculto registrando silenciosamente el payload original en algún lugar que olvidaste.
La configuración
Dos agentes, tres bases de datos, un contenedor de Postgres. fintechP es la base de datos de producción: un dataset bancario relacional —clientes, direcciones, cuentas, tarjetas, transacciones— poblado con Faker para que cada fila contenga PII realista. SSNs con formato real, números de tarjeta de 16 dígitos válidos por Luhn, números de cuenta y de ruta de 9 dígitos, unos 80 clientes por defecto. fintechT es la base de datos de prueba: esquema idéntico, deliberadamente vacía. Y una tercera base de datos, bus, contiene una única tabla mq.messages que los dos agentes usan como buzón de correo.
El agente de producción (prod agent) se asienta sobre fintechP y responde a solicitudes de datos. El agente de pruebas (test agent) es dueño de fintechT y necesita filas para poblarla: la clásica solicitud de datos interna. La versión perezosa es dejar que el lado de pruebas consulte la producción directamente o, peor aún, entregarle el connection string. Ahora tienes dos sistemas con la misma PII real y el doble de radio de explosión (blast radius).
La versión que construí: el agente de pruebas pregunta a través del buzón, el agente de producción responde, y los números de tarjeta y SSN reales nunca abandonan el proceso del lado de producción. El transporte es deliberadamente aburrido: cada agente ejecuta la misma extensión de buzón, un mailbox_send y un mailbox_wait que realiza long-polling a la tabla del bus con FOR UPDATE SKIP LOCKED, de modo que un mensaje se consume exactamente una vez. Sin sistema de archivos compartido, sin base de datos compartida entre los agentes, solo cuerpos de solicitud tipados como {{ entity: "customers", limit: 5 }} en una dirección y filas enmascaradas de vuelta.
Un detalle que me gusta más de lo que esperaba: ambos agentes ejecutan el mismo código de extensión. Los roles son configuración, no bifurcaciones (forks). La extensión bank-db lee PI_DB_ROLE: como producer registra una única herramienta llamada query_masked; como consumer registra insert_rows, query_local y una herramienta de marca de agua (watermark). Un archivo para auditar, dos comportamientos.
Enmascarar en el productor, nunca confiar en el consumidor
Aquí está la parte en la que la gente se equivoca. El instinto es poner la regla en el consumidor: "no guardes los valores originales", "redacta antes de registrar en el log". Eso es confiar en el lado que no controlas. Tal vez el agente de pruebas esté bien hoy. Tal vez el próximo trimestre alguien modifique su prompt y el paso de redacción desaparezca silenciosamente. Nunca lo sabrás hasta que aparezca en un agregador de logs.
La regla pertenece al productor, porque el productor es el único lado que toca los datos reales. Enmascara antes de que los bytes crucen el cable. Para cuando algo llega al buzón, los valores sensibles ya no están. El comportamiento del consumidor —cuidadoso, descuidado, comprometido— deja de importar, porque no queda nada peligroso que pueda manejar mal.
Esto es simplemente minimización de datos en el límite, el mismo principio que utiliza un DBA al decidir qué columnas puede seleccionar un rol de informes. No entregas la fila completa y pides a la gente que sea educada al respecto.
No uses prompts para la seguridad. Aplícala forzosamente.
El atajo tentador con cualquier agente LLM es escribir "nunca reveles números de tarjeta de crédito completos" en el system prompt y darlo por terminado. No confío en eso y tú tampoco deberías. Un prompt es una sugerencia para un sistema no determinista. Se mantiene hasta que el modelo se distrae con una solicitud ingeniosa, o el contexto se llena y la instrucción se desliza fuera de la ventana. Tratar un prompt como un control de acceso es cómo terminas explicando un incidente.
Así que, en esta construcción, la aplicación (enforcement) consta de dos capas de código, y ninguna consulta el juicio del modelo.
La primera capa es un bloqueo estricto de herramientas. Las extensiones de Pi pueden interceptar cada llamada a una herramienta, y la del productor hace exactamente eso: cualquier cosa que no esté en una lista permitida de tres elementos es rechazada antes de ejecutarse:
const PRODUCER_TOOLS = ["mailbox_wait", "mailbox_send", "query_masked"];
pi.on("tool_call", async (event) => {{
if (!PRODUCER_TOOLS.includes(event.toolName)) {{
return {{ block: true, reason: `Blocked: the production agent may only use ${{PRODUCER_TOOLS.join(", ")}}.` }};
}}
}});Eso anula bash, read, write y edit en el lado de producción. El modelo físicamente no puede abrir una sesión de psql, leer el .env o crear su propia consulta manualmente. Su única ruta hacia los datos es query_masked, y esa ruta siempre enmascara.
La segunda capa es la máscara en sí: un módulo puro de TypeScript, sin DB, sin red, sin modelo. query_masked pasa cada fila por él antes de devolver nada:
const {{ rows }} = await dbPool().query(sql, args);
// EL LÍMITE: enmascarar cada fila aquí, antes de que sea devuelta al modelo.
const masked = rows.map((r) => maskRow(params.entity as Entity, r));Las reglas son por entidad y aburridas a propósito. last_name conserva su primera letra: Johnson se convierte en J***. Un SSN de 224-30-8280 se convierte en ***-**-8280. Un número de tarjeta conserva sus primeros y últimos cuatro: 4111 **** **** 1234. Los correos electrónicos se convierten en d***@yahoo.com. Las fechas de nacimiento conservan el año y ponen a cero el mes y el día; sigue siendo un DATE válido, por lo que el cálculo de edad en el lado de pruebas sigue funcionando. Las direcciones de las calles se redactan, pero la ciudad y el estado pasan. Y las transacciones pasan intactas, porque los montos y los timestamps no contienen identificadores directos, y el lado de pruebas necesita datos con formas realistas para ser útil.
Ese último punto es la restricción de diseño que hace que todo sea utilizable: cada valor enmascarado sigue siendo compatible para inserción con el tipo de columna original. El agente de pruebas toma lo que recibe y lo inserta directamente en fintechT en orden de clave foránea: clientes, luego direcciones y cuentas, luego tarjetas y transacciones. Mismo esquema, mismas formas, un número de tarjeta que aún pasa una verificación de longitud. Lo suficientemente bueno para construir y probar, inútil para cualquiera que robe la base de datos de pruebas.
También hay una ruta de sincronización incremental adecuada, porque "repoblar todo" no es como funcionan los entornos reales. El consumidor lee su marca de agua local —el ID máximo que ya tiene por tabla— y pide a producción solo las filas posteriores a este. Los nuevos datos aterrizan en producción, el delta cruza el buzón enmascarado y nada de lo que ya se sincronizó se mueve dos veces. Es change data capture con un límite de seguridad en el medio, y el productor limita cualquier respuesta individual a 200 filas para que un agente confundido no pueda pedir el mundo entero.
Verifícalo, no te fíes de la intuición
Un control de seguridad que no has probado es solo una esperanza. Lo bueno de canalizar todo el tráfico a través de una tabla mq.messages es que el cable (wire) en sí se puede consultar después del hecho. Así que la comprobación es una línea de SQL: escanear cada cuerpo de mensaje que haya cruzado el bus en busca de cualquier cosa con forma de SSN o un número de tarjeta de 16 dígitos.
SELECT count(*) FROM mq.messages
WHERE body::text ~ '[0-9]{{3}}-[0-9]{{2}}-[0-9]{{4}}'
OR body::text ~ '[0-9]{{16}}';Resultado esperado: 0. Resultado real, después de poblar producción con tarjetas válidas por Luhn y SSNs con formato real, y dejar que los agentes sincronicen clientes, cuentas y tarjetas: 0. Mientras tanto, fintechP todavía muestra los valores originales y fintechT muestra J***, ***-**-8280, 4111 **** **** 1234. El módulo de enmascaramiento también es una función pura, por lo que se puede probar unitariamente en una línea de Node sin base de datos alguna; además, el repositorio incluye scripts de sincronización sin modelo que reutilizan el mismo mask.ts, para que puedas verificar el límite sin gastar un solo token.
Por qué ayuda un arnés minimalista
Este patrón funciona en cualquier framework de agentes en principio. Es más fácil confiar en Pi porque hay muy poco entre la herramienta y el cable. El bucle del agente es pequeño, la superficie de herramientas son cuatro primitivas y la personalización son archivos que puedes leer de una sentada. Cuando un revisor de seguridad pregunta "¿dónde exactamente se enmascara el SSN y puede algo evadirlo?", puedo señalar un módulo —mask.ts es todo el límite de seguridad— más un bloque de herramientas de diez líneas, y responder de verdad. El agente de producción incluso imprime su traza completa en cada ciclo, cada llamada a herramienta y resultado, para que puedas observar lo que hace antes de que regrese cualquier dato. Auditar un framework robusto con plugins, middleware oculto y una docena de puntos de inyección es una tarde mucho peor.
Esa es la parte a la que siempre vuelvo. El enmascaramiento es trivial. La disciplina es ponerlo en el productor, en código, en un harness lo suficientemente delgado como para que el límite sea toda la historia. Dale a un agente la tabla de clientes real y una instrucción cortés, y habrás construido una filtración con pasos extra. Dale una herramienta que solo puede devolver filas enmascaradas, bloquea todo lo demás y haz un grep al tráfico después para demostrarlo; así, la cuestión de si el otro agente se comporta bien nunca llega a plantearse.
