Vous avez seulement ouvert ce skill communautaire — et il a lancé une commande shell

Vous installez un skill depuis un hub communautaire, vous jetez un œil à la ligne de succès, et vous passez à autre chose. Des semaines plus tard, vous l’ouvrez dans une session juste pour lire ce qu’il fait réellement — et ce coup d’œil était la partie dangereuse : ouvrir le skill suffisait auparavant à exécuter son snippet inline-shell. Vous n’avez lancé aucune commande. La commande s’est exécutée quand même.
Le problème n’a jamais été « le scanner a raté quelque chose ». C’est que la question « qu’est-ce qui compte comme un skill communautaire » recevait une réponse trop optimiste. Quatre frontières de confiance ont été resserrées d’un coup, et le même lot a nettoyé une salle entière de faux positifs ainsi que deux installations qui ne pouvaient jamais réussir.
Ce qu’est le inline shell, et quand il s’exécute vraiment
Le corps d’un skill Hermes peut contenir un snippet inline-shell : un marqueur !`cmd` à l’intérieur de SKILL.md. Quand vous ouvrez ou chargez ce skill, la commande est expansée via bash -c et sa sortie est injectée dans le corps du skill, ce qui permet à un skill de transporter des données vivantes — la branche courante, un horodatage, un comptage.
Il est désactivé par défaut :
hermes config get skills.inline_shell # False par défaut
Vous l’activez dans config.yaml :
skills:
inline_shell: true
inline_shell_timeout: 10 # secondes par snippet
Le piège, c’est que inline_shell: true est un interrupteur qui arme tous les skills installés d’un coup. Ce qui décide si un snippet s’exécute réellement n’est donc pas le verdict du scanner, mais la réponse du runtime à « est-ce un skill communautaire ? ». C’est cette réponse qui a changé.
Les trois trous de l’ancienne réponse
L’ancienne vérification _is_community_hub_skill reposait sur trois hypothèses, et les trois étaient optimistes :
- Elle ne correspondait qu’à un répertoire exactement égal au chemin d’installation enregistré. Un skill imbriqué dans un bundle communautaire — disons
agents/inner/SKILL.md— ne comptait pas du tout comme communautaire. - Elle ne lisait que le lock de hub du profile actif. Quand un autre profile atteignait le même skill via
skills.external_dirs, la provenance vivait dans le lock de quelqu’un d’autre et la vérification revenait vide. - Un lock qu’elle ne pouvait pas lire était traité comme « pas communautaire ». C’était le pire : un
.hub/lock.jsontronqué ou malformé retransformait tous les skills de cet arbre en skills de confiance — un défaut fail-open.
Voici l’avant et l’après :
| Cas | Avant | Maintenant |
|---|---|---|
| Un skill imbriqué dans un bundle communautaire | Expansé | Reste brut |
Un autre profile y accédant via external_dirs |
Expansé | Reste brut |
Le .hub/lock.json de l’arbre tronqué ou de forme incorrecte |
Traité comme non communautaire, expansé | Tout l’arbre échoue en mode fermé |
| Aucun fichier de lock du tout (aucune installation de hub sur cette machine) | N’était pas expansé | Inchangé |
La vérification a désormais deux règles strictes : elle correspond au répertoire d’installation ou à n’importe quel répertoire en dessous (sur les chemins résolus), et elle lit le lock du profile actif et le lock propriétaire de l’arbre de skills dans lequel vit le répertoire — l’ancêtre le plus proche nommé skills/ qui porte un .hub/lock.json. Autrement dit, qui regarde le skill ne change plus ce qu’il est considéré être.
Le compromis est documenté à dessein : tant que le lock d’un arbre est corrompu, les skills que vous avez écrits vous-même dans ce même arbre cessent eux aussi d’être expansés. Un lock cassé ne peut pas nous dire quels répertoires sont des installations communautaires, donc tout l’arbre échoue en mode fermé jusqu’à réparation du lock.
L’autre face du scanner : skills-guard-v9 corrige les faux positifs
Un article précédent couvrait les angles morts du scanner. Ce lot corrige la défaillance inverse — bloquer trop.
Les données d’abord. Sur 14 jours de nouveaux clients, scan_blocked était la première raison d’échec des installations de skills de hub (hermes-index 39 événements sur 8 jours-installations, clawhub 19/15, skills-sh 5/4, url 6/4). Tous ces échecs sont réels — l’installation était refusée. La cause n’était pas un contenu dangereux ; c’était un pattern matching imprécis. Sur un échantillon aléatoire de 89 entrées d’index, environ 10 des 13 blocages étaient des faux positifs de motif.
Une cause racine est classique : toute la table de règles compilait avec re.IGNORECASE, donc la règle sur les clés AWS AKIA[0-9A-Z]{16} correspondait au placeholder AKIAxxxxxxxxxxxxxxxx. De même, rm -rf /, base64 -d |, setuid et l’injection par commentaire HTML se déclenchaient sur le nettoyage de cache d’un Dockerfile, sur | jq, sur de la prose ordinaire et sur des commentaires de mise en page.
Après le correctif, sur un échantillon aléatoire de 421 skills d’index réels, les installations bloquées passent de 83 à 68 — 33 débloquées, 0 nouvellement bloquée — et chaque attaque fabriquée reste bloquée.
Concrètement, le scanner désormais :
- traite les formes de clés comme sensibles à la casse —
AKIA,ghp_/github_pat_,sk-,sk-ant-,glpat-,BEGIN PRIVATE KEYne correspondent plus aux placeholders tout en minuscules ; - exempte le nettoyage de cache de paquets de la règle
rm -rf /(/var/lib/apt/lists,/var/cache/apt|apk|yum|dnf) tout en vérifiant chaque opérande — ce qui détecte aussirm -rf --no-preserve-root /, manqué auparavant ; - restreint les pipes
base64 -d: ne se déclenche que lorsque les octets décodés atteignent un interpréteur à n’importe quelle étape ultérieure (sh/bash/python/perl/node/eval/xargs/env/sudo/iex/pwsh), ou sont envoyés directement dans| tar(une charge utile embarquée que le scanner ne voit jamais). Les consommateurs de données comme| jqet| grepne se déclenchent plus ; - exige une formulation d’injection pour les commentaires HTML — « ignore previous instructions », system prompt, instructions cachées — au lieu de se déclencher sur n’importe quel commentaire ;
- ne considère setuid comme critique que pour de vrais appels (
set*id(,cap_set[ug]id,chmod u+s,g=s, octal 2xxx–7xxx) ; le mot seul devient une note. Cela détecte aussichmod 4755, que l’ancienne règle manquait ; - se déclenche sur
curl … | sudo -E bashen tant que curl-pipe-shell, là où l’ancienne règle ne reconnaissait que| bash; - incrémente la version du scanner vers
skills-guard-v9(etplugin-guard-v10pour les plugins), ce qui invalide les verdicts en cache et fait tout rescanner.
Un détail mérite d’être répété : le changement a introduit sa propre régression, corrigée le lendemain. Dès que la règle a vérifié chaque opérande de rm, un commentaire // en fin de ligne a été lu comme un chemin enraciné à /, si bien que rm -rf node_modules dist // clean ressortait critique. Le correctif évite qu’un // seul après le premier opérande compte comme racine, tandis que rm -rf // et rm -rf //etc restent critiques. Un rescan d’un échantillon en cache de 633 skills a produit un verdict, une décision d’installation et un ensemble de constats identiques.
Les 1 900 lignes d’index qui ne pouvaient jamais s’installer
Si vous avez déjà choisi un skill dans l’index et l’avez vu échouer, l’échec ressemblait probablement à stale_index — et le client avait déjà dépensé environ 40 appels à l’API GitHub pour en arriver là.
La cause est banale : skills.sh contient environ 20 000 lignes, et environ 1 920 à 1 985 d’entre elles sont livrées sans resolved_github_id — aucun emplacement téléchargeable du tout. Le constructeur d’index traitait trois situations réellement différentes comme une seule : un dépôt 404, un arbre qui se lisait bien mais ne contenait aucun skill correspondant, et un arbre tronqué. Les trois étaient publiées telles quelles. Son fuzzy match retirait aussi - mais pas _, et il ne gérait jamais la disposition à skill unique où le seul SKILL.md d’un dépôt se trouve à la racine — alors même que le client installe ceux-là.
Le constructeur distingue désormais ok / truncated / gone (dépôt 404) / error ; normalise les deux côtés avec [-_] (si bien que pdb-database correspond à pdb_database) ; et résout les dépôts dont le seul SKILL.md se trouve à la racine ou dans un répertoire générique skills/, .agents/skills/ ou .claude/skills/. Les lignes d’un dépôt disparu, et les lignes sans correspondance dans un arbre complet, sont supprimées. Pour les arbres tronqués (posthog/posthog compte 58k entrées), il sonde plutôt des chemins candidats, plafonnés à 10 lignes par dépôt. Une error — 5xx, timeout, auth — laisse ses lignes non résolues, si bien qu’une défaillance transitoire ne supprime jamais rien. Le log de build affiche désormais une ligne qui mérite un grep : Resolved N/M paths, dropped D uninstallable (G repo gone, R not in repo).
Un échantillon de 75 lignes rend les choses concrètes : l’ancien constructeur en publiait 75 (dont 27 non résolues) ; le nouveau en publie 57, avec 0 non résolue et 18 supprimées (3 dépôts disparus, 15 où le skill n’est pas dans le dépôt).
Deux installations qui ne pouvaient jamais réussir, désormais corrigées
Les restaurations intégrées échouaient sans fin. Si vous supprimiez un skill alors que des fichiers restaient dans son répertoire, ou qu’une copie antérieure mourait à mi-chemin, il vous restait un répertoire sans SKILL.md — et chaque nouvelle tentative renvoyait [Errno 17] File exists. Une seconde disposition le cassait aussi : quand le répertoire de catégorie est un lien symbolique, le chargeur suit le lien et le skill fonctionne, mais le rglob de la restauration n’y descend pas, si bien que la garde de confinement signalait Unsafe install path. Désormais un SKILL.md canonique est considéré comme présent et la copie utilise dirs_exist_ok, comblant les restes au lieu d’échouer. Une restauration échouée est aussi enregistrée comme source=bundled au lieu de retomber sur source=hub.
Le budget d’API anonyme. Installer un skill de hub consommait un appel Contents API par fichier du dossier du skill. Avec 60 appels anonymes par heure, un skill d’environ 58 fichiers ou plus ne pouvait jamais s’installer (hermes-index rate_limited : 26 événements sur 9 jours-installations en 14 jours). Les fichiers sont désormais lus depuis raw.githubusercontent.com/<repo>/<sha>/<path> d’abord — les mêmes octets immuables, et ils ne comptent pas dans la limite de l’API — avec un repli sur la Contents API uniquement quand la lecture raw échoue (dépôt privé, panne de raw).
Que faire maintenant
- Vérifiez l’interrupteur inline-shell :
hermes config get skills.inline_shell. Laissez-le désactivé sauf si vous en avez besoin — c’est la ligne défensive la moins chère dont vous disposez. - Rappelez-vous que les verdicts en cache ont été invalidés : la version du scanner a changé, donc les skills sont rescannés au prochain accès. Tout ce qui était bloqué à tort mérite une nouvelle tentative.
- Sur les déploiements partagés, assurez-vous que le
.hub/lock.jsonde chaque profile se parse. Un seul lock corrompu désactive désormais le inline shell pour tout son arbre jusqu’à réparation. - N’allez pas chercher le contournement par réflexe.
hermes skills install --forceignore un verdict de blocage, et il existe pour le cas où vous avez vous-même examiné le contenu — pas pour « le scanner est encore pénible ».
En bref
Le fil conducteur de ce lot est que la confiance est passée d’une supposition statique à une décision de provenance : d’où vient un skill, quel lock enregistre son installation, et qui le regarde, tout cela compte davantage que le fait que le répertoire ressemble ou non à une installation communautaire. Quand la réponse est floue, tout échoue en mode fermé — y compris la conséquence un peu agaçante qu’un lock corrompu désactive temporairement le inline shell pour tout un arbre de skills jusqu’à ce que vous le répariez.
Pour le contexte, commencez par notre article sur les trois angles morts du scanner, puis la référence de la commande hermes skills. Si vous installez des skills tiers, deux autres valent votre temps : l’isolation des plugins et les frontières de l’hôte et le portail de confiance pour les plugins portables et MCP. Et si vous préférez choisir parmi des sources déjà examinées, voyez comment le catalogue de plugins gère cela.