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 chown rä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. find kann xattrs nicht prüfen. Wurde dem Container SETFCAP, ALL gewä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 lassen SETFCAP weg, 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.
  • Der Scope ist eng. Strip-Ziele kommen aus den Mounts des Containers in docker inspect und bleiben nur erhalten, wenn realpath(Source) unter einem <sandbox>/docker-Root liegt, den der Host selbst herleitet — sein eigenes Sandbox-Verzeichnis oder das Standard-Verzeichnis des Hermes-Roots und jedes profiles/*. Das deckt Profile ab, die sich einen Container teilen. Container-Labels und andere Metadaten erweitern die Menge nie, denn Extra-Args und LABELs 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-from kommt 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-drop und no-new-privileges auf der Container-Seite erreichen den Host nicht.
  • Fix: Ein Strip-Durchlauf auf der Host-Seite (chmod ug-s plus Owner-Swap für Capabilities) bei Teardown, Detach, Reattach und Orphan-Sweep, begrenzt auf Sandbox-bind-Verzeichnisse.
  • Heute: Mounts mit docker inspect finden, mit find -perm /6000 selbst prüfen, auf das Update warten; langfristig docker_run_as_host_user nutzen oder nicht vertrauenswürdige Artefakte aus der Host-Shell heraushalten.