Acceso SSH para un agente de IA sin darle tus secretos

Tabla de contenidos

Tienes un VPS con un proyecto que lleva años en marcha. No hay pipeline: cada despliegue es un ssh y cuatro comandos que te sabes de memoria. Claude Code o Codex ya te hacen el trabajo en local, y la siguiente idea llega sola: que el agente se conecte y despliegue también.

Se puede hacer. Lo que importa es con qué usuario entra.

Si tu terminal puede ejecutar ssh usuario@servidor y ese usuario llega a todo, el agente también llega a todo en cuanto le dejas lanzar ese comando. Da igual que no conozca la contraseña, que no esté instalado en el servidor o que la conexión no aparezca en ninguna skill.

Para comprobar hasta dónde llega cada medida monté un laboratorio: un contenedor Linux con OpenSSH 9.6, un servidor web de mentira con secretos de mentira y Claude Code lanzando intentos reales de saltarse cada barrera. Las capturas de este post son salida real de esas pruebas. Aquí va el primer paso, el que casi todo el mundo da mal el primer día.

Una frase en el AGENTS.md no cierra nada

Lo primero que se le ocurre a cualquiera es escribirle normas al agente, en el AGENTS.md (o en el CLAUDE.md, si usas Claude Code):

AGENTS.md
Nunca leas archivos .env del servidor.Nunca salgas de /var/www/miapp.Nunca ejecutes sudo.

Es una buena política y el modelo la va a respetar casi siempre. El problema está en ese «casi»: una instrucción mal entendida, una inyección de prompt escondida en un fichero que el agente lee para otra cosa o un comando que parecía inofensivo.

Para verlo, en el laboratorio hay un usuario, ai-free, con una shell normal y corriente. Es lo que hace casi todo el mundo: crear un usuario y darle la clave al agente.

Un usuario con shell completa: el cd inicial no confina, y solo los permisos Unix frenan la lectura del .env

Arrancar la sesión en /var/www/proyecto-a no encierra a nadie: con un cd /etc ya está fuera. Lo único que frena la lectura del .env es el permiso 600 del sistema de ficheros, no la frase del markdown. Y lo que no protejas con permisos, como /etc/passwd, se lee sin más.

Los límites los pone el servidor. Las instrucciones al agente siguen siendo útiles, porque evitan la mayoría de errores honestos, pero nunca son la última barrera.

Un usuario para el agente y otro para la aplicación

El agente necesita su propio usuario, distinto del que ejecuta la aplicación y del tuyo. Sin contraseña, para que solo pueda entrar con clave (darle esa clave es el paso siguiente). En el laboratorio hay dos que importan aquí:

Usuario Para qué existe A qué llega
app-runtime Ejecuta la aplicación: PHP, workers, cron Es el dueño del código y del .env
ai-deploy El que usa el agente Solo a lo que necesita para desplegar

En tu VPS, el usuario que ejecuta la aplicación probablemente ya existe y se llama de otra forma: en Debian y Ubuntu, PHP-FPM y nginx corren como www-data. Mira cuál es con ps -o user= -C php-fpm8.3 (cambia la versión por la tuya) y ponlo donde aquí pone app-runtime. Si usas el nombre del laboratorio sin más, tras el chmod 600 la aplicación dejará de leer su propio .env.

Así se montan, como root en el servidor:

En el servidor, como root
useradd -m -s /bin/bash app-runtime   # en tu VPS, quizá ya existe como www-datauseradd -m -s /bin/bash ai-deployusermod -p '*' ai-deploy   # sin contraseña: solo entra con clave# Los secretos son del usuario que ejecuta la app, y de nadie más.# Ojo: -R cambia el dueño de todo el árbol (cachés, storage, subidas)chown -R app-runtime:app-runtime /var/www/proyecto-a /var/www/proyecto-bchmod 600 /var/www/proyecto-a/.envchmod 750 /var/www/proyecto-b# ai-deploy solo escribe donde el despliegue lo necesitainstall -d -o app-runtime -g app-runtime /var/www/proyecto-a/public/assetssetfacl -R -m u:ai-deploy:rwX /var/www/proyecto-a/public/assetssetfacl -R -d -m u:ai-deploy:rwX /var/www/proyecto-a/public/assets   # y lo que se cree después

Línea a línea:

  • usermod -p '*' deja la cuenta sin contraseña válida: no se puede entrar tecleando nada, solo con clave. useradd ya la deja bloqueada, pero así queda explícito. Como además no tiene entrada en sudoers, tampoco hay sudo.
  • chmod 600 sobre el .env hace que solo su dueño, app-runtime, pueda leerlo.
  • chmod 750 sobre proyecto-b deja fuera a cualquiera que no sea su dueño o su grupo. Si en el servidor tienes otro proyecto, el agente no llega a él.
  • setfacl da a ai-deploy permiso de escritura en public/assets sin cambiar el dueño de nada. Es una ACL: un permiso extra para un usuario concreto encima de los permisos normales. La primera línea cubre lo que ya existe; la segunda, con -d, es la ACL por defecto, la que heredan los ficheros que la aplicación cree después. Sin ella, ai-deploy no podría sobrescribirlos.

No le des escritura en todo public/: un .php que dejara ahí lo ejecutaría PHP-FPM con los permisos de la aplicación. Por eso public/assets se sirve como contenido estático:

En el server de nginx
location ^~ /assets/ {    try_files $uri =404;}

El ^~ hace que nginx no llegue a evaluar la regla que manda los .php a PHP-FPM, así que lo que haya en esa carpeta nunca se ejecuta.

Con esto, ai-deploy no lee el .env, no se asoma a proyecto-b y no puede usar sudo. Parece suficiente. No lo es.

Leer el .env sin poder leerlo

Este es el detalle que se suele pasar por alto. Un usuario que no puede leer un fichero puede conseguir su contenido si es capaz de escribir código que ejecuta otro usuario que sí puede leerlo.

En Laravel, en WordPress o en cualquier aplicación PHP, el código de app/ lo ejecuta el usuario del runtime. Y ese usuario lee el .env, porque lo necesita para conectarse a la base de datos.

Lo probé dando a un usuario permiso de escritura en app/, que es justo lo que parece lógico si quieres que el agente despliegue código:

El usuario no puede leer el .env, escribe un script en app/, el runtime lo ejecuta y el secreto aparece en public/

El cat .env directo falla, como debe. Pero basta con dejar un cache-warmup.sh en app/. En cuanto el runtime lo ejecute (un cron, un worker, un hook de despliegue o un .php servido por PHP-FPM), lo hace con sus permisos y el secreto acaba en public/leak.txt, que cualquiera puede descargar desde el navegador. En el laboratorio lo lancé a mano como app-runtime, que es lo que haría cualquiera de esos procesos. El permiso 600 no ha servido de nada.

No hace falta que el agente quiera hacerlo. Basta un fichero con instrucciones escondidas que lea mientras trabaja, o un script que parecía inofensivo y que nadie revisó antes de que el runtime lo ejecutara.

Desplegar no es subir código

La salida es separar tres cosas que solemos meter en el mismo saco:

  1. Desplegar: el agente elige qué versión se publica. No sube código que él haya escrito.
  2. Tocar secretos: nunca desde la identidad del agente, ni directa ni indirectamente.
  3. Operaciones privilegiadas: reinicios, migraciones o limpiar cachés van en scripts que has revisado tú.

En el laboratorio, ai-deploy solo puede publicar una versión que ya existe. Si lo que se despliega sale de un tag de git revisado, el agente no tiene por dónde colar un script nuevo en el servidor, y el runtime nunca ejecuta nada que no hayas visto.

Cómo aplicarlo: el usuario del agente no escribe en ningún directorio que ejecute el runtime. Si necesita escribir, que sea en una carpeta donde el servidor web no ejecute nada, como la public/assets de antes, y que el código llegue siempre desde una versión que has aprobado tú.

Lo que este usuario todavía puede hacer

Con el usuario propio, los permisos bien puestos y sin escribir en nada que ejecute el runtime, el agente no llega al .env. Pero ai-deploy sigue teniendo una shell. Puede salir de su carpeta, leer todo lo que no esté protegido con permisos y abrir un túnel hacia la red interna del servidor. Y si un día te conectas con ssh -A, el servidor puede usar las claves de tu ssh-agent mientras dure la sesión.

La tentación es arreglarlo en tu máquina, con una regla que obligue al agente a pedir permiso antes de cualquier ssh. La probé contra ocho formas de escribir el mismo comando. Cinco pasaron sin preguntar.

Lo que de verdad cierra el servidor es que la clave del agente no sirva para abrir una shell, sino solo para lanzar unas pocas operaciones que tú has decidido. Ese es el siguiente paso, y es el que más protege.

Seis capas entre el agente y el .env del servidor: las reglas del agente y el hook se saltan con /usr/bin/ssh y con 's'+'sh'; el usuario propio con sus permisos es la capa que se monta en este post; la credencial, el comando forzado y la auditoría quedan para el artículo completo

Qué incluye el artículo completo

El resto es un artículo premium para suscriptores de Web Reactiva. Todo está probado en el mismo laboratorio, con 11 capturas de salida real y el código completo listo para copiar:

  • Cómo atar una clave SSH a un único programa, de forma que el agente no tenga shell aunque la pida
  • El script despachador que acepta cuatro operaciones y rechaza inyecciones, terminales y SFTP
  • Certificados SSH que caducan solos, y lo que la caducidad no corta
  • Por qué la regla ask de Claude Code deja pasar cinco de ocho variantes de ssh
  • Un hook que solo deja salir hacia el servidor por una puerta, y el truco que se le escapa
  • La capa que aguanta cuando fallan todas las de tu máquina
  • La configuración equivalente en Codex, con sandbox sin red y reglas por prefijo
  • Auditoría en los dos lados y cómo queda una sesión de despliegue de principio a fin

Artículo premium

El tutorial completo para dar acceso SSH a un agente sin entregarle el servidor

Lo que has leído es el primer paso: un usuario propio y unos permisos que guardan el .env. El resto, con el comando forzado, los certificados de vida corta, el hook y las pruebas de cómo se salta cada capa, es un artículo premium que puedes comprar.

  • Una clave que no abre una shell aunque la pidas
  • Cinco formas de saltarse la regla ask de Claude Code
  • Certificados que caducan, y la sesión que sobrevive
  • La capa que aguanta cuando fallan las demás
  • La misma configuración en Codex
Compra el artículo completo →
Imagen de Daniel Primo
Claude, IA de Anthropic

Escrito con la ayuda de la IA generativa de Claude, fuentes fidedignas y con un human in the loop:
Dani Primo.

CEO en pantuflas de Web Reactiva. Programador y formador en tecnologías que cambian el mundo y a las personas. Activo en linkedin, en substack y canal @webreactiva en telegram

12 recursos para developers cada domingo en tu bandeja de entrada

Además de una skill práctica bien explicada, trucos para mejorar tu futuro profesional y una pizquita de humor útil para el resto de la semana. Gratis.