Du hast den Community-Skill nur geöffnet — und er führte einen Shell-Befehl aus

Du installierst einen Skill aus einem Community-Hub, wirfst einen Blick auf die Erfolgszeile und machst weiter. Wochen später öffnest du ihn in einer Session nur, um zu lesen, was er tatsächlich tut — und genau dieser Blick war der gefährliche Teil: Den Skill zu öffnen genügte früher, um sein Inline-Shell-Snippet auszuführen. Du hast keinen Befehl ausgeführt. Der Befehl lief trotzdem.
Das Problem war nie „der Scanner hat etwas übersehen“. Sondern dass die Frage „Was gilt als Community-Skill?“ zu optimistisch beantwortet wurde. Vier Vertrauensgrenzen wurden auf einmal verschärft, und dasselbe Batch räumte einen ganzen Schwung False Positives auf — plus zwei Installationen, die nie gelingen konnten.
Was Inline-Shell ist und wann sie tatsächlich läuft
Ein Hermes-Skill-Body kann ein Inline-Shell-Snippet enthalten: einen !`cmd`-Marker in SKILL.md. Wenn du den Skill öffnest oder lädst, wird der Befehl über bash -c expandiert und seine Ausgabe in den Skill-Body injiziert, sodass ein Skill Live-Daten mitführen kann — den aktuellen Branch, einen Zeitstempel, eine Anzahl.
Standardmäßig ist es aus:
hermes config get skills.inline_shell # standardmäßig False
Du aktivierst es in config.yaml:
skills:
inline_shell: true
inline_shell_timeout: 10 # Sekunden pro Snippet
Der Haken: inline_shell: true ist ein Schalter, der jeden installierten Skill auf einmal scharf stellt. Was entscheidet, ob ein Snippet tatsächlich läuft, ist deshalb nicht das Urteil des Scanners, sondern die Antwort der Runtime auf die Frage „ist das ein Community-Skill?“. Und genau diese Antwort hat sich geändert.
Die drei Löcher in der alten Antwort
Der alte _is_community_hub_skill-Check ruhte auf drei Annahmen, und alle drei waren optimistisch:
- Er matchte nur ein Verzeichnis, das exakt dem gespeicherten Installationspfad entsprach. Ein Skill, der in ein Community-Bundle verschachtelt war — etwa
agents/inner/SKILL.md—, zählte überhaupt nicht als Community. - Er las nur das Hub-Lock des aktiven Profils. Wenn ein anderes Profil denselben Skill über
skills.external_dirserreichte, lag die Herkunft im Lock von jemand anderem und der Check blieb leer. - Ein Lock, den er nicht lesen konnte, galt als „nicht Community“. Das war der schlimmste Fall: Eine abgeschnittene oder fehlerhafte
.hub/lock.jsonmachte jeden Skill in diesem Baum wieder zu einem vertrauenswürdigen — ein Fail-Open-Standard.
Hier das Vorher und Nachher:
| Fall | Vorher | Jetzt |
|---|---|---|
| Ein Skill, der in ein Community-Bundle verschachtelt ist | Expandiert | Bleibt roh |
Ein anderes Profil erreicht ihn über external_dirs |
Expandiert | Bleibt roh |
Die .hub/lock.json des Baums abgeschnitten oder falsch geformt |
Als nicht-Community behandelt, expandiert | Der ganze Baum schlägt fail-closed fehl |
| Gar keine Lock-Datei (keine Hub-Installationen auf dieser Maschine) | Nicht expandiert | Unverändert |
Der Check hat jetzt zwei harte Regeln: Er matcht das Installationsverzeichnis oder jedes Verzeichnis darunter (auf aufgelösten Pfaden), und er liest das Lock des aktiven Profils und das Lock, dem der Skills-Baum gehört, in dem das Verzeichnis liegt — den nächstgelegenen Vorfahren namens skills/, der eine .hub/lock.json trägt. Anders gesagt: Wer auf den Skill schaut, ändert nicht mehr, was er ist.
Der Kompromiss ist bewusst dokumentiert: Solange das Lock eines Baums beschädigt ist, expandieren auch Skills, die du selbst in demselben Baum geschrieben hast, nicht mehr. Ein kaputtes Lock kann uns nicht sagen, welche Verzeichnisse Community-Installationen sind, also schlägt der ganze Baum fail-closed fehl, bis das Lock repariert ist.
Die andere Seite des Scanners: skills-guard-v9 behebt False Positives
Ein früherer Artikel behandelte die blinden Flecken des Scanners. Dieses Batch behebt den entgegengesetzten Fehler — zu viel blockieren.
Zuerst die Daten. Über 14 Tage neuer Clients war scan_blocked der häufigste Grund, warum Hub-Skill-Installationen fehlschlugen (hermes-index 39 Ereignisse über 8 Install-Tage, clawhub 19/15, skills-sh 5/4, url 6/4). All das sind echte Fehlschläge — die Installation wurde verweigert. Die Ursache war kein gefährlicher Inhalt, sondern ungenaues Pattern-Matching. In einer zufälligen Index-Stichprobe mit 89 Einträgen waren etwa 10 von 13 Blocks Pattern-False-Positives.
Eine Ursache ist ein Klassiker: Die ganze Regeltabelle kompilierte mit re.IGNORECASE, also matchte die AWS-Key-Regel AKIA[0-9A-Z]{16} den Platzhalter AKIAxxxxxxxxxxxxxxxx. Ebenso schlugen rm -rf /, base64 -d |, setuid und HTML-Comment-Injection bei Dockerfile-Cache-Aufräumarbeiten, bei | jq, bei gewöhnlichem Fließtext und bei Layout-Kommentaren an.
Nach dem Fix sinken auf einer Zufallsstichprobe von 421 echten Index-Skills die blockierten Installationen von 83 auf 68 — 33 entsperrt, 0 neu blockiert — und jeder konstruierte Angriff wird weiterhin blockiert.
Konkret macht der Scanner jetzt Folgendes:
- behandelt Key-Formen case-sensitiv —
AKIA,ghp_/github_pat_,sk-,sk-ant-,glpat-,BEGIN PRIVATE KEYmatchen keine rein kleingeschriebenen Platzhalter mehr; - nimmt Package-Cache-Aufräumarbeiten von der
rm -rf /-Regel aus (/var/lib/apt/lists,/var/cache/apt|apk|yum|dnf), prüft aber jedes Operand — was auchrm -rf --no-preserve-root /erfasst, das vorher übersehen wurde; - schränkt
base64 -d-Pipes ein: schlägt nur an, wenn die dekodierten Bytes in irgendeiner späteren Stufe einen Interpreter erreichen (sh/bash/python/perl/node/eval/xargs/env/sudo/iex/pwsh) oder direkt in| tarfließen (ein eingebettetes Payload, das der Scanner nie sieht). Daten-Konsumenten wie| jqund| grepschlagen nicht mehr an; - verlangt Injection-Formulierungen bei HTML-Kommentaren — „ignore previous instructions“, System-Prompt, versteckte Anweisungen — statt bei jedem Kommentar anzuschlagen;
- stuft setuid nur bei echten Aufrufen als kritisch ein (
set*id(,cap_set[ug]id,chmod u+s,g=s, oktal 2xxx–7xxx); das bloße Wort wird zu einem Hinweis. Das erfasst auchchmod 4755, das die alte Regel übersah; - schlägt bei
curl … | sudo -E bashan als Curl-Pipe-Shell, wo die alte Regel nur| basherkannte; - hebt die Scanner-Version auf
skills-guard-v9an (undplugin-guard-v10für Plugins), was gecachte Urteile ungültig macht, sodass alles neu gescannt wird.
Ein Detail lohnt zu wiederholen: Die Änderung führte eine eigene Regression ein, die am nächsten Tag behoben wurde. Sobald die Regel jedes rm-Operand prüfte, wurde ein nachgestellter //-Kommentar als ein auf / verwurzelter Pfad gelesen, also wurde rm -rf node_modules dist // clean als kritisch ausgegeben. Der Fix verhindert, dass ein nacktes // nach dem ersten Operand als Root zählt, während rm -rf // und rm -rf //etc weiterhin kritisch bleiben. Ein erneuter Scan einer gecachten 633-Skill-Stichprobe lieferte identisches Urteil, identische Installationsentscheidung und identische Befundmenge.
Die 1.900 Index-Zeilen, die nie installierbar waren
Wenn du je einen Skill aus dem Index gewählt und ihn scheitern gesehen hast, sah der Fehlschlag wahrscheinlich wie stale_index aus — und der Client hatte bis dahin bereits etwa 40 GitHub-API-Aufrufe verbraucht.
Die Ursache ist banal: skills.sh hält rund 20.000 Zeilen, und etwa 1.920 bis 1.985 davon werden ohne resolved_github_id ausgeliefert — also ganz ohne herunterladbare Location. Der Index-Builder behandelte drei wirklich unterschiedliche Situationen als eine: ein 404-Repository, einen Baum, der sich gut lesen ließ, aber keinen passenden Skill enthielt, und einen abgeschnittenen Baum. Alle drei wurden unverändert veröffentlicht. Sein Fuzzy-Match entfernte außerdem -, aber nicht _, und er behandelte nie das Single-Skill-Layout, bei dem das einzige SKILL.md eines Repos im Root liegt — obwohl der Client diese durchaus installiert.
Der Builder unterscheidet jetzt ok / truncated / gone (Repo 404) / error; normalisiert beide Seiten mit [-_] (sodass pdb-database auf pdb_database passt); und löst Repos auf, deren einziges SKILL.md im Root oder in einem generischen skills/, .agents/skills/- oder .claude/skills/-Verzeichnis liegt. Zeilen in einem verschwundenen Repo und Zeilen ohne Treffer in einem vollständigen Baum werden verworfen. Bei abgeschnittenen Bäumen (posthog/posthog hat 58k Einträge) sondiert er stattdessen Kandidatenpfade, begrenzt auf 10 Zeilen pro Repo. Ein error — 5xx, Timeout, Auth — lässt seine Zeilen unaufgelöst, ein vorübergehender Fehler löscht also nie etwas. Das Build-Log gibt jetzt eine Zeile aus, die sich zu greppen lohnt: Resolved N/M paths, dropped D uninstallable (G repo gone, R not in repo).
Eine Stichprobe mit 75 Zeilen macht es konkret: Der alte Builder lieferte 75 aus (27 davon unaufgelöst); der neue liefert 57, mit 0 unaufgelösten und 18 verworfen (3 Repos verschwunden, 15, bei denen der Skill nicht im Repo ist).
Zwei Installationen, die nie gelingen konnten — jetzt behoben
Built-in-Wiederherstellungen schlugen für immer fehl. Wenn du einen Skill gelöscht hast, aber Dateien in seinem Verzeichnis blieben, oder eine frühere Kopie mittendrin starb, blieb ein Verzeichnis ohne SKILL.md zurück — und jeder erneute Versuch gab [Errno 17] File exists aus. Ein zweites Layout machte es ebenfalls kaputt: Wenn das Kategorie-Verzeichnis ein Symlink ist, folgt der Loader dem Symlink, sodass der Skill funktioniert, aber das rglob der Wiederherstellung steigt nicht hinein ab, weshalb der Containment-Guard Unsafe install path meldete. Jetzt zählt ein kanonisches SKILL.md als vorhanden und die Kopie nutzt dirs_exist_ok, füllt also um Überbleibsel herum auf, statt zu scheitern. Eine fehlgeschlagene Wiederherstellung wird auch als source=bundled vermerkt, statt auf source=hub zurückzufallen.
Anonymes API-Budget. Die Installation eines Hub-Skills verbrauchte einen Contents-API-Aufruf pro Datei im Skill-Ordner. Bei 60 anonymen Aufrufen pro Stunde konnte ein Skill mit ungefähr 58 oder mehr Dateien nie installiert werden (hermes-index rate_limited: 26 Ereignisse über 9 Install-Tage in 14 Tagen). Dateien werden jetzt zuerst von raw.githubusercontent.com/<repo>/<sha>/<path> gelesen — dieselben unveränderlichen Bytes, und sie zählen nicht gegen das API-Limit —, wobei nur dann auf die Contents-API zurückgefallen wird, wenn der Raw-Leser scheitert (privates Repo, Raw-Ausfall).
Was jetzt zu tun ist
- Den Inline-Shell-Schalter prüfen:
hermes config get skills.inline_shell. Lass ihn aus, außer du brauchst ihn — er ist die billigste Verteidigungslinie, die du hast. - Daran denken, dass gecachte Urteile ungültig wurden: Die Scanner-Version hat sich geändert, Skills werden beim nächsten Zugriff neu gescannt. Alles, was fälschlich blockiert wurde, lohnt einen erneuten Versuch.
- Bei geteilten Deployments stelle sicher, dass die
.hub/lock.jsonjedes Profils sich parsen lässt. Ein einziges beschädigtes Lock deaktiviert jetzt Inline-Shell für seinen ganzen Baum, bis es repariert ist. - Greif nicht reflexartig zum Bypass.
hermes skills install --forceignoriert ein blockiertes Urteil, und es existiert für den Fall, dass du den Inhalt selbst geprüft hast — nicht für „der Scanner nervt schon wieder“.
Das Fazit
Der rote Faden dieses Batches: Vertrauen ist von einer statischen Vermutung zu einer Herkunftsentscheidung geworden: Woher ein Skill kam, welches Lock seine Installation festhält und wer darauf schaut, zählt alles mehr, als ob das Verzeichnis zufällig wie eine Community-Installation aussieht. Ist die Antwort unklar, schlägt alles fail-closed fehl — einschließlich der etwas lästigen Konsequenz, dass ein beschädigtes Lock Inline-Shell für einen ganzen Skills-Baum vorübergehend deaktiviert, bis du es reparierst.
Für den Hintergrund beginne mit unserem Artikel über die drei blinden Flecken des Scanners, dann mit der hermes skills-Befehlsreferenz. Wenn du Third-Party-Skills installierst, lohnen sich zwei weitere: Plugin-Isolation und Host-Grenzen und das Trust-Gate für portable Plugins und MCP. Und wenn du lieber aus bereits geprüften Quellen wählst, lies, wie der Plugin-Katalog das handhabt.