Un script de 16 KB congeló un gateway durante 23 segundos: la autopsia del escaneo de comandos


Le pasas a Hermes un script de unos cientos de líneas —un heredoc de Python que incrusta texto de documentos en payloads JSON— y le pides que lo ejecute. El terminal se queda callado. Un rato después pides progreso por Telegram y el bot no responde. En el log encuentras un conjunto de líneas que no explican nada: deadline (15s) expired but the event loop has not processed the expiry, the loop thread appears BLOCKED in a synchronous call, y luego el watchdog de apagado, una salida, un reinicio… y entonces el agente reintenta el mismo comando y todo empieza de nuevo. Parece un problema de red o un fallo de la plataforma de mensajería. En realidad va de algo que suena a pura seguridad: la detección de comandos peligrosos.

El síntoma: no es «lentitud», es un event loop retenido

Antes de ejecutar un comando, Hermes tiene que decidir si ese comando es peligroso, lo que determina si pasa sin más, te pregunta o queda bloqueado directamente. Esa comprobación corre en el motor de regex de Python y retiene el GIL. Cuanto más largo es el comando, más tiempo ocupa el proceso; y lo que ocupa no es la ranura de un comando, sino el event loop entero.

Un usuario lo reportó con material de reproducción completo (#113535, 16 de septiembre de 2026, imagen oficial 0.20.6), y merece la pena leer la cadena de fallo una vez:

  1. La detección tarda minutos con un comando largo y deja sin aire al event loop.
  2. El sondeo de Telegram expira; el deadline de 15 segundos de telegram-init vence, pero el bucle no puede procesar la expiración.
  3. El watchdog de apagado concluye que el proceso está atascado, sale con código 75 y el gateway se reinicia.
  4. Al reiniciar, el agente se encuentra el mismo comando sin terminar y ejecuta la misma detección otra vez, y el ciclo se repite.

Ese último paso merece énfasis: «se atasca, reinicia, reintenta, se atasca otra vez» se parece exactamente a una plataforma de mensajería rota, y eso lleva a la gente a buscar en el sitio totalmente equivocado.

Causa raíz: un regex que arranca en O(n²), multiplicado por una explosión de variantes

El análisis de quien lo reportó es directo. Se combinan dos factores:

Primero, un lookahead de cadena completa sin anclar. La regla del ciclo de vida del gateway vía launchctl estaba escrita como dos lookaheads de cadena completa independientes:

(r'(?=[\s\S]*\blaunchctl\s+(?:stop|kickstart|bootout|unload|kill|disable|remove)\b)'
 r'(?=[\s\S]*\b(?:hermes|ai\.hermes)\b)', "stop/restart hermes launchd service ...")

La intención es «que aparezcan tanto un verbo de parada con launchctl como la palabra hermes/ai.hermes, en cualquier orden». Pero ninguno de los lookaheads está anclado, así que re.search los reintenta en cada offset de inicio, y cada intento reescanea hasta el final de la cadena: una búsqueda es O(n²). Medido sobre una cadena de 60.018 caracteres: 16,5 segundos para ese único patrón; con un ancla, 0,001 segundos.

Segundo, las «variantes de palabras de comando» explotan con el número de comillas. _command_detection_variants genera una variante de longitud completa por cada palabra de comando entre comillas o escapada, para poder casar la forma desofuscada. Un heredoc cargado de comillas (JSON estilo "key": "value") tiene cientos de palabras entrecomilladas: 230 líneas producen 233 variantes. La detección escanea cada variante con cada patrón, y con las dos pasadas (hardline y dangerous) el total es O(n³).

Las mediciones reportadas:

Longitud del comando Variantes Tiempo total de regex
3.000 caracteres 41 2,7 s
6.000 caracteres 82 27,8 s
15.369 caracteres 201 unos 7 minutos (la herramienta expiró a los 420 s)

Así que esto nunca fue «un poco lento»: se cuadruplica o peor con cada duplicación de la longitud.

El arreglo: un ancla y una variante combinada, con veredictos idénticos

El PR #114208, fusionado el 17 de septiembre de 2026, desmonta la explosión con dos cambios:

  • Anclar los lookaheads de launchctl con \A. Esto no cambia la semántica: un lookahead evaluado en el offset 0 ya ve la cadena completa, así que el conjunto de coincidencias es idéntico; el orden de tokens invertido sigue casando y la entrada multilínea también. Una búsqueda simplemente pasa de O(n²) a O(n).
  • Construir una sola variante combinada en lugar de una por palabra. Cada palabra de comando se desofusca y se aplica como un único candidato; los tramos llegan desordenados (los cuerpos de bucle después de sus palabras clave) y pueden anidarse (una palabra entre backticks y el comando que la contiene), así que las sustituciones se aplican ordenadas, y un tramo que solapa con otro ya aplicado conserva su variante por palabra; los tramos solapados esperan a la siguiente ronda (una variante combinada por nivel de anidamiento), de modo que los scripts con sustitución anidada tampoco pueden volver a explotar. Con la entrada de quien reportó, el número de variantes pasó de 923 a 4.

El resultado medido: un heredoc de 16 KB y 460 líneas baja de unos 23 segundos (7,8 s de hardline + 15 s de dangerous) a unos 0,4 segundos, con veredictos idénticos. La pasada hardline conserva su semántica YOLO sin límites: un heredoc benigno no queda bloqueado ni genera pregunta, ni antes ni después.

Esa parte de «veredictos idénticos» es la afirmación más importante de todo el arreglo. Una puerta de aprobación es una frontera de seguridad, y no se vuelve rápida aflojándola: dejar pasar un comando que debería haberse bloqueado es muchísimo peor que atascarse 23 segundos. La reparación correcta elimina trabajo desperdiciado, nunca la comprobación en sí.

Por qué la regla es tan amplia de entrada

Lee el comentario del código fuente y el conservadurismo tiene sentido: una versión anterior exigía que «hermes» apareciera después del verbo, y un bucle for que armaba la etiqueta a partir de una lista definida antes (for item in 'ai.hermes...'; do launchctl bootout "$label") se coló y reinició cuatro gateways sin ninguna aprobación. Para una puerta de aprobación, pecar de amplio es lo correcto: una pregunta de más sale barata; un fallo puede matar a todos los agentes en marcha.

Esa es exactamente la razón por la que este arreglo solo toca el rendimiento y no la amplitud. Es lo que permite confiar en él.

Cuándo te toparás con esto

  • Scripts largos. Los heredocs de unos cientos de líneas y los comandos que incrustan JSON grande o texto de documentos pasan por esta ruta. Los comandos cortos de cada día, no.
  • Agentes en un gateway. Como la detección retiene el GIL, la víctima no es solo el comando actual, sino todos los canales de mensajería, heartbeats y watchdogs del mismo proceso. En una CLI esperas; en un gateway el bot se queda a oscuras y se reinicia en bucle.
  • Palabras clave del log que buscar. loop thread appears BLOCKED in a synchronous call, deadline expirado, código de salida 75 del watchdog. Cuando aparecen juntas, sospecha de bloqueo síncrono, no de la red.

Estado de la release

El arreglo se fusionó en main el 17 de septiembre de 2026. Quien lo reportó confirmó que el patrón problemático sigue existiendo sin cambios en la v2026.9.14, que es la release actual, la v0.21.3, así que el build que ejecutas hoy todavía se atasca; el arreglo no está en ninguna etiqueta.

hermes --version   # v0.21.3 o anterior: los heredocs largos siguen topándose con esto
hermes update      # corregido cuando llegue la próxima etiqueta

Hasta entonces, si necesitas que el agente ejecute un script muy largo, escríbelo primero en un archivo y haz que el agente ejecute ese archivo en lugar de pegar el cuerpo entero en la línea de comandos: esa ruta se salta por completo el escaneo de cadena completa.

Para seguir leyendo: sobre la puerta en su conjunto, aprobaciones cuando nadie está mirando cubre modos y políticas; por qué editar la config de SSH requiere aprobación es el recorrido de una regla real desde el incidente hasta el código; probar las aprobaciones en dry-run te deja comprobar si tus reglas se disparan por error; y si tienes curiosidad por cómo se recupera un gateway atascado, el watchdog del bucle del gateway es el lado de operaciones de la misma historia.

Resumen

  • Síntoma: un heredoc largo inmoviliza el event loop del gateway dentro de regex síncronos, y eso se manifiesta como un bot de Telegram mudo, deadlines de telegram-init expirados, salida 75 del watchdog y un bucle de reintentos tras el reinicio.
  • Causa raíz: dos lookaheads de cadena completa sin anclar hacían que una búsqueda fuera O(n²) (16,5 s con una cadena de 60 K caracteres), mientras que _command_detection_variants producía una variante de longitud completa por cada palabra entrecomillada, llevando el total a O(n³) (82 variantes y 27,8 s con 6.000 caracteres).
  • Arreglo (PR #114208, 17 de septiembre de 2026): un ancla \A más una única variante combinada, de 923 variantes a 4, un heredoc de 16 KB de 23 s a 0,4 s, con veredictos idénticos.
  • Estado: el build publicado (v0.21.3) sigue afectado; el arreglo está en main y no en una etiqueta.