Taming Autonomous AI Agents with Rootless Docker

Taming Autonomous AI Agents with Rootless Docker

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. 1. The AI Execution Hazard 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: Unbounded docker run loops that exhaust host memory and inode tables. Nested Docker-in-Docker with --privileged, collapsing namespace isolation. Host path mounts (-v /:/host) "for debugging," followed by writes outside the workspace. Fork bombs disguised as parallel test runners when --pids-limit is absent. Accidental targeting of production Compose stacks because 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. 2. Rootless Architecture Under the Hood 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 Subordinate UID/GID mappings 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. User-mode networking 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: Any iptables rules the rootless daemon writes live inside RootlessKit's namespace and never touch the host firewall. Publishing host ports below 1024 fails unless you set net.ipv4.ip_unprivileged_port_start or grant CAP_NET_BIND_SERVICE to rootlesskit. Throughput is lower than with a veth bridge. For agent sandboxes, that trade-off is acceptable. Faster drivers exist, but they widen the trust boundary. 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. 3. Hardening via Linux cgroups v2 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 memory.max Hard memory ceiling OOM of sibling services, host thrashing cpu.max CPU quota per period Runaway compile farms stealing cores pids.max Process count cap Fork bombs from flaky test harnesses io.max 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. 4. Resolving Agent Sockets 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: Honor an explicit DOCKER_HOST. Else, if $XDG_RUNTIME_DIR/docker.sock exists and is a socket, use it. Else, fall back to /var/run/docker.sock only when policy allows rootful access, and log a loud warning. Never silently prefer the system socket when a rootless socket is present. 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. 5. Comparison: Rootful vs Rootless Security Posture 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 /var/run/docker.sock $XDG_RUNTIME_DIR/docker.sock Breakout outcome Host root That user's privileges Host firewall control Full (host iptables) None (rules stay inside RootlessKit's namespace) --privileged Full host device and kernel access Capabilities apply only inside the user namespace cgroups v2 limits Always available Requires systemd driver; cpu/io need delegation 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. 6. Ready-to-Run Artifacts Rootless daemon.json Place 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 no-new-privileges Containers can't gain privileges through setuid binaries default-cgroupns-mode Each container sees only its own cgroup tree userland-proxy Lets RootlessKit's port driver propagate client source IPs default-ulimits.nofile Bounds file descriptors per container log-opts 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. Delegating cgroup controllers (system-wide, once) Delegation is configured on the system's user@.service, not on the user's Docker unit, and needs root: sudo mkdir -p /etc/systemd/system/user@.service.d /etc/systemd/system/user@.service.d/delegate.conf: [Service] Delegate=cpu memory pids io sudo systemctl daemon-reload # Log out and back in (or restart user@UID.service), 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. User Docker service override 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 Hardened agent container invocation 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: #4: Verify the daemon's UID via peer credentials checks who is actually listening on the resolved socket (SO_PEERCRED), and refuses a root daemon when the operator expects a rootless one. It's labeled help wanted. #2: MCP client configuration for rootless Docker is a documentation task, labeled 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.

Original Source

Read the full article at Hackernoon →

KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.