Autonomous coding agents such as Claude Code, Cursor, Cline and their MCP-wired cousins ask before running shell commands by default, but that gate erodes fast in practice. Developers allowlist docker once, switch on auto-approve for long tasks, or wire an MCP server that calls the Engine API directly with no prompt at all. When agents inherit a Docker CLI pointed at a rootful daemon, every docker run, docker exec and socket-mounted sidecar becomes a privileged execution path on the host. Rootless Docker, Linux user namespaces and cgroups v2 together form a containment layer for agent-driven container workloads, provided agents are wired to the right socket and can't fall back to the system daemon.
The Docker Engine API is root-equivalent. Any process that can open /var/run/docker.sock can create a privileged container, bind-mount the host filesystem, or escape through device nodes. Mounting the socket :ro doesn't change this. A read-only bind mount protects the socket file, not the HTTP requests sent through it.
Autonomous agents amplify that blast radius. A single prompt like "reproduce the CI failure in Docker" can cascade into:
docker run loops that exhaust host memory and inode tables.--privileged, collapsing namespace isolation.-v /:/host) "for debugging," followed by writes outside the workspace.--pids-limit is absent.DOCKER_HOST was never pinned.The failure mode isn't a malicious operator. It's a probabilistic tool-caller with shell access and incomplete context. A "helpful" retry loop that re-pulls images, recreates networks and restarts Compose projects looks like routine automation in the transcript and like a change storm on the host. The defense has to be structural: demote the daemon itself, then put a quota on every container it creates.
Rootless Docker runs dockerd and containerd inside a user namespace created by RootlessKit. Every request an agent makes crosses these layers:
[Autonomous agent / MCP server]
│ Engine API over a user-owned socket
▼
[unix://$XDG_RUNTIME_DIR/docker.sock]
│
▼
[Rootless dockerd + RootlessKit] user, mount and network namespaces; UID remapping
│
▼
[slirp4netns / cgroups v2] user-mode networking; delegated resource controllers
│
▼
[Host kernel, as a non-root UID] no CAP_SYS_ADMIN in the initial namespace
Rootless mode depends on subordinate ID ranges in /etc/subuid and /etc/subgid:
deploy:100000:65536
RootlessKit builds the namespace's ID map from two sources. Container UID 0 maps to the invoking user's own host UID, for example 1001 for deploy. Container UIDs 1 through 65536 map to the subordinate range, so container UID 1 is host UID 100000, and so on.
Root inside a container is therefore never root on the host. A process that escapes a container holds the deploying user's privileges, and files that container root creates on bind mounts are owned by that user. The rule that follows: run agent workloads under a dedicated, low-privilege service account, never a developer's personal login, so that "the user's privileges" means almost nothing.
Setup is typically:
dockerd-rootless-setuptool.sh install
export PATH="$HOME/bin:$PATH"
export DOCKER_HOST="unix://$XDG_RUNTIME_DIR/docker.sock"
dockerd-rootless.sh starts the daemon under rootlesskit, which creates the namespaces, applies the ID mapping through the setuid helpers newuidmap and newgidmap, and starts the networking helper.
Rootful Docker attaches containers to a host bridge (docker0) and programs host iptables as real root. Rootless Docker can't. Its network lives inside RootlessKit's own namespace, connected to the host by a user-mode network stack. That's slirp4netns when installed, and otherwise gvisor-tap-vsock; pasta is available experimentally. Container traffic leaves the host as ordinary sockets owned by the unprivileged user.
What that means for agent hardening:
net.ipv4.ip_unprivileged_port_start or grant CAP_NET_BIND_SERVICE to rootlesskit.lxc-user-nic needs a setuid binary, and bypass4netns needs a custom seccomp profile. Stay on slirp4netns unless you've measured a need.Containers egress as the deploying user, not as the host's packet-filter authority. Host firewall policy is therefore a separate concern: don't assume egress restrictions you never configured.
User namespaces stop privilege escalation; cgroups v2 stops resource exhaustion. Rootless Docker applies cgroup limits only when running with cgroup v2 and systemd. In that case, docker info reports Cgroup Driver: systemd. If it reports none, rootless Docker silently ignores --memory, --cpus and --pids-limit.
Even with systemd, the user's slice typically receives only the memory and pids controllers. cpu and io limits require extra delegation, configured system-wide in section 6.
|
Controller |
Purpose |
Agent failure it stops |
|---|---|---|
|
|
Hard memory ceiling |
OOM of sibling services, host thrashing |
|
|
CPU quota per period |
Runaway compile farms stealing cores |
|
|
Process count cap |
Fork bombs from flaky test harnesses |
|
|
Block I/O throttling |
Disk saturation from image pulls and builds |
Every agent-launched container should carry explicit limits:
docker run --rm \
--memory=512m \
--memory-swap=512m \
--cpus=1.0 \
--pids-limit=256 \
--security-opt no-new-privileges:true \
--read-only \
--tmpfs /tmp:rw,size=64m \
agent-sandbox:latest
Setting --memory-swap equal to --memory disables extra swap. --pids-limit is non-negotiable for agents that spawn shells and package managers. --cpus sets a hard cpu.max quota, while --cpu-shares is only a relative weight under contention.
Verify that the CPU controller, the one that usually isn't delegated, actually works:
docker run --rm --cpus=0.5 busybox cat /sys/fs/cgroup/cpu.max
You should see 50000 100000. If the command fails or the limit is missing, cpu isn't delegated to your user. Apply the drop-in in section 6 before blaming the agent wrapper.
Most agent configurations and MCP servers default to /var/run/docker.sock, the rootful system socket. Rootless Docker listens elsewhere:
unix://$XDG_RUNTIME_DIR/docker.sock
typically: unix:///run/user/1000/docker.sock
If DOCKER_HOST is unset and the client falls back to the system socket, every rootless boundary you built is bypassed. Agents running as systemd user units, IDE extensions or MCP child processes often inherit a minimal environment that lacks XDG_RUNTIME_DIR.
A resolution order that holds up in production:
DOCKER_HOST.$XDG_RUNTIME_DIR/docker.sock exists and is a socket, use it./var/run/docker.sock only when policy allows rootful access, and log a loud warning.Wrappers and MCP servers should stat the runtime path before connecting. Pair that with a deny rule if the socket's peer credentials (SO_PEERCRED) show UID 0 when the agent expected a user daemon. IDE-spawned children usually inherit HOME but not XDG_RUNTIME_DIR. Setting DOCKER_HOST explicitly in the MCP server definition matters as much as the rootless install itself.
|
Dimension |
Rootful Docker |
Rootless Docker |
|---|---|---|
|
Daemon runs as |
root (UID 0) |
Unprivileged user |
|
Container root maps to |
Host root |
The deploying user's UID |
|
Socket path |
|
|
|
Breakout outcome |
Host root |
That user's privileges |
|
Host firewall control |
Full (host iptables) |
None (rules stay inside RootlessKit's namespace) |
|
|
Full host device and kernel access |
Capabilities apply only inside the user namespace |
|
cgroups v2 limits |
Always available |
Requires systemd driver; |
|
Host ports below 1024 |
Allowed |
Blocked without sysctl or capability changes |
|
Fit for autonomous agents |
Poor default |
Strong default |
Rootless isn't a substitute for an application-level policy proxy. It's the execution substrate that makes a compromised or confused agent survivable. Keep the MCP tool surface narrow, and use rootless Docker so whatever still reaches the Engine API can't trivially become host root.
daemon.jsonPlace at ~/.config/docker/daemon.json:
{
"no-new-privileges": true,
"default-cgroupns-mode": "private",
"userland-proxy": false,
"default-ulimits": {
"nofile": {
"Name": "nofile",
"Hard": 4096,
"Soft": 2048
}
},
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}
|
Key |
Why |
|---|---|
|
|
Containers can't gain privileges through setuid binaries |
|
|
Each container sees only its own cgroup tree |
|
|
Lets RootlessKit's port driver propagate client source IPs |
|
|
Bounds file descriptors per container |
|
|
Caps log growth from chatty agent jobs |
Process limits belong in cgroups through --pids-limit, not in an nproc ulimit. Under user namespaces, nproc counts processes per host UID, so containers would share one budget with each other and with the daemon.
Delegation is configured on the system's [email protected], not on the user's Docker unit, and needs root:
sudo mkdir -p /etc/systemd/system/[email protected]
/etc/systemd/system/[email protected]/delegate.conf:
[Service]
Delegate=cpu memory pids io
sudo systemctl daemon-reload
# Log out and back in (or restart [email protected]), then confirm:
cat "/sys/fs/cgroup/user.slice/user-$(id -u).slice/user@$(id -u).service/cgroup.controllers"
The output should list cpu io memory pids. Add cpuset to the Delegate= line, which requires systemd 244 or later, if agents need --cpuset-cpus.
After dockerd-rootless-setuptool.sh install, cap the daemon itself:
mkdir -p ~/.config/systemd/user/docker.service.d
~/.config/systemd/user/docker.service.d/override.conf:
[Service]
MemoryMax=2G
CPUQuota=200%
TasksMax=4096
These limits apply to the daemon's own processes: dockerd, containerd, RootlessKit and the network helper. Containers get their own systemd scopes, limited per container.
Don't add NoNewPrivileges=true to this unit. RootlessKit needs the setuid helpers newuidmap and newgidmap to build the ID mapping, and NoNewPrivileges stops them from gaining privileges, so the daemon fails to start. Clients choose the socket through DOCKER_HOST, so setting it in this unit has no effect.
systemctl --user daemon-reload
systemctl --user restart docker
loginctl enable-linger "$(whoami)" # keep the user manager running without a login session
export DOCKER_HOST="unix://${XDG_RUNTIME_DIR}/docker.sock"
docker run --rm -i \
--name "agent-job-${RANDOM}" \
--memory=512m \
--memory-swap=512m \
--cpus=1.0 \
--pids-limit=256 \
--cgroupns=private \
--security-opt no-new-privileges:true \
--cap-drop ALL \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
--network none \
--user 65534:65534 \
alpine:3.20 \
sh -c 'echo "contained agent task"; id; cat /sys/fs/cgroup/pids.max'
--network none is the right starting point for build and test agents that don't need egress. Open networking only when the task requires it. Dropping all capabilities and running as nobody closes the obvious privilege ladders even inside the user namespace.
Rootless dockerd, subordinate ID mappings, user-mode networking and cgroups v2 quotas turn agent container access from an implicit root grant into a bounded, observable workload. The remaining gap is the agent's client side. An MCP server that still resolves /var/run/docker.sock quietly undoes all of it.
docker-mcp-guardian is an open-source MCP server that gives Claude and similar agents read-mostly access to Docker. It's read-only by default, resolves restart deny-lists by canonical container name, withholds secrets from inspect output, and bounds every daemon call with a timeout. It also implements the resolution order from section 4: an explicit DOCKER_HOST, then the rootless socket, and the rootful socket only with GUARDIAN_ALLOW_ROOTFUL=true. With no acceptable endpoint it fails closed instead of connecting.
Two open issues take rootless support further, and contributions are welcome:
SO_PEERCRED), and refuses a root daemon when the operator expects a rootless one. It's labeled help wanted.good first issue, covering the DOCKER_HOST and XDG_RUNTIME_DIR environment gap in MCP client definitions.Pick one, point your own agents at a rootless socket, and help make safe container access the default for autonomous coding tools.