# Helpers SSH sin estado para flujos de trabajo más seguros con agentes de IA

Los agentes autónomos de programación no deberían conservar una sesión SSH de una ejecución a la siguiente. Un contexto de ejecución local nuevo para cada acción dificulta que una ejecución anterior preste autoridad, identidad o efectos secundarios inexplicables a otra posterior. También ofrece a los revisores una unidad de evidencia clara: este proceso solicitó este comando contra este host y el helper devolvió este resultado.

Eso no hace que SSH sea inofensivo. Una conexión nueva no deshace un despliegue, no detiene un proceso en segundo plano ni protege una cuenta de servidor con acceso excesivo. La ejecución sin estado resuelve un problema más concreto y muy práctico: elimina la persistencia local que los agentes pueden aprovechar accidentalmente con especial facilidad.

## Los helpers SSH sin estado eliminan la persistencia local, no el estado remoto

Un helper SSH sin estado crea la conexión necesaria para una acción, ejecuta esa acción, devuelve el resultado y termina sin conservar un transporte autenticado para llamadas posteriores. La siguiente acción comienza de nuevo con un proceso helper y un intento de conexión nuevos.

La distinción importa porque a menudo se dice «SSH sin estado» cuando en realidad se quiere decir «nada persiste en ninguna parte». Eso es falso. SSH tiene varios lugares donde puede existir estado:

- El cliente puede conservar una conexión maestra, un socket de multiplexación, una entrada de hosts conocidos, un socket de agente, configuración o archivos temporales.
- El host remoto puede conservar un directorio de trabajo, el historial del shell, un artefacto subido, un archivo de bloqueo, un proceso de servicio, una caché de paquetes y una base de datos modificada.
- El sistema de autorización puede conservar una cuenta, un certificado SSH, una clave pública y permisos.

Un helper sin estado se ocupa de la primera categoría. Impide que una llamada posterior del agente herede silenciosamente la conexión activa o el contexto local de credenciales de una llamada anterior. El lado remoto sigue siendo un ordenador real con consecuencias reales.

Por eso «conexión nueva» es una expresión operativa mejor que «servidor nuevo». El helper es nuevo. El servidor no.

Supón que un agente ejecuta una comprobación de despliegue y termina después de que un revisor rechace el siguiente cambio propuesto. Si la primera ejecución dejó un socket de multiplexación, otro proceso de la misma máquina puede conectarse a un canal ya autenticado. El segundo proceso ha saltado un límite de seguridad que los operadores creían aplicar a cada ejecución. Con un helper sin estado, ese segundo proceso debe solicitar una acción nueva y volver a pasar por la ruta de autorización prevista.

El beneficio es en parte de seguridad y en parte de diagnóstico. Cuando un revisor de incidentes ve una conexión compartida que vivió durante horas, debe reconstruir cuál de varios procesos la utilizó. Cuando cada solicitud controla su propio ciclo de vida de conexión, el registro tiene un inicio y un final naturales.

## Compartir conexiones entra en conflicto con la atribución por ejecución

OpenSSH admite deliberadamente el uso compartido de conexiones. El manual de `ssh_config` describe `ControlMaster` como una opción que permite que varias sesiones compartan una única conexión de red. `ControlPersist` puede mantener la conexión maestra abierta en segundo plano después de que los clientes terminen. Estas opciones son útiles para un administrador que emite varios comandos desde un terminal. Son un mal valor predeterminado para ejecuciones de agentes que necesitan autoridad y registros separados.

Considera esta configuración habitual:

```sshconfig
Host build-box
  HostName 10.0.0.24
  User deploy
  ControlMaster auto
  ControlPath ~/.ssh/cm-%r@%h:%p
  ControlPersist 15m
```

La primera invocación de SSH crea un socket maestro autenticado en `~/.ssh`. Las invocaciones posteriores que puedan acceder a ese socket pueden reutilizar la conexión durante la ventana de persistencia de quince minutos. Un proceso de agente iniciado después de que termine el proceso original puede parecer una conexión independiente en su propia transcripción, aunque en realidad esté usando un transporte establecido antes.

Esto crea cuatro problemas distintos.

Primero, cambia el significado de la aprobación. Si una persona aprobó que un proceso de agente concreto ejecutara una comprobación, esa aprobación no debería cubrir automáticamente un proceso nuevo que casualmente encuentre el mismo socket.

Segundo, resulta más difícil inspeccionar la identidad del destino en el momento adecuado. La verificación del host SSH ocurre cuando se establece la conexión maestra. Los clientes posteriores heredan ese resultado. Un revisor que examine una llamada posterior debe localizar un evento de conexión anterior para saber qué verificación del host tuvo lugar.

Tercero, los registros pierden una relación sencilla entre una solicitud y un transporte. Un registro tradicional del servidor SSH puede anotar un inicio de sesión mientras los registros de nivel de aplicación muestran muchos comandos. Ambos pueden ser correctos, pero correlacionarlos requiere trabajo justo cuando nadie tiene tiempo de sobra.

Cuarto, el propio socket se convierte en un activo. OpenSSH advierte en `ssh_config` que cualquiera que pueda acceder al socket de control puede acceder a la conexión. Los permisos de archivo ayudan, pero no hacen que un canal autenticado compartido sea adecuado para un espacio de trabajo hostil o simplemente confuso.

Desactiva la multiplexación en las llamadas que necesitan una atribución clara. Una invocación directa puede dejar esa decisión explícita:

```sh
ssh -o ControlMaster=no -o ControlPath=none deploy@build-box 'id; hostname'
```

La salida esperada tiene, por ejemplo, esta forma:

```text
uid=1004(deploy) gid=1004(deploy) groups=1004(deploy)
build-box
```

El comando solo demuestra qué cuenta y qué host respondieron. No demuestra que la cuenta tuviera los permisos adecuados ni que la tarea solicitada fuera segura. Aun así, ofrece al revisor una acción que puede relacionar con un único intento de conexión.

No confundas la ausencia de una línea `ControlMaster` con una solución completa. Una configuración global de SSH puede establecerla, un wrapper puede añadirla y un proceso puede apuntar `ControlPath` a un lugar inesperado. El límite de ejecución debe establecer sus propias opciones SSH en lugar de confiar en los archivos de configuración del repositorio.

## Cada ejecución del agente necesita su propio límite de autoridad

Una ejecución de agente no es una sesión de terminal humana con un mecanógrafo más rápido. Puede recibir instrucciones nuevas de incidencias, pull requests, resultados de pruebas generados, texto de registros copiado y archivos fuente que no creó. Cualquiera de esos contenidos puede dirigirlo hacia comandos que no formaban parte de la intención original.

Eso cambia el significado de las funciones de conveniencia. Una persona que conserva una sesión normalmente sabe que la está conservando. Un agente puede comenzar una tarea posterior sin saber que sigue disponible un canal antiguo. Su interfaz de herramientas a menudo solo muestra «ejecutar comando», mientras el cliente subyacente encuentra silenciosamente un socket y obtiene autoridad previa.

Considera el proceso del agente como la unidad que recibe la aprobación. Cuando termina, su acceso debería terminar con él. Si comienza un proceso nuevo, aunque lo haya iniciado el mismo editor u orquestador, exige una decisión de autorización nueva antes de que llegue a un host protegido.

Este enfoque es más estricto de lo que muchos desarrolladores desean al principio. Ven aprobaciones repetidas y recurren a una lista de permisos amplia. Eso resuelve una molestia eliminando el límite útil. Un diseño mejor agrupa acciones relacionadas en una ejecución explícita, concede acceso después de que un revisor vea su identidad y retira el acceso cuando termina la ejecución.

La identidad de firma de código importa aquí, pero no responde a todas las preguntas. Puede indicar al revisor qué programa firmado inició el proceso. No puede decirle si el programa actúa siguiendo instrucciones fiables o si el repositorio actual contiene un prompt malicioso. La aprobación debe vincular un proceso conocido a un periodo limitado de autoridad, no aprobar todas las instrucciones que ese proceso vaya a leer.

Un límite claro también gestiona correctamente los reintentos. Un fallo de red puede provocar una solicitud nueva. La pasarela debería registrar que la primera acción falló antes de ejecutarse o durante el transporte y registrar el reintento como otra acción. Agrupar los reintentos en un único registro de éxito ambiguo elimina información que los investigadores de incidentes necesitan.

## El reenvío del agente destruye el límite de credenciales

El reenvío del agente SSH ofrece a un host remoto una vía para pedir a tu agente de autenticación local que firme desafíos. No copia el archivo de clave privada al host, pero esa diferencia puede sonar más segura de lo que realmente es.

El manual de `ssh` de OpenSSH advierte que los usuarios capaces de saltarse los permisos de archivo en el host remoto pueden acceder al agente reenviado. Un usuario root en ese host puede usar el socket reenviado para autenticarse en otro lugar mientras la conexión permanezca abierta. No recibe el material de la clave privada, pero puede usar la autoridad que hay detrás. En un agente autónomo, ese suele ser precisamente el riesgo que querías evitar.

Evita llamadas como esta:

```sh
ssh -A deploy@build-box 'git fetch && ./deploy.sh'
```

La opción `-A` reenvía el agente de autenticación local. Un script de despliegue que llegue a una segunda máquina puede hacer que el host remoto solicite firmas mediante el socket reenviado. Una máquina de compilación comprometida, o un script modificado por un colaborador no fiable, obtiene una ruta inesperada hacia otro entorno.

Usa en su lugar una credencial que pertenezca a la tarea de destino. Puede ser una cuenta de despliegue con una clave pública restringida, un certificado SSH de corta duración emitido para un entorno o una cuenta de servicio remota que solo pueda obtener el artefacto que necesita. El mecanismo exacto depende de tu infraestructura. La disciplina no cambia: no conviertas la acción aprobada de un agente en acceso general de autenticación desde una máquina remota.

Algunos equipos mantienen el reenvío porque el SSH anidado resulta cómodo. Tienen un bastion host y después saltan a hosts privados. Usa `ProxyJump` o una ruta de pasarela estrictamente controlada siempre que sea posible. Esos patrones mantienen el control de la conexión en el cliente, en lugar de colocar un socket de agente local con mucho poder en el host intermedio.

El reenvío de puertos merece la misma desconfianza. Los reenvíos locales, remotos y dinámicos pueden convertir un comando aprobado en un túnel abierto que sobreviva al trabajo útil de la acción. Un helper sin estado debería rechazar las funciones de reenvío o exigir una aprobación separada, salvo que la tarea solicitada las necesite específicamente.

## Una cuenta remota precisa limita el daño de las malas instrucciones

El estado local nuevo no ayuda cuando el inicio de sesión remoto puede hacerlo todo. La cuenta a la que llega un agente debe tener una función definida y permisos acordes con ella.

En un host de despliegue, podría ser una cuenta capaz de cambiar el enlace simbólico de una versión, reiniciar un servicio y leer un directorio de despliegue. No debería tener también escalada de privilegios sin contraseña para todo, acceso al directorio personal de cada usuario y permiso para modificar el runner de CI. Estas combinaciones aparecen porque alguien quería que una tarea de automatización funcionara antes de la comida. Se mantienen porque nadie vuelve después para reducirlas.

OpenSSH ofrece a los operadores del servidor varios controles en el formato `authorized_keys`. El manual documenta opciones como `command=`, que fuerza un comando cuando la clave se autentica, y opciones que desactivan el reenvío de puertos, el reenvío X11 y el reenvío del agente. Estos controles resultan útiles cuando el destino tiene una superficie de comandos pequeña y estable.

Por ejemplo, un equipo de operaciones puede asociar una clave pública dedicada a un wrapper remoto restringido:

```text
command="/usr/local/libexec/release-action",no-port-forwarding,no-X11-forwarding,no-agent-forwarding ssh-ed25519 AAAA... agent-release
```

El comando forzado debe analizar los argumentos solicitados de forma defensiva. No pases texto del usuario directamente a un shell. Un wrapper seguro acepta un conjunto pequeño de verbos, valida los identificadores de versión con un formato esperado, escribe un registro de auditoría y ejecuta la operación fija correspondiente. Si el agente necesita acceso arbitrario al shell, reconócelo abiertamente y mantén los permisos de la cuenta lo bastante limitados para ese riesgo.

Un comando forzado no es un lenguaje de políticas genérico. Es una interfaz deliberadamente pequeña en un host. Esa limitación es su fortaleza. Puedes probarla, revisarla e identificar qué acciones puede realizar sin interpretar un conjunto de reglas en lenguaje natural.

Ten cuidado con `sudo`. Una línea que permite un script aún puede convertirse en acceso amplio si el script acepta rutas arbitrarias, carga configuración desde un directorio modificable, inicia un editor o llama a otro programa mediante una variable de entorno controlada por el usuario. Lee toda la cadena de llamadas. La línea de permisos es el inicio de la revisión, no el final.

## El registro de acciones debe responder quién, qué, dónde y resultado

Un registro de auditoría útil hace algo más que indicar que se usó SSH. Debe permitir que una persona reconstruya una acción sin adivinar qué ejecución del agente la originó.

Registra el identificador de la ejecución del agente, su autoridad de proceso, la decisión del revisor, el destino, la cuenta remota, el comando u operación solicitados, la hora, el resultado y una referencia a la salida. Captura la identidad del host resuelta cuando tu diseño pueda mostrarla. Registra también una denegación. Una acción denegada demuestra que el límite funcionó como estaba previsto y puede revelar una instrucción incorrecta antes de que cause daños.

Los comandos necesitan un tratamiento especial. El comando sin modificar puede contener contraseñas, tokens de acceso, valores de consultas y rutas privadas. Ocultarlo todo vuelve inútil el registro, mientras que conservarlo todo puede convertirlo en otro almacén de secretos. La respuesta práctica es diseñar la interfaz de acciones para que los valores sensibles no aparezcan en los argumentos desde el principio. Cuando sea necesario ocultarlos, registra tanto el evento de ocultación como suficiente contexto estructurado para identificar la operación.

La salida presenta el mismo problema. Un error de un comando de despliegue puede mostrar una variable de entorno o una URL con credenciales. Mantén la salida separada del registro de eventos principal, limita quién puede leerla y trátala como una entrada potencialmente sensible para la siguiente ejecución del agente. No devuelvas automáticamente una transcripción completa de producción a un agente solo porque haya pedido diagnosticar un fallo.

Hay otra distinción que conviene mantener clara: un registro que indica que un agente solicitó una acción es distinto de un registro que demuestra que la acción llegó al host remoto. Los fallos de transporte, el rechazo de la clave del host, el fallo de autenticación remota, el estado de salida del comando y las conexiones perdidas son resultados distintos. Un buen registro asigna a cada resultado su propio estado en lugar de clasificarlos todos como «fallidos».

La evidencia de manipulación cambia el modelo de confianza del registro. Un registro encadenado mediante hashes puede mostrar que las entradas se alteraron, eliminaron o reordenaron cuando la verificación falla. No puede decirte que el comando original fuera sensato ni hacer que un endpoint comprometido diga la verdad. Protege el historial que tienes, pero no sustituye el refuerzo de la seguridad del sistema.

## Un despliegue fallido muestra por qué ayuda el estado nuevo

Imagina que un agente de programación recibe una solicitud para desplegar una rama en un host de staging. Su primera llamada abre una conexión SSH, comprueba el espacio en disco e inicia un comando de lanzamiento. El comando falla porque existe un bloqueo de migración. El agente ve el fallo, lee las notas del repositorio y recibe un mensaje copiado que le dice «elimina el bloqueo y vuelve a intentarlo con la cuenta de emergencia».

Ese mensaje puede ser una sugerencia inocente pero insegura, o una inyección de prompts oculta en un archivo que se pidió al agente que inspeccionara. En cualquier caso, el agente ahora propone un comando fuera de la rutina de despliegue original.

Con una conexión compartida de larga duración, varias cosas pueden salir mal. El transporte original puede seguir autenticado como una cuenta de despliegue demasiado amplia. Un proceso nuevo puede reutilizarlo. El revisor solo ve la aprobación de la primera conexión y quizá no percibe con claridad la escalada posterior. Si la configuración también reenviaba un agente SSH, el host de staging puede tener una vía para usar otra identidad.

Con un helper sin estado y autorización por ejecución, la siguiente llamada del agente comienza como una solicitud nueva. El límite identifica el nuevo proceso del agente, registra el comando y el destino realmente propuestos y solicita aprobación si la ejecución aún no está aprobada. El revisor puede rechazar la solicitud de la cuenta de emergencia y aprobar en su lugar una operación limitada que inspeccione quién posee el bloqueo.

La frescura de la conexión no decidió que eliminar un bloqueo fuera inseguro. Las personas todavía deben juzgar las operaciones. Lo que hizo fue impedir que un canal antiguo ya aprobado volviera irrelevante esa decisión en silencio.

Por eso un diseño SSH sin estado debe estar junto a las restricciones de comandos adecuadas, no sustituirlas. El aislamiento de la conexión limita la autoridad local heredada. Una interfaz remota restringida limita lo que puede hacer una conexión nueva. Los registros de auditoría permiten entender ambas decisiones más adelante.

## La ejecución sin estado necesita un diseño operativo explícito

Un helper que abre una conexión para cada solicitud necesita un comportamiento claro respecto a la verificación del host, los tiempos de espera, la cancelación y los informes de errores. Dejar esos detalles en manos de lo que proporcione el entorno del agente recrea el estado oculto con otro nombre.

Verifica las claves de host. `StrictHostKeyChecking=yes` de OpenSSH indica al cliente que rechace los hosts cuya clave sea desconocida o haya cambiado, en lugar de preguntar de forma interactiva. Normalmente es la postura correcta para una acción automatizada protegida. Distribuye las claves de host fiables mediante un proceso controlado y termina con un error cerrado si el destino no coincide.

Usa tiempos de espera limitados. Una conexión SSH bloqueada debe devolver finalmente un fallo registrado, no permanecer disponible indefinidamente como una sesión a medio terminar. El helper también debería terminar los procesos hijos cuando se cancela, siempre que el sistema operativo lo permita, e informar de si puede confirmar esa terminación. No informes de que un comando remoto se canceló cuando el transporte se perdió después de que el servidor ya lo hubiera iniciado.

Mantén pequeña y fácil de inspeccionar la forma de la solicitud. Un límite de acciones puede representar una solicitud SSH con campos estructurados como destino, cuenta, comando, directorio de trabajo si se admite y tiempo de espera. No debería aceptar un bloque de configuración del shell que cambie archivos de identidad, comportamiento del proxy, rutas de sockets de control y opciones de reenvío sin revisión.

Una pasarela puede conservar la credencial SSH mientras el agente solicita una acción. Sallyport sigue este modelo para SSH mediante su helper `sp-ssh` sin estado incluido: el agente no recibe la clave SSH y la app ejecuta la acción en su lugar.

El límite también debe distinguir entre la aprobación de una sesión y la aprobación de cada llamada. La aprobación de sesión es adecuada para una ejecución de agente delimitada en la que un revisor acepta una secuencia de trabajo prevista. La aprobación por llamada es adecuada para credenciales o destinos en los que cada uso merece una decisión humana separada. No finjas que ambas opciones ofrecen el mismo control. Cambian de forma deliberada la interrupción por la granularidad.

## Comprueba la reutilización oculta antes de confiar en el límite

Puedes comprobar si tu configuración crea realmente conexiones independientes. Hazlo en un entorno que no sea de producción y con una cuenta sin acceso sensible.

Primero, inicia dos procesos separados de agente o helper, cada uno con un comando inocuo como `id`. Comprueba el registro de autenticación del servidor SSH y tu registro de acciones. Deberías ver dos intentos de conexión que puedas relacionar con dos registros de proceso separados.

Después inspecciona el lado del cliente para buscar sockets de multiplexación. En sistemas macOS y similares a Linux, una inspección amplia podría verse así:

```sh
find ~/.ssh -type s -print
```

Si muestra una ruta correspondiente a un socket de control, identifica qué configuración lo creó. No elimines un socket a ciegas en una máquina compartida; primero averigua quién lo posee y qué clientes activos tiene. Elimina la configuración de uso compartido de conexiones de la ruta de ejecución del helper aislado en lugar de depender de la limpieza posterior.

Por último, ejecuta una acción aprobada, termina el proceso del agente e inicia un segundo proceso. El segundo proceso no debería heredar la autorización, el transporte de autenticación ni la capacidad de invocar un shell remoto del primero sin una decisión nueva. Comprueba la ruta denegada con el mismo cuidado que la aprobada. Los equipos descubren a menudo que su ruta normal está aislada, mientras que un gestor de errores vuelve a un comando SSH directo.

Esta última comprobación detecta el fallo conocido: una pasarela cuidadosa gestiona las solicitudes normales, pero un script de reintento o una herramienta de diagnóstico la evita cuando aumenta la presión. Normalmente alguien añade el bypass para restaurar el servicio rápidamente. Después se convierte en la ruta que encuentra un agente cuando falla su primer intento.

El estado nuevo para cada acción SSH no es un ritual. Ofrece al trabajo autónomo un límite que las personas pueden inspeccionar, aprobar, revocar e investigar. Mantén limitada la cuenta remota, rechaza las credenciales reenviadas, registra los resultados con precisión y haz que cada proceso de agente nuevo se gane su propio acceso.
