A file your agent left in Docker can run as root on your laptop

You ask the agent to build something inside a Docker sandbox. It installs dependencies, compiles, packages, and drops the artifact in /workspace/dist/ — a bind mount that is really a directory on your machine. The next day you run that artifact locally and it executes as root, writing wherever it likes.
The mechanism is mundane. Docker containers run as root by default, so files they write into a bind mount are root-owned on the host. If any of those files carry setuid/setgid bits or file capabilities, executing them on the host brings the privilege escalation with them. The container’s nosuid mount, --cap-drop ALL and no-new-privileges do not help here: they constrain processes inside the container, not the permission bits of a file sitting on your filesystem.
Why container hardening does not follow the file home
| Hardening | What it constrains | Does it apply to a host file? |
|---|---|---|
--cap-drop ALL |
Processes inside the container | No |
nosuid mount |
The container’s view of its mounts | No |
no-new-privileges |
exec inside the container |
No |
The file’s own mode (u+s, g+s, file caps) |
Anyone who executes it | Yes — across the boundary |
The last row is the whole story. setuid is a file attribute, not a container attribute. When the container writes into a bind mount, the bits go with it; delete the container and the bits remain. The only dependable defence is to strip them on the host side — chmod ug-s plus clearing file capabilities — instead of trusting mount options to do it.
What Hermes changed
The change lives in tools/environments/docker_ownership.py and the order is deliberate: hand ownership back first, then strip bits.
- Order. At teardown the ownership handback runs first — its
chownalready clears bits and capabilities on everything root-owned. A separate strip pass runs after it, and each step has its own 10-second budget so a huge tree cannot starve the handback. - How the strip works. One hardened throwaway container runs:
find -P <sandbox-binds> -xdev <prune-nested-mounts> \
-type f -perm /6000 -exec chmod ug-s {} +
It changes no owner and needs no stat, so it also works on images that never shipped /bin/stat (bullseye-slim); verified on GNU findutils and busybox.
- File capabilities.
findcannot test xattrs. When the container was grantedSETFCAP,ALL, or runs--privileged, the same throwaway re-chowns every regular file to its own uid (uids read once, one walk per owner), which clears the caps without leaving a different owner behind. The base arguments dropSETFCAP, so the default path stays on the cheap strip. - Persistent sandboxes are covered too. That was the real gap in the previous pass (#141033): it only ran when a container was removed, and the default persistent sandbox is never removed. The strip now also runs:
- when a session detaches from a persistent container (
cleanup()in persist mode, on the atexit-drained teardown thread); - before the first command after reattaching;
- in
reap_orphan_containers, before an exited container is removed.
- when a session detaches from a persistent container (
- Scope is tight. Strip targets come from the container’s mounts in
docker inspect, kept only whenrealpath(Source)sits under a<sandbox>/dockerroot the host derives itself — its own sandbox directory, or the default one of the Hermes root and eachprofiles/*. That covers profiles that share a container. Container labels and other metadata never widen the set, because extra args and imageLABELs can set them. - Mounting. Only those sandbox directories are mounted, by their resolved path, with
--mount type=bind(a missing source is skipped, never recreated root-owned). It does not use--volumes-from, so a path swapped mid-walk can only lead to sandbox files. - Failures are not silent. A strip that cannot run (missing tool, nonzero exit, timeout) logs a WARNING with the sandbox path, once per sandbox.
The parts that did not change matter as much: find -P never follows symlinks, nested mounts are pruned, PATH/loader env is reset, --cap-drop ALL with only CHOWN/DAC_OVERRIDE/FOWNER, no-new-privileges, device nodes and FIFOs untouched, best-effort overall. The whole mechanism is gated on Linux, Hermes itself not root, non-rootless Docker, and no userns-remap.
Check your own host (ten minutes)
If your build predates the fix, you can verify and clean up by hand. First get the real mount paths:
docker ps --format '{{.ID}} {{.Image}} {{.Names}}'
docker inspect <container> --format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{"\n"}}{{end}}'
Then look for privileged files on the host:
find -P <that Source path> -xdev -type f -perm /6000 -print
Output means real exposure. Strip by hand:
find -P <that Source path> -xdev -type f -perm /6000 -exec chmod ug-s {} +
Then check file capabilities (needs libcap tooling; many slim hosts lack it):
getcap -r <that Source path>
setcap -r <file path>
These are Linux forms — GNU find’s -perm /6000 means “any of these bits set”. On macOS with Docker Desktop the mapping behaviour differs and host files are normally owned by you, so the exposure is smaller; the machines that matter are Linux production boxes, CI runners and shared dev servers.
Three decisions you can make today
1. Wait for the update, or clean up now. The change merged to main on October 18, 2026 — after v0.22.2 shipped on October 15 — so the current release does not have it. Your next hermes update picks it up automatically; there is no flag to enable, it is on by default.
2. Longer term: run the container as your user. If the sandbox mostly edits mounted files rather than apt installing things, this removes root-owned files at the source:
terminal:
backend: docker
docker_run_as_host_user: true # default: false
The trade-off is documented: the container can no longer write root-owned paths such as /root/.npm, so you need an image whose HOME is owned by a non-root user if you need both. More detail in the Docker section of the configuration docs.
3. Do not execute sandbox artifacts on the host. Version-independent advice: code you do not trust belongs back in a container or a disposable VM. Stripping bits prevents accidents, not malicious builds.
Not an isolated patch
#141033 followed by #141086 makes a broader point: an isolation guarantee only covers the inside of the isolation boundary. The moment a file crosses out of the container and lands on the host filesystem, every hardening flag stops applying. Any backend that lets an agent write to your machine — Docker bind mounts, remote SSH hosts, synced cloud-sandbox directories — has to answer the same question: what permissions does this artifact carry when it arrives on the trusted side?
If you are building that kind of setup, two related pieces here are worth reading: sharing one Docker container across sessions covers the isolation boundary when several chats share a container, and how sandbox media gets delivered covers artifact movement across the boundary. Setting Hermes up for the first time? The install guide lists how each backend is enabled.
Summary
- Symptom: files written into a bind mount arrive on the host carrying setuid/setgid or file capabilities, so executing them escalates.
- Cause: these are file attributes; container-side
nosuid,cap-dropandno-new-privilegescannot reach the host. - Fix: a host-side strip pass (
chmod ug-splus an owner-swap for capabilities) at teardown, detach, reattach and orphan sweep, limited to sandbox bind directories. - Today: find your mounts with
docker inspect, self-check withfind -perm /6000, wait for the update; longer term usedocker_run_as_host_useror keep untrusted artifacts out of the host shell.