Eine Datei, die dein Agent in Docker hinterlässt, kann auf deinem Laptop als root laufen

Du lässt den Agent in einer Docker-Sandbox etwas bauen. Er installiert Abhängigkeiten, kompiliert, packt und legt das Artefakt in /workspace/dist/ — einem bind mount, der in Wahrheit ein Verzeichnis auf deiner Maschine ist. Am nächsten Tag startest du das Artefakt lokal, und es läuft als root und schreibt, wohin es will.
Der Mechanismus ist banal. Docker-Container laufen standardmäßig als root, also sind die Dateien, die sie in einen bind mount schreiben, auf dem Host root-eigen. Tragen diese Dateien setuid-/setgid-Bits oder file capabilities, dann bringt das Ausführen auf dem Host die Rechteausweitung mit. Das nosuid-Mount des Containers, --cap-drop ALL und no-new-privileges helfen hier nicht: Sie beschränken Prozesse im Container, nicht die Permission-Bits einer Datei, die auf deinem Dateisystem liegt.
Warum Container-Härtung nicht mit der Datei mitzieht
| Härtung | Was sie beschränkt | Gilt sie für eine Datei auf dem Host? |
|---|---|---|
--cap-drop ALL |
Prozesse im Container | Nein |
nosuid-Mount |
Die Sicht des Containers auf seine Mounts | Nein |
no-new-privileges |
exec im Container |
Nein |
Der Modus der Datei selbst (u+s, g+s, file caps) |
Jeden, der sie ausführt | Ja — über die Grenze hinweg |
Die letzte Zeile ist die ganze Geschichte. setuid ist ein Dateiattribut, kein Container-Attribut. Schreibt der Container in einen bind mount, wandern die Bits mit; löschst du den Container, bleiben die Bits. Die einzige verlässliche Abwehr ist, sie auf der Host-Seite zu entfernen — chmod ug-s plus file capabilities löschen —, statt Mount-Optionen zu vertrauen.
Was Hermes geändert hat
Die Änderung liegt in tools/environments/docker_ownership.py, und die Reihenfolge ist Absicht: erst Ownership zurückgeben, dann Bits entfernen.
- Reihenfolge. Beim Teardown läuft zuerst die Ownership-Rückgabe — ihr
chownräumt auf allem root-Eigenen bereits Bits und Capabilities ab. Danach folgt ein separater Strip-Durchlauf, und jeder Schritt hat sein eigenes 10-Sekunden-Budget, damit ein riesiger Baum die Rückgabe nicht aushungert. - Wie der Strip arbeitet. Ein gehärteter Wegwerf-Container führt aus:
find -P <sandbox-binds> -xdev <prune-nested-mounts> \
-type f -perm /6000 -exec chmod ug-s {} +
Er ändert keinen Owner und braucht kein stat, funktioniert also auch auf Images, die nie /bin/stat mitgebracht haben (bullseye-slim); geprüft mit GNU findutils und busybox.
- File capabilities.
findkann xattrs nicht prüfen. Wurde dem ContainerSETFCAP,ALLgewährt oder läuft er--privileged, chownt derselbe Wegwerf-Container jede reguläre Datei auf ihre eigene uid (uids einmal gelesen, ein Durchlauf pro Owner), was die Caps löscht, ohne einen fremden Owner zu hinterlassen. Die Basis-Argumente lassenSETFCAPweg, damit der Standardpfad beim günstigen Strip bleibt. - Persistente Sandboxes sind mit abgedeckt. Genau da lag die Lücke im vorherigen Durchgang (#141033): Er lief nur, wenn ein Container entfernt wurde, und die standardmäßig persistente Sandbox wird nie entfernt. Der Strip läuft jetzt zusätzlich:
- wenn eine Session sich von einem persistenten Container löst (
cleanup()im Persist-Modus, auf dem atexit-geleerten Teardown-Thread); - vor dem ersten Befehl nach dem Wiederanhängen;
- in
reap_orphan_containers, bevor ein beendeter Container entfernt wird.
- wenn eine Session sich von einem persistenten Container löst (
- Der Scope ist eng. Strip-Ziele kommen aus den Mounts des Containers in
docker inspectund bleiben nur erhalten, wennrealpath(Source)unter einem<sandbox>/docker-Root liegt, den der Host selbst herleitet — sein eigenes Sandbox-Verzeichnis oder das Standard-Verzeichnis des Hermes-Roots und jedesprofiles/*. Das deckt Profile ab, die sich einen Container teilen. Container-Labels und andere Metadaten erweitern die Menge nie, denn Extra-Args undLABELs im Image können sie setzen. - Mounten. Nur diese Sandbox-Verzeichnisse werden gemountet, über ihren aufgelösten Pfad, mit
--mount type=bind(eine fehlende Quelle wird übersprungen, nie root-eigen neu angelegt).--volumes-fromkommt nicht zum Einsatz, damit ein mitten im Durchlauf getauschter Pfad höchstens zu Sandbox-Dateien führen kann. - Fehler bleiben nicht still. Ein Strip, der nicht laufen kann (fehlendes Tool, Exit-Code ungleich null, Timeout), loggt eine WARNUNG mit dem Sandbox-Pfad, einmal pro Sandbox.
Die Teile, die sich nicht geändert haben, zählen genauso viel: find -P folgt nie Symlinks, verschachtelte Mounts werden gepruned, PATH-/Loader-Env wird zurückgesetzt, --cap-drop ALL mit nur CHOWN/DAC_OVERRIDE/FOWNER, no-new-privileges, Device-Nodes und FIFOs bleiben unangetastet, alles best effort. Der ganze Mechanismus ist an Linux gebunden, daran, dass Hermes selbst nicht root ist, an nicht-rootless Docker und daran, dass kein userns-remap aktiv ist.
Prüf deinen eigenen Host (zehn Minuten)
Ist dein Build älter als der Fix, kannst du von Hand prüfen und aufräumen. Zuerst die echten Mount-Pfade holen:
docker ps --format '{{.ID}} {{.Image}} {{.Names}}'
docker inspect <container> --format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{"\n"}}{{end}}'
Dann auf dem Host nach privilegierten Dateien suchen:
find -P <that Source path> -xdev -type f -perm /6000 -print
Ausgabe bedeutet echte Exposition. Von Hand entfernen:
find -P <that Source path> -xdev -type f -perm /6000 -exec chmod ug-s {} +
Dann file capabilities prüfen (braucht libcap-Tooling; vielen schlanken Hosts fehlt es):
getcap -r <that Source path>
setcap -r <file path>
Das sind Linux-Formen — -perm /6000 von GNU find heißt „eines dieser Bits gesetzt“. Auf macOS mit Docker Desktop verhält sich das Mapping anders, und Host-Dateien gehören normalerweise dir, die Exposition ist also kleiner; die Maschinen, auf die es ankommt, sind Linux-Produktionskisten, CI-Runner und geteilte Dev-Server.
Drei Entscheidungen, die du heute treffen kannst
1. Auf das Update warten oder jetzt aufräumen. Die Änderung wurde am 18. Oktober 2026 nach main gemergt — nachdem v0.22.2 am 15. Oktober erschienen ist —, das aktuelle Release hat sie also nicht. Dein nächstes hermes update nimmt sie automatisch mit; es gibt kein Flag, sie ist standardmäßig an.
2. Langfristig: den Container als deinen User laufen lassen. Wenn die Sandbox hauptsächlich gemountete Dateien bearbeitet statt apt install zu machen, beseitigt das root-eigene Dateien an der Quelle:
terminal:
backend: docker
docker_run_as_host_user: true # default: false
Der Trade-off ist dokumentiert: Der Container kann keine root-eigenen Pfade wie /root/.npm mehr schreiben, du brauchst also ein Image, dessen HOME einem Nicht-root-User gehört, wenn du beides willst. Mehr dazu im Docker-Abschnitt der Konfigurationsdoku.
3. Sandbox-Artefakte nicht auf dem Host ausführen. Versionsunabhängiger Rat: Code, dem du nicht traust, gehört zurück in einen Container oder eine Wegwerf-VM. Bits zu entfernen verhindert Unfälle, nicht bösartige Builds.
Kein isolierter Patch
Erst #141033, dann #141086 — zusammen ergeben die beiden einen größeren Punkt: Eine Isolationsgarantie deckt nur das Innere der Isolationsgrenze ab. In dem Moment, in dem eine Datei aus dem Container heraus auf das Host-Dateisystem gelangt, gelten alle Härtungs-Flags nicht mehr. Jedes Backend, das einen Agent auf deine Maschine schreiben lässt — Docker-bind-mounts, entfernte SSH-Hosts, synchronisierte Cloud-Sandbox-Verzeichnisse —, muss dieselbe Frage beantworten: Welche Rechte bringt dieses Artefakt mit, wenn es auf der vertrauenswürdigen Seite ankommt?
Wenn du so etwas baust, lohnen sich zwei verwandte Stücke hier: einen Docker-Container über Sessions hinweg teilen behandelt die Isolationsgrenze, wenn mehrere Chats sich einen Container teilen, und wie Sandbox-Medien ausgeliefert werden behandelt den Artefakt-Transport über die Grenze. Richtest du Hermes gerade zum ersten Mal ein? Der Installations-Guide listet, wie die einzelnen Backends aktiviert werden.
Zusammenfassung
- Symptom: In einen bind mount geschriebene Dateien kommen mit setuid/setgid oder file capabilities auf dem Host an, und ihr Ausführen eskaliert Rechte.
- Ursache: Das sind Dateiattribute;
nosuid,cap-dropundno-new-privilegesauf der Container-Seite erreichen den Host nicht. - Fix: Ein Strip-Durchlauf auf der Host-Seite (
chmod ug-splus Owner-Swap für Capabilities) bei Teardown, Detach, Reattach und Orphan-Sweep, begrenzt auf Sandbox-bind-Verzeichnisse. - Heute: Mounts mit
docker inspectfinden, mitfind -perm /6000selbst prüfen, auf das Update warten; langfristigdocker_run_as_host_usernutzen oder nicht vertrauenswürdige Artefakte aus der Host-Shell heraushalten.