Você só abriu aquela skill da comunidade — e ela rodou um comando de shell

Você instala uma skill de um hub da comunidade, dá uma olhada na linha de sucesso e segue em frente. Semanas depois você a abre numa sessão só para ler o que ela de fato faz — e esse olhar era a parte perigosa: abrir a skill bastava para executar o snippet inline-shell dela. Você não rodou nenhum comando. O comando rodou de qualquer jeito.
O problema nunca foi “o scanner deixou passar algo”. Foi que a pergunta “o que conta como uma skill da comunidade” era respondida de forma otimista demais. Quatro fronteiras de confiança foram apertadas de uma vez, e o mesmo lote limpou uma sala cheia de falsos positivos mais duas instalações que nunca conseguiam dar certo.
O que é inline shell, e quando ele de fato roda
O corpo de uma skill do Hermes pode conter um snippet inline-shell: um marcador !`cmd` dentro do SKILL.md. Quando você abre ou carrega essa skill, o comando é expandido via bash -c e a saída dele é injetada no corpo da skill, o que permite a uma skill carregar dados ao vivo — o branch atual, um timestamp, uma contagem.
Ele vem desligado por padrão:
hermes config get skills.inline_shell # False por padrão
Você o habilita no config.yaml:
skills:
inline_shell: true
inline_shell_timeout: 10 # segundos por snippet
O detalhe é que inline_shell: true é um interruptor que arma toda skill instalada de uma vez. O que decide se um snippet de fato roda, portanto, não é o veredito do scanner, mas a resposta do runtime à pergunta “isto é uma skill da comunidade?”. Essa resposta é o que mudou.
Os três buracos na resposta antiga
A antiga checagem _is_community_hub_skill repousava em três suposições, e as três eram otimistas:
- Ela só casava com um diretório exatamente igual ao caminho de instalação registrado. Uma skill aninhada dentro de um pacote da comunidade — digamos
agents/inner/SKILL.md— não contava como comunidade de forma alguma. - Ela lia só o lock de hub do profile ativo. Quando outro profile alcançava a mesma skill por
skills.external_dirs, a proveniência morava no lock de outra pessoa e a checagem vinha vazia. - Um lock que ela não conseguia ler era tratado como “não é comunidade”. Este era o pior: um
.hub/lock.jsontruncado ou malformado tornava toda skill daquela árvore novamente confiável — um padrão fail-open.
Aqui está o antes e o depois:
| Caso | Antes | Agora |
|---|---|---|
| Uma skill aninhada dentro de um pacote da comunidade | Expandida | Fica crua |
Outro profile alcançando-a via external_dirs |
Expandida | Fica crua |
O .hub/lock.json da árvore truncado ou com formato errado |
Tratado como não-comunidade, expandido | Toda a árvore falha de forma fechada |
| Nenhum arquivo de lock (sem installs de hub nesta máquina) | Não expandia | Inalterado |
A checagem agora tem duas regras duras: ela casa com o diretório de instalação ou qualquer diretório abaixo dele (em caminhos resolvidos), e lê o lock do profile ativo e o lock que possui a árvore de skills onde o diretório vive — o ancestral mais próximo chamado skills/ que carrega um .hub/lock.json. Em outras palavras, quem está olhando para a skill não muda mais o que ela é considerada ser.
O trade-off está documentado de propósito: enquanto o lock de uma árvore está corrompido, skills que você mesmo escreveu naquela mesma árvore também param de expandir. Um lock quebrado não consegue nos dizer quais diretórios são instalações da comunidade, então a árvore inteira falha de forma fechada até o lock ser consertado.
O outro lado do scanner: skills-guard-v9 corrige falsos positivos
Um texto anterior cobriu os pontos cegos do scanner. Este lote corrigiu a falha oposta — bloquear demais.
Primeiro os dados. Ao longo de 14 dias de clientes novos, scan_blocked foi a razão número um de falhas de instalação de skills de hub (hermes-index 39 eventos em 8 dias-instalação, clawhub 19/15, skills-sh 5/4, url 6/4). Todas essas são falhas reais — a instalação foi recusada. A causa não era conteúdo perigoso; era correspondência de padrões imprecisa. Numa amostra aleatória de 89 entradas do índice, cerca de 10 de 13 bloqueios eram falsos positivos de padrão.
Uma causa raiz é clássica: a tabela de regras inteira era compilada com re.IGNORECASE, então a regra de chave AWS AKIA[0-9A-Z]{16} casava com o placeholder AKIAxxxxxxxxxxxxxxxx. Da mesma forma, rm -rf /, base64 -d |, setuid e injeção por comentário HTML disparavam em limpeza de cache de Dockerfile, em | jq, em prosa comum e em comentários de layout.
Depois da correção, numa amostra aleatória de 421 skills reais do índice, as instalações bloqueadas caem de 83 para 68 — 33 desbloqueadas, 0 recém-bloqueadas — e todo ataque elaborado continua sendo bloqueado.
Concretamente, o scanner agora:
- trata formatos de chave como sensíveis a maiúsculas e minúsculas —
AKIA,ghp_/github_pat_,sk-,sk-ant-,glpat-,BEGIN PRIVATE KEYnão casam mais com placeholders todo em minúsculas; - isenta a limpeza de cache de pacotes da regra
rm -rf /(/var/lib/apt/lists,/var/cache/apt|apk|yum|dnf) enquanto checa cada operando — o que também pegarm -rf --no-preserve-root /, que passava batido antes; - estreita os pipes de
base64 -d: só dispara quando os bytes decodificados chegam a um interpretador em qualquer estágio posterior (sh/bash/python/perl/node/eval/xargs/env/sudo/iex/pwsh), ou são alimentados direto num| tar(um payload embutido que o scanner nunca vê). Consumidores de dados como| jqe| grepnão disparam mais; - exige fraseado de injeção para comentários HTML — “ignore previous instructions”, system prompt, instruções escondidas — em vez de disparar em qualquer comentário;
- só trata setuid como crítico para chamadas reais (
set*id(,cap_set[ug]id,chmod u+s,g=s, octal 2xxx–7xxx); a palavra solta vira uma nota. Isso também pegachmod 4755, que a regra antiga deixava passar; - dispara em
curl … | sudo -E bashcomo um curl-pipe-shell, onde a regra antiga só reconhecia| bash; - sobe a versão do scanner para
skills-guard-v9(eplugin-guard-v10para plugins), o que invalida vereditos em cache para que tudo seja reescaneado.
Um detalhe vale ser repetido: a mudança introduziu uma regressão própria, consertada no dia seguinte. Assim que a regra começou a checar cada operando de rm, um comentário // no final era lido como um caminho enraizado em /, então rm -rf node_modules dist // clean saía como crítico. A correção impede que um // solto depois do primeiro operando conte como raiz, enquanto rm -rf // e rm -rf //etc continuam críticos. Um novo scan de uma amostra em cache de 633 skills produziu veredito, decisão de instalação e conjunto de achados idênticos.
As 1.900 linhas de índice que nunca podiam ser instaladas
Se você alguma vez escolheu uma skill do índice e a viu falhar, a falha provavelmente parecia stale_index — e o cliente já tinha gastado cerca de 40 chamadas à API do GitHub até chegar lá.
A causa é mundana: o skills.sh guarda cerca de 20.000 linhas, e aproximadamente 1.920 a 1.985 delas vêm sem nenhum resolved_github_id — nenhum local para download. O construtor de índice tratava três situações genuinamente diferentes como uma só: um repositório 404, uma árvore que lia bem mas não continha nenhuma skill correspondente e uma árvore truncada. As três eram publicadas como estavam. A correspondência difusa dele também removia - mas não _, e nunca tratava o layout de skill única em que o único SKILL.md de um repo fica na raiz — mesmo que o cliente instale esses.
O construtor agora distingue ok / truncated / gone (repo 404) / error; normaliza os dois lados com [-_] (então pdb-database casa com pdb_database); e resolve repos cujo único SKILL.md fica na raiz ou num diretório genérico skills/, .agents/skills/ ou .claude/skills/. Linhas de um repo que desapareceu, e linhas sem correspondência numa árvore completa, são descartadas. Para árvores truncadas (posthog/posthog tem 58k entradas) ele sonda caminhos candidatos, limitado a 10 linhas por repo. Um error — 5xx, timeout, auth — mantém as suas linhas não resolvidas, então uma falha transitória nunca apaga nada. O log de build agora imprime uma linha que vale um grep: Resolved N/M paths, dropped D uninstallable (G repo gone, R not in repo).
Uma amostra de 75 linhas torna isso concreto: o construtor antigo entregava 75 (27 delas não resolvidas); o novo entrega 57, com 0 não resolvidas e 18 descartadas (3 repos desaparecidos, 15 em que a skill não está no repo).
Duas instalações que nunca conseguiam dar certo, agora consertadas
Restaurações de built-in falhavam para sempre. Se você apagou uma skill mas sobraram arquivos no diretório dela, ou uma cópia anterior morreu no meio, você ficava com um diretório sem nenhum SKILL.md — e toda tentativa retornava [Errno 17] File exists. Um segundo layout também quebrava isso: quando o diretório de categoria é um symlink, o loader segue o symlink, então a skill funciona, mas o rglob da restauração não desce por ele, e a guarda de contenção reportava Unsafe install path. Agora um SKILL.md canônico conta como presente e a cópia usa dirs_exist_ok, preenchendo em volta do que sobrou em vez de falhar. Uma restauração falha também é registrada como source=bundled em vez de cair para source=hub.
Orçamento da API anônima. Instalar uma skill de hub gastava uma chamada da API Contents por arquivo na pasta da skill. Com 60 chamadas anônimas por hora, uma skill com cerca de 58 arquivos ou mais nunca conseguia instalar (hermes-index rate_limited: 26 eventos em 9 dias-instalação em 14 dias). Os arquivos agora são lidos primeiro de raw.githubusercontent.com/<repo>/<sha>/<path> — os mesmos bytes imutáveis, e eles não contam para o limite da API — caindo para a API Contents só quando a leitura raw falha (repo privado, indisponibilidade do raw).
O que fazer agora
- Cheque o interruptor do inline-shell:
hermes config get skills.inline_shell. Deixe desligado a menos que você precise — é a linha defensiva mais barata que você tem. - Lembre que os vereditos em cache foram invalidados: a versão do scanner mudou, então skills são reescaneadas no próximo acesso. Vale a pena tentar de novo qualquer coisa que foi bloqueada por engano.
- Em implantações compartilhadas, garanta que o
.hub/lock.jsonde cada profile faça o parse. Um único lock corrompido agora desliga o inline shell para toda a árvore dele até ser consertado. - Não recorra ao bypass por reflexo.
hermes skills install --forceignora um veredito bloqueado, e ele existe para o caso em que você mesmo revisou o conteúdo — não para “o scanner está sendo chato de novo”.
A versão curta
O fio que atravessa este lote é que a confiança passou de um palpite estático para uma decisão de proveniência: de onde uma skill veio, qual lock registra a sua instalação e quem está olhando para ela importam mais do que se o diretório por acaso parece uma instalação da comunidade. Quando a resposta é incerta, tudo falha de forma fechada — incluindo a consequência levemente incômoda de que um lock corrompido desliga temporariamente o inline shell de toda uma árvore de skills até você consertá-lo.
Para o contexto, comece pelo nosso texto sobre os três pontos cegos do scanner, depois pela referência do comando hermes skills. Se você instala skills de terceiros, mais dois valem o seu tempo: isolamento de plugins e fronteiras de host e o portão de confiança para plugins portáveis e MCP. E se você prefere escolher de fontes que já foram revisadas, leia como o catálogo de plugins lida com isso.