# Los procesos hijos huérfanos de un agente pueden mantener activas las credenciales

Un agente de IA cuyo tiempo límite ha vencido no necesariamente se ha detenido. Si su envoltorio de shell, compilador, asistente HTTP o comando SSH creó un proceso hijo antes de que se activara el tiempo límite, ese hijo puede continuar después de que desaparezca el PID del padre. Si heredó una credencial utilizable o una conexión ya autenticada, el tiempo límite no terminó con la autoridad que creías haber revocado.

He visto informes de incidentes que empiezan con «el agente fue eliminado a las 14:03» y terminan con una llamada a la API a las 14:11. Normalmente nadie descubrió un exploit ingenioso. El ejecutor eliminó un proceso, mientras que el trabajo útil ya se había trasladado a otro. La limpieza de procesos parece una tarea de mantenimiento hasta que un agente autónomo puede usar credenciales de producción. Entonces pasa a formar parte del límite de seguridad.

## La muerte del padre no termina el trabajo

Un proceso hijo puede sobrevivir a su padre porque el kernel gestiona los procesos de forma independiente, no como extensiones desechables de un comando de shell. Cuando termina un padre, el sistema operativo reasigna sus hijos a un recolector del sistema o a un supervisor. El hijo conserva su propio PID, memoria, descriptores de archivo, sockets, directorio actual y, a menudo, su entorno.

Ese comportamiento es legítimo. Los sistemas de compilación lo utilizan. También los multiplexores de terminal. Los gestores de servicios dependen de él. El error consiste en tratar el PID del padre como el límite de ejecución de una ejecución del agente.

Considera una cadena habitual:

```text
agent-runner (PID 4102)
  shell tool wrapper (PID 4131)
    deployment script (PID 4140)
      ssh helper (PID 4144)
```

El ejecutor llega a su plazo y envía una señal al PID 4102. Si el shell o el script de despliegue no terminan con él, la cadena restante puede seguir ejecutándose. Un caso más problemático aparece cuando el script inicia un trabajo en segundo plano con `\u0026`, usa `nohup`, llama a un gestor de servicios o pide a un host remoto que inicie un trabajo. El padre puede morir correctamente mientras el hijo ya se ha independizado.

Por eso un proceso hijo huérfano de un agente es algo más que un consumidor de CPU que quedó atrás. Es la continuación no observada de una decisión que una persona o un planificador creía terminada. El daño depende de lo que el proceso todavía pueda alcanzar, pero el fallo empieza antes: el ejecutor eligió la unidad equivocada para detenerla.

POSIX documenta los elementos que explican este comportamiento en sus interfaces de procesos y control de trabajos. Un grupo de procesos es un conjunto de procesos relacionados con un único ID de grupo. Las señales pueden dirigirse al grupo en lugar de a un solo miembro. Una sesión agrupa uno o más grupos de procesos y normalmente mantiene una relación con una terminal controladora. Son conceptos distintos del kernel, y tratar esas palabras como intercambiables produce un código de limpieza débil.

## Un tiempo límite debe terminar una unidad de contención

Un tiempo límite es seguro solo cuando apunta a una unidad de contención establecida antes de iniciar el agente. Para un árbol de comandos local, esa unidad suele ser un grupo de procesos dedicado. El ejecutor registra inmediatamente el ID del grupo y, cuando vence el plazo, envía señales a ese grupo.

El orden importa. No esperes hasta la limpieza para descubrir a los hijos. Para entonces, el padre original puede haber desaparecido, la relación PPID puede resultar engañosa y puede haber comenzado otra ejecución.

Una ruta práctica para gestionar el tiempo límite tiene cinco acciones:

1. Crea un grupo dedicado o un trabajo propiedad de un supervisor antes de iniciar el agente.
2. Registra el ID de ejecución, el PID, el PGID, la hora de inicio, el comando y el plazo en un único registro.
3. Al vencer el plazo, marca la ejecución como expirada antes de enviar una señal, para que las acciones nuevas no se confundan con trabajo aprobado.
4. Envía `TERM` a la unidad de contención registrada, espera un breve periodo de gracia definido y envía `KILL` únicamente a los procesos que sobrevivan.
5. Captura el resultado y guarda las horas de las señales, el estado de salida y los PID que sigan vivos.

El registro va primero porque la limpieza puede competir con la salida rápida de un proceso. Si tu registro solo dice «agente eliminado», no puede responder qué proceso eliminaste, qué hijos compartían su grupo ni si un hijo escapó antes de la señal.

En macOS, inspecciona los identificadores en lugar de confiar en la vista de un árbol de procesos. Este comando muestra los campos que explican la mayoría de los fallos relacionados con tiempos límite:

```sh
ps -axo pid,ppid,pgid,sid,lstart,etime,command
```

Una ejecución expirada saludable podría dejar un resultado con esta forma antes de la limpieza:

```text
  PID  PPID  PGID   SID  STARTED                  ELAPSED COMMAND
 4102  3988  4102  4102  Thu Jul 24 14:00:02 2026   00:31 agent-runner ...
 4131  4102  4102  4102  Thu Jul 24 14:00:02 2026   00:31 /bin/sh -c ...
 4144  4131  4102  4102  Thu Jul 24 14:00:04 2026   00:29 ssh ...
```

Los valores son ilustrativos, no un patrón que debas codificar de forma fija. El dato útil es que la ejecución tiene un PGID conocido, 4102. La limpieza envía la señal a ese grupo, no solo al PID 4102. Un destino negativo indica a `kill` que envíe la señal a un grupo de procesos:

```sh
kill -TERM -4102
```

Verifica la sintaxis para el entorno de ejecución y el sistema operativo que uses. Algunos envoltorios interpretan mal los números negativos o tratan un argumento adicional como una opción del comando. No construyas un límite de seguridad alrededor de una línea de comandos que no hayas probado con un tiempo límite real.

Una señal al grupo también tiene un límite. Un hijo puede llamar a `setsid`, crear un grupo de procesos nuevo, entregar el trabajo a otro servicio o iniciar un proceso en una máquina remota. La disciplina de grupos cierra la vía de escape local habitual, pero no todas las vías posibles. Tu ejecutor debe tratar esas transiciones como entregas explícitas, con su propia cancelación y su propio registro de auditoría.

## Los grupos de procesos, las sesiones y las credenciales resuelven problemas distintos

Un grupo de procesos te proporciona un destino local para las señales. Una sesión ofrece un límite más amplio y puede aislar el control de trabajos de la terminal. Ninguno revoca una credencial que un proceso haya copiado en memoria o escrito en disco. Son controles independientes y necesitan evidencias independientes.

La diferencia se difumina porque el fallo aparece como un solo evento: el tiempo límite del agente vence y una llamada posterior tiene éxito. La capa de limpieza pregunta: «¿Qué procesos locales deben detenerse?». La gestión de credenciales pregunta: «¿Qué proceso todavía puede autorizar esta acción externa?». La auditoría pregunta: «¿Podemos demostrar qué acción ocurrió después de que terminara la aprobación?». Un grupo de procesos responde únicamente a la primera pregunta.

Las variables de entorno son el ejemplo clásico de una mala entrega. Si un ejecutor exporta `API_TOKEN`, todos los descendientes lo reciben, salvo que un proceso posterior limpie el entorno. Un hijo puede copiar el token, pasarlo a otro programa o mantener abierta una conexión autenticada. Sustituir una variable de entorno después de iniciar el padre no retira los bytes que ya se heredaron.

Los archivos y sockets son igual de importantes. Un proceso puede heredar un descriptor de archivo abierto que apunta a un archivo de credenciales, un socket HTTPS conectado con estado de sesión o un socket del agente SSH. Un proceso en segundo plano puede seguir usando el descriptor después de que termine su padre. close-on-exec ayuda a evitar la herencia accidental durante un nuevo `exec`, pero no sirve si el hijo ya tiene el descriptor o si el programa lo entrega deliberadamente a otro proceso.

SSH requiere especial cautela. Una clave privada cargada en un asistente local, un socket de agente SSH, una conexión de control multiplexada y un comando remoto tienen cuatro ciclos de vida distintos. Matar al padre local puede dejar en ejecución una conexión existente o un comando remoto. Si el agente puede invocar SSH arbitrario, el ejecutor debe saber si autorizó la invocación de un asistente local, un canal autenticado o un trabajo remoto. Llamar a los tres «el comando SSH» oculta el límite que necesitas controlar.

El patrón más seguro entrega una solicitud de acción, no el secreto sin procesar. El proceso pide a un componente de autoridad que realice una solicitud HTTP o una operación SSH concreta. Ese componente guarda el secreto, decide si la ejecución actual puede usarlo, realiza la acción y devuelve el resultado. Un hijo que escape todavía puede repetir solicitudes mientras su ejecución siga autorizada, así que el componente de autoridad también debe entender la expiración o la revocación.

Sallyport aplica esta separación en sus canales HTTP y SSH compatibles: el agente solicita la acción a través de su adaptador MCP, mientras la aplicación guarda el material de API o SSH y ejecuta la acción, en lugar de pasar el secreto al proceso del agente. Esto no sustituye la limpieza de procesos, pero elimina la forma más sencilla de heredar credenciales.

## Ejecutar en segundo plano es una vía de escape, no un detalle inocuo del shell

El fallo de limpieza más habitual comienza con una comodidad del shell. Alguien escribe `command \u0026`, inicia una tubería, usa `nohup` o lanza un entorno de ejecución que crea trabajadores. El comando padre parece haber terminado o alcanza su tiempo límite, pero el trabajador sigue adelante.

Una tubería del shell merece especial atención. Un ejecutor puede ejecutar `/bin/sh -c 'generator | uploader'` y registrar el PID del shell. El shell tiene procesos hijos a ambos lados de la tubería. Según cómo cree el grupo el ejecutor, matar solo el shell puede dejar vivo al generador o al cargador. Si el cargador posee una credencial y una conexión de red, ese es precisamente el proceso que necesitabas detener.

`nohup` es peor de lo que muchas personas admiten. Evita que un proceso reciba una señal de desconexión; no hace que el proceso sea confiable ni se anuncia al ejecutor. En automatización, normalmente significa que alguien quiere que el trabajo sobreviva a la salida de una terminal o de su padre. Puede ser válido para un servicio administrado, pero debe trasladarse a un supervisor que sea dueño del trabajo y registre su ciclo de vida. No debería aparecer por accidente dentro de una llamada a una herramienta del agente.

Los hijos separados crean un problema distinto. En Node.js, `spawn` con `detached: true` da deliberadamente al hijo un grupo de procesos y una sesión nuevos en sistemas similares a Unix. Ese comportamiento puede ser útil para una aplicación de escritorio que deba sobrevivir a su lanzador. Es un mal valor predeterminado dentro de un ejecutor de agentes porque anula la eliminación normal del grupo por parte del ejecutor.

```js
import { spawn } from "node:child_process";

const child = spawn("/bin/sh", ["-c", "sleep 600"], {
  detached: true,
  stdio: "ignore"
});
child.unref();
```

Ese fragmento sirve como caso de prueba de limpieza, no como patrón de ejecución para copiar en un ejecutor de agentes. Crea un hijo que deja de compartir el grupo de procesos normal del padre. Si un marco de herramientas puede ejecutar código así, la limpieza basada únicamente en grupos de procesos no puede ofrecer una garantía completa. Restringe la ejecución separada, intercepta el comportamiento en el límite de la herramienta o ejecuta la herramienta dentro de una contención más sólida del sistema operativo.

La recomendación popular de «matar simplemente el árbol de procesos» también falla cuando significa recorrer los PPID de forma recursiva. Los PPID describen un momento, no un límite de pertenencia duradero. La reasignación los cambia. Un hijo puede crear otro proceso entre tu recorrido y tu señal. Un descendiente puede salir del árbol. Registra una identidad de grupo o trabajo al iniciar y convierte cualquier salida de esa identidad en una operación deliberada y auditable.

## No limpies por nombre de comando

`pkill` y las coincidencias amplias por nombre resultan atractivas porque son breves. También encajan mal con las ejecuciones de agentes simultáneas. `pkill ssh` puede terminar una conexión interactiva que no tenía relación. `pkill python` puede eliminar una herramienta local de un desarrollador. La coincidencia con una cadena de argumentos puede no detectar un proceso que haya cambiado sus argumentos o coincidir por error con otro proceso de una ejecución distinta.

Usa el nombre del comando solo como pista de investigación. Para hacer cumplir la limpieza, usa un límite de propiedad.

Un ejecutor que conoce un grupo de procesos puede inspeccionar primero a sus miembros. En macOS, filtra la tabla de procesos usando el número registrado, no un nombre de ejecutable supuesto:

```sh
ps -axo pid,ppid,pgid,sid,etime,command | awk '$3 == 4102 || NR == 1'
```

La primera columna es el PID, la segunda es el PPID y la tercera es el PGID para esta distribución del comando. En código de producción, evita analizar texto orientado a personas cuando tu lenguaje pueda llamar a las API nativas de procesos. En una terminal de operaciones, esta salida permite hacer una comprobación rápida y legible antes de elegir una señal.

Después envía la señal al grupo y vuelve a inspeccionarlo:

```sh
kill -TERM -4102
sleep 2
ps -axo pid,ppid,pgid,sid,etime,command | awk '$3 == 4102 || NR == 1'
```

Si quedan miembros, averigua por qué antes de enviar `KILL` de forma automática. Un proceso bloqueado en trabajo de kernel no interrumpible requiere una investigación distinta de la de un proceso que ignoró `TERM`. Un proceso con otro PGID no sobrevivió a tu señal por accidente: cruzó el límite. Eso demuestra una decisión de diseño, un comportamiento del marco o una herramienta maliciosa.

Después del periodo de gracia definido, `KILL` es apropiado para los procesos locales que permanezcan en el grupo registrado y no deban terminar su trabajo. No prometas una limpieza ordenada cuando el agente ya ha superado su ventana de autoridad. La gracia sirve para cerrar archivos y registrar el estado, no para continuar indefinidamente con el trabajo externo.

Los contenedores y los gestores de servicios pueden ofrecer una propiedad mejor que un grupo de procesos sin más cuando controlas el entorno de ejecución. Los flujos de trabajo de agentes de escritorio en macOS no obtienen automáticamente cgroups de Linux, así que no importes recomendaciones sobre cgroups como si se aplicaran sin cambios. En un Mac, un grupo de procesos hijo dedicado, permisos de herramientas limitados y un contrato explícito para los trabajos remotos suelen ser la base. Si necesitas límites de recursos o una contención completa de todos los descendientes, usa un entorno de ejecución que ofrezca realmente esos controles.

## La auditoría debe sobrevivir al padre

No puedes reconstruir una acción que escapó a partir del texto final del agente. El padre puede haber muerto antes de vaciar los registros, y un hijo que siguió ejecutándose no tiene motivos para informar. Construye la auditoría alrededor de eventos inmutables observados fuera del proceso del agente.

Para cada ejecución, registra al menos estos campos antes de permitir trabajo externo:

```json
{
  "run_id": "run-7f3c",
  "started_at": "2026-07-24T14:00:02Z",
  "deadline_at": "2026-07-24T14:00:32Z",
  "parent_pid": 4102,
  "process_group": 4102,
  "approval_identity": "signed agent process identity",
  "state": "active"
}
```

El registro de la acción debe incluir el ID de ejecución, una secuencia de acciones, la hora en que la pasarela la aceptó, el canal, la identidad del objetivo y el resultado. No registres por defecto valores bearer sin procesar, claves privadas, encabezados de autorización ni cuerpos completos de las solicitudes. Un registro que expone la credencial mientras documenta su uso crea un segundo incidente.

Cuando venza el tiempo límite, añade una transición de estado antes de enviar cualquier señal:

```json
{
  "run_id": "run-7f3c",
  "event": "deadline_expired",
  "observed_at": "2026-07-24T14:00:32Z",
  "process_group": 4102,
  "signal": "TERM"
}
```

Ahora tu investigación tiene una pregunta concreta con una respuesta concreta: ¿comenzó una acción externa después de `deadline_expired`? Si la pasarela acepta una acción después de ese evento, o no aplicó el estado de la ejecución o el emisor no llevaba la identidad que la pasarela esperaba. Si una acción comenzó antes de la expiración, pero terminó después, dilo claramente. El inicio y la finalización tienen marcas de tiempo distintas, y confundirlas hace que un trabajo normal en curso parezca una fuga.

Conserva la hora del reloj de pared para las personas y un valor monotónico de tiempo transcurrido para ordenar eventos dentro de una máquina. Los relojes de pared pueden cambiar por sincronización o ajustes manuales. Durante un incidente no necesitas una lección sobre medición del tiempo; necesitas datos suficientes para no afirmar que una acción ocurrió después de la muerte de su padre cuando los relojes no coinciden.

Las secciones Sessions y los registros Activity de Sallyport separan la vista de la ejecución de las llamadas individuales, y su registro cifrado encadenado mediante hashes puede verificarse sin conexión con `sp audit verify`. Esto resulta útil cuando desaparece un padre, porque el registro de auditoría no depende de que el agente ofrezca voluntariamente su propio historial.

Un registro de auditoría es una evidencia, no una contención. Te indica que se realizó una llamada y permite revisarla posteriormente. No termina un proceso local huérfano, no recupera los datos que ya recibió ni cancela un comando remoto que carece de una vía de cancelación. Trátalos como responsabilidades distintas y haz que cada una aparezca en el registro de ejecución.

## Reconstruye el fallo en el orden correcto

Cuando encuentres un posible proceso huérfano, conserva los hechos antes de despejar la escena. Un `kill -9` inmediato puede estar justificado para detener un trabajo dañino, pero puede borrar las relaciones entre procesos que necesitas para corregir el ejecutor. Captura primero una instantánea de los procesos si la situación lo permite.

Empieza por el registro de la ejecución expirada. Anota su PID padre, PGID o identidad del trabajo, hora de inicio, plazo y todas las señales enviadas. Después toma la instantánea actual de los procesos. En macOS, compara PID, PPID, PGID, SID, tiempo transcurrido y comando. Un proceso huérfano suele tener un PPID de 1 o de un supervisor, pero no lo conviertas en tu única prueba. Un proceso puede seguir siendo un riesgo activo aunque su PPID todavía apunte a otro lugar.

Después, clasifica lo que escapó:

- Un proceso del PGID original sobrevivió a la señal esperada.
- Un proceso tiene un PGID o SID nuevo y, por tanto, se separó localmente.
- Un proceso local solicitó iniciar un trabajo remoto y el trabajo remoto continuó.
- Una credencial o un canal autenticado siguió siendo utilizable después de que expirara la ejecución.

Cada clase requiere una reparación distinta. La primera apunta a la gestión de señales o al momento de la limpieza. La segunda apunta a un mecanismo de separación no aprobado. La tercera requiere un ID del trabajo remoto y un protocolo de cancelación. La cuarta requiere que la autoridad de acciones vincule las solicitudes con una ejecución vigente, en lugar de confiar en que el proceso padre se comporte correctamente.

Después relaciona los registros de acciones con la línea temporal. No empieces leyendo las transcripciones del agente. Busca solicitudes aceptadas después de la expiración, objetivos distintos de la instrucción original, llamadas repetidas que continúen un lote y llamadas que el padre no podía haber informado porque ya había terminado. La transcripción puede explicar la intención después. No puede demostrar que una solicitud de red ocurrió o no ocurrió.

Por último, decide si la credencial necesita rotación o revocación. Si ningún secreto sin procesar entró en el agente y la pasarela de acciones rechazó las llamadas posteriores a la expiración, el riesgo restante puede limitarse a los datos o cambios ya completados. Si el hijo heredó un token bearer, una clave privada, acceso al agente SSH o un canal administrativo conectado, supone que todavía puede actuar hasta que hayas deshabilitado esa autoridad. Aquí es donde los equipos pierden tiempo debatiendo si el proceso «probablemente» hizo algo. Si no puedes demostrar que perdió el acceso, retira el acceso.

## Una prueba de fallo debe hacer evidente el proceso huérfano

Un sistema de pruebas de tiempos límite necesita una prueba que falle claramente cuando sobrevive un hijo. Probar únicamente que el padre devuelve un tiempo límite demuestra muy poco.

Crea un accesorio de prueba que inicie un padre y un hijo en el mismo grupo registrado. El hijo debe esperar lo suficiente para que el sistema de pruebas pueda hacer que el padre supere su tiempo límite. Antes de bloquearse, el padre debe informar del PID y del grupo del hijo. Después del tiempo límite, comprueba que ninguno de los dos procesos exista y que el registro de acciones de prueba no tenga ninguna llamada aceptada después del registro de expiración.

Este accesorio de shell muestra la forma del problema:

```sh
#!/bin/sh
(
  trap 'exit 0' TERM INT
  sleep 600 \u0026
  child=$!
  printf 'parent=%s child=%s pgid=' "$$" "$child"
  ps -o pgid= -p "$$" | tr -d ' '
  wait "$child"
)
```

Ejecútalo con el mismo código de lanzamiento que usa tu ejecutor de agentes, no desde una terminal interactiva que proporcione silenciosamente un comportamiento distinto para el control de trabajos. Captura los identificadores impresos, fuerza el vencimiento del plazo y comprueba la tabla de procesos. Si el hijo permanece, has aprendido algo concreto: o el lanzamiento no creó el grupo que suponías o la señal del tiempo límite no se dirigió a él.

Repite la prueba con las herramientas que realmente invocan tus agentes. Los envoltorios de shell, las API de procesos hijos de Node, las llamadas a subprocesos de Python, los gestores de paquetes, los compiladores y los asistentes SSH toman decisiones distintas sobre grupos, descriptores y reenvío de señales. No necesitas cientos de pruebas. Necesitas una prueba destructiva para cada patrón de lanzamiento que pueda crear descendientes.

Añade un segundo accesorio de prueba que intente separarse. Puede usar una llamada que cree una sesión o una opción del entorno de ejecución que cree un hijo separado. El resultado esperado debe ser un rechazo, la asignación a un supervisor o un evento de registro explícito. El éxito silencioso es un defecto porque indica al agente que puede mover el trabajo fuera del límite del tiempo de espera.

No te detengas en los PID locales. Si tu agente puede iniciar trabajo remoto, haz que el lado remoto devuelva un identificador de trabajo y prueba la cancelación después del tiempo límite local. El estado deseado no es «el cliente SSH salió». Es «el trabajo remoto identificado por esta ejecución se detuvo o el trabajo restante tiene un responsable documentado».

## La aprobación debe expirar antes que el proceso

La aprobación humana de una ejecución de agente sirve de poco si un hijo puede usarla después de que la ejecución se declare terminada. Vincula la autoridad para acciones externas a una identidad y un estado de ejecución que el componente de autoridad compruebe en cada solicitud. Cuando el ejecutor marque la ejecución como expirada, las acciones nuevas de esa ejecución deben fallar aunque un proceso huérfano local todavía tenga tiempo de CPU.

Este diseño no significa pedir a una persona que apruebe cada comando de shell. Significa que el componente capaz de gastar autoridad sabe cuándo terminó una ejecución. Ese componente puede entregar a la ejecución un identificador de corta duración, rechazarlo después de la revocación y registrar el rechazo. La eliminación del grupo de procesos limita entonces el daño local, mientras que el límite de acciones limita las consecuencias de una carrera durante la limpieza.

Mantén la aprobación por acción para las operaciones que la necesiten, pero no confundas aprobación con supervisión. Una persona puede aprobar un proceso de agente firmado al principio de una sesión. Si un descendiente continúa después de que el agente termine, ha superado la duración de ejecución aprobada aunque haya heredado el mismo contexto local. Tu sistema debe poder hacer cumplir esa condición, en lugar de confiar en que el hijo se comporte correctamente.

El ejecutor también debe distinguir entre cancelar una ejecución y cancelar un efecto externo. Una solicitud cancelada puede haber llegado ya a la API. Un cliente SSH terminado puede haber iniciado ya un comando remoto. Registra la solicitud de cancelación, la salida local observada y cualquier confirmación devuelta por el sistema externo como eventos separados. Esa forma de expresarlo resulta menos tranquilizadora que «cancelado», pero indica a los operadores lo que realmente saben.

La primera reparación que haría en un ejecutor de agentes es pequeña y poco llamativa: crear y registrar un grupo de procesos dedicado al iniciar, terminar ese grupo en la ruta de tiempo límite y comprobar el resultado con una prueba de fallo. Combínalo con un límite de acciones que rechace una ejecución después de su expiración. Cuando esos dos hechos sean ciertos, un proceso huérfano será un incidente que puedes contener y explicar, no un temor impreciso de que algo siga ejecutándose.
