# Rootless Docker inside Apple Container bottles Spike branch: `spike/rootless-docker-macos` (`a4d8461`) ## Summary **Negative result.** Rootless Docker cannot run inside an Apple Container bottle without granting the bottle `CAP_SYS_ADMIN`. This is a kernel constraint on writing multi-range `uid_map`, not a packaging gap we can close with a better init script, a different base image, or more careful `/etc/subuid` handling. The spike was built on the premise — stated in `bot_bottle/backend/macos_container/rootless_docker.py` — that it would *"deliberately refuse to compensate for missing prerequisites with outer capabilities, a privileged container, or a host Docker socket."* That premise is exactly what the experiment falsified. The two ways forward are to abandon the premise (add `CAP_SYS_ADMIN` to the bottle, and with it most of the isolation the bottle exists to provide) or to abandon rootless Docker. Recommendation: abandon rootless Docker. Podman does not have this problem — see [Podman is not blocked by this](#podman-is-not-blocked-by-this) below. ## Local environment Tested on 2026-07-21: ```console $ sw_vers ProductName: macOS ProductVersion: 26.5.1 BuildVersion: 25F80 $ container --version container CLI version 1.0.0 (build: release, commit: ee848e3) $ uname -a # inside the bottle Linux ... 6.18.15 #1 SMP Tue Mar 17 01:36:53 UTC 2026 aarch64 GNU/Linux ``` ## The failure `tests/integration/test_macos_rootless_docker_spike.py` builds the image, launches the bottle, and dies in `rootless_docker.start`: ``` + exec rootlesskit --net=slirp4netns --mtu=65520 ... dockerd-rootless.sh [rootlesskit:parent] error: failed to setup UID/GID map: newuidmap 1100 [0 1000 1 1 100000 65536] failed: newuidmap: write to uid_map failed: Operation not permitted ``` ## Why it fails Every prerequisite you would normally suspect is present and correct in the guest: | Check | Result | | --- | --- | | `/usr/bin/newuidmap` | `-rwsr-xr-x root root` — setuid bit intact, survived the OCI export | | `/` mount options | `rw,relatime` — **not** `nosuid` | | `NoNewPrivs` | `0` | | `Seccomp` | `0`, no filters | | `/etc/subuid`, `/etc/subgid` | `node:100000:65536` in both | | user namespace | `user:[4026531837]`, identical to pid 1 — the *initial* userns | | `unshare -U -r true` | succeeds | | `/proc/sys/user/max_user_namespaces` | `4505` | The one thing that is missing is in the capability bounding set that Apple Container gives the container: ``` CapBnd: 00000000a80425fb = chown, dac_override, fowner, fsetid, kill, setgid, setuid, setpcap, net_bind_service, net_raw, sys_chroot, mknod, audit_write, setfcap ``` No `CAP_SYS_ADMIN`. That is the whole story, and the chain is: 1. The kernel's `map_write()` gates writing a `uid_map` on `file_ns_capable(file, ns, CAP_SYS_ADMIN)` — capability over the **new** user namespace, evaluated against the credentials that opened `/proc//uid_map`. 2. `newuidmap` is setuid-root, so it runs with euid 0 — but its capability sets are clamped by the bounding set, which has no `CAP_SYS_ADMIN`. 3. `cap_capable()` has a shortcut that grants *all* capabilities when the caller's userns is the new namespace's parent **and** `ns->owner == cred->euid`. It does not apply: the namespace was created by `node` (uid 1000) while `newuidmap` runs as euid 0. 4. So the check falls through to the effective-set test in the initial userns, which fails. `EPERM`. Note that the single-line unprivileged path (`unshare -U -r`) works precisely because it does not go through `newuidmap` and does not need `CAP_SYS_ADMIN`. Only the multi-range subuid mapping that rootless Docker requires does. This is the same constraint that makes upstream's `dind-rootless` image require `--privileged`. It is not specific to Apple Container, except that Apple Container gives us no bounding set that includes `CAP_SYS_ADMIN` by default. ## It does work with the capability — which is the point Adding the capability clears the failure immediately, and exposes one further, much smaller blocker: `/dev/net/tun` exists (the kernel has tun; `/proc/misc` lists `200 tun`) but Apple Container creates it `crw------- root root`, so uid 1000 cannot open it and `slirp4netns` fails with `open: Permission denied`. A `chmod 0666 /dev/net/tun` as root inside the bottle fixes that, and needs no capability beyond what the bottle already has. With both applied by hand, the daemon comes up completely: ```console $ container run --rm -u root --cap-add CAP_SYS_ADMIN \ bot-bottle-claude:latest-rootless-docker sh -c '...' Server Version: 20.10.24+dfsg1 Storage Driver: fuse-overlayfs Cgroup Driver: none Cgroup Version: 2 API listen on /tmp/rt/docker.sock ``` So `rootless-docker-init.sh` and `rootless_docker.py` are *correct*. The spike did not fail on a bug. It failed on its own premise. Two secondary findings from that run, relevant if anyone revisits this: - Debian's `docker.io` package pins Docker **20.10** (EOL), not the 28.x implied by the `docker:28-cli` compose plugin the image copies in. - `Cgroup Driver: none` — no resource limits on nested containers. ## Why we should not just add the capability `CAP_SYS_ADMIN` is close to a superset of "root" in practical terms — mount, `pivot_root`, namespace manipulation, and a long tail of subsystem-specific powers. Granting it to the agent bottle would undercut the containment argument the rest of the backend is built around, including the deliberately narrow choices immediately adjacent to it in `launch.py` (`--cap-drop CAP_NET_RAW`, no `NET_ADMIN`, a host-only agent network). Trading all of that for nested `docker compose` is a bad exchange. ## Podman is not blocked by this Sanity-checked on the same host, same kernel, same runtime, so the comparison is apples to apples: | Scenario | Result | | --- | --- | | Podman rootless, `/etc/subuid` populated | **Fails identically** — `newuidmap: write to uid_map failed: Operation not permitted` | | Podman rootless, no subuid ranges, `--network=host` | **Works**, no added capabilities | | Podman rootless, no subuid ranges, default netns, `/dev/net/tun` at `0600` | Fails — `slirp4netns: open("/dev/net/tun"): Permission denied` | | Podman rootless, no subuid ranges, default netns, `/dev/net/tun` at `0666` | **Works**, no added capabilities | The difference is that podman degrades gracefully when no subuid range is available: it falls back to a single-UID self-mapping, which an unprivileged process may write itself, so `newuidmap` is never invoked and `CAP_SYS_ADMIN` is never needed. Docker's rootless mode has no equivalent fallback. The cost of that fallback is real and should be weighed before building on it: with a single-UID mapping, every UID inside a nested container collapses onto the bottle's own uid 1000. There is no UID separation between the agent and anything it runs — `root` in a nested container is the agent user outside it. It also requires `ignore_chown_errors` on the storage driver. Whether that is acceptable depends on whether the bottle boundary (which is unchanged) or the nested-container boundary (which is effectively nil) is the one we are relying on. ## What the podman spike then needed The podman implementation that replaced the Docker one on this branch turned up two more device-node blockers of the same shape as `/dev/net/tun` — Apple Container creates the node, but 0600 root:root: - **`/dev/fuse`** — blocks the `fuse-overlayfs` storage driver (`fuse: failed to open /dev/fuse: Permission denied`). Without it the only working driver is `vfs`, which copies whole layers per container. - **`/dev/net/tun`** — blocks `slirp4netns`, which rootless podman uses for the default bridge network. Both are fixed by `chmod 0666` as root inside the bottle, which needs no capability the bottle does not already hold. This is categorically different from the `CAP_SYS_ADMIN` requirement: it is a permission on a node that already exists, not an outer privilege grant. One design note worth recording: the agent-facing surface stays `docker` and `docker compose`, pointed at podman's Docker-compatible API socket via `DOCKER_HOST`. Setting `netns="host"` in `containers.conf` does *not* propagate through that compat API — stock `docker run` and compose files request bridge networking explicitly — so slirp4netns (and therefore the `/dev/net/tun` chmod) is required for ordinary compose files to work at all. Host networking remains available per-workload via `--network=host`. Verified working in a bottle with zero added capabilities: fuse-overlayfs storage, the compat API socket, `docker run` on both bridge and host networking, and published ports. ### Nested pulls collide with our own egress DLP The first live run got podman up and `docker compose` running, then failed on the image pull: ``` web Pulling initializing source docker://python:3.12-alpine: reading manifest ... StatusCode: 403, egress DLP: Generic Bearer JWT found in body ``` This is bot-bottle's own egress scanner, not a podman problem. The Docker registry auth flow carries a bearer JWT *by protocol*, and the `token_patterns` detector's `Generic Bearer JWT` rule (`Bearer\s+[A-Za-z0-9._\-]{50,}`) matches it on every pull. Any bottle that pulls images will hit this. The fix is per-route detector scoping, which the egress config already supports — drop `token_patterns` on the registry hosts and keep `known_secrets`: ```json {"host": "registry-1.docker.io", "dlp": {"outbound_detectors": ["known_secrets"]}} ``` That is the right trade rather than a grudging one: `known_secrets` matches the bottle's *actual* credential values, so real exfil through a registry host is still caught. `token_patterns` on a registry route only ever produces protocol noise. Worth generalising later: any manifest enabling `docker_access` needs this on its registry routes, so it probably belongs in a shared registry-route snippet rather than being copy-pasted per bottle. ### And then registry auth collides with the Authorization strip With DLP scoped, the pull failed differently: `unauthorized: authentication required`. This one is architectural. `egress_addon.py` strips agent-set `Authorization` unconditionally before forwarding — deliberately, so an agent cannot smuggle a credential out in a header the DLP detectors don't recognise. A route may carry gateway-injected auth instead, but only from a *static* token in an env var (`auth_scheme` + `token_env`). Docker registry auth doesn't fit that shape. The client fetches a short-lived, per-repository-scope bearer token from `auth.docker.io` and presents it to `registry-1.docker.io`. There is no static token to inject, and the token the client legitimately obtained is stripped. Measured inside a bottle, by hand: | Step | Result | | --- | --- | | Fetch token from `auth.docker.io` | 200, 5409-byte token body | | Manifest request **with** that valid token | 401 | | Manifest request with **no** Authorization | 401 — identical | A valid token behaves exactly like sending none, which is direct evidence the header never arrives. Any nested-container workflow that pulls from a registry is blocked on this, so it is not a detail that can be deferred: pulling base images is most of what nested containers are for. ### Registries that skip the token dance work today Not every registry needs the stripped header. Measured directly: | Registry | Manifest request with no `Authorization` | | --- | --- | | `quay.io` | 200 | | `mcr.microsoft.com` | 200 | | `registry.k8s.io` | 307 (redirect, no auth) | | `ghcr.io` | 401 | | `registry-1.docker.io` | 401 | So "just add the registry to the bottle config" genuinely works — for quay, MCR, registry.k8s.io, or any unauthenticated internal registry. Docker Hub and GHCR are the ones that need the strip resolved. The acceptance test uses quay for exactly this reason. Resolving it for Docker Hub means picking one of: 1. **Per-route opt-in to preserve client Authorization.** Smallest change. Note the compounding effect on exactly these routes: the DLP scoping above already removed `token_patterns` there, so a preserved-auth registry route is one where the agent may send bearer tokens that neither the strip nor the pattern detector inspects. `known_secrets` still applies, so the bottle's real credentials are still caught. 2. **A registry-aware gateway** that performs the token dance itself and injects the result. Preserves the invariant fully; materially more work, and it makes the gateway speak a specific registry protocol. 3. **Pre-seed images at provision time** (host-side `container image save` into podman storage), so bottles never pull at runtime. Preserves the invariant, and limits nested containers to pre-approved images — which fits the custody positioning, at the cost of no ad-hoc `docker pull`. 4. **Stop.** Nested containers are not supported on this backend. ### Podman 4.3.1 silently swallows container exit codes Debian bookworm — which the current agent base image is built on — ships podman 4.3.1. Through its Docker-compatible API, `docker run` returns 0 no matter what the container did: | Command | podman 4.3.1 | podman 5.4.2 | | --- | --- | --- | | `docker run … sh -c 'exit 7'` (compat API) | **0** | 7 | | `docker run … sh -c 'exit 0'` (compat API) | 0 | 0 | | `podman run … sh -c 'exit 7'` (native) | 7 | 7 | This is worse than a broken feature: every failing command an agent runs via `docker run` reports success. A test suite, a build step, or a CI script inside a bottle would pass while failing. It also silently defeated the acceptance test's egress-containment assertion, which is why that assertion now checks an in-band marker rather than an exit code. Podman 5.4.2 (Debian trixie) fixes it, but needs two packages that bookworm's podman does not: `passt` (podman 5's default network tool) and `nftables` (netavark shells out to `nft`; without it every run fails with `unable to upgrade to tcp, received 500`). With both installed, exit codes propagate correctly and the compat API behaves. The open question this leaves is where podman 5 comes from, since the agent base is bookworm-based: 1. **Move the agent images to Debian trixie.** Trixie is current stable. Correct, and the blast radius is every bottle, not just this feature. 2. **Drop the compat socket and use podman natively** (`podman-docker` provides a `docker` shim; compose comes from `podman-compose`). Native podman propagates exit codes correctly even on 4.3.1. Contained to this feature, at the cost of `docker compose` becoming `docker-compose`/`podman-compose`. 3. **Ship bookworm's podman 4.3.1 with the compat socket** — not viable. Silent false success is a correctness bug agents cannot see. ## Recommendation 1. Do not revive rootless Docker on this backend. This document is the record of why. 2. Nested containers, if wanted, come from podman under the single-mapping constraint — with the explicit understanding that the nested-container boundary carries no security weight. `root` in a nested container is the agent user outside it. 3. Nested containers are therefore a build/test convenience. The bottle remains the security boundary, exactly as it was.