cap:containers
August 3, 2026 · View on GitHub
A container-host tile runs rootless Podman (or Docker) inside its own
sandbox to spawn sub-containers — the substrate for "dev sandbox" tiles. It is
the third reserved sandbox capability, built on the exact pattern of
cap:net-admin (DECISIONS D18a): an admin-only grant that relaxes one tile's
sandbox profile, contained to that tile's own namespaces.
The problem
An ordinary tile backend is Unprivileged: all Linux capabilities dropped +
a seccomp block-list (backendDeny) that denies the whole mount family,
pivot_root, setns, mknod. Every container runtime needs exactly those to
build a container's rootfs / /proc / overlay, so a container runtime dies
immediately in an ordinary tile. unshare/clone are not blocked (nested
namespaces per se are allowed) — but a namespace you can't mount into is
useless for containers.
The capability
cap:containers (a reserved grant target, admin-only, internal/broker →
ContainersFor) flips the backend into a container-host profile
(Spec.Containers):
- Keep the user-namespace capabilities instead of dropping them — rootless
Podman as userns-root needs
CAP_SYS_ADMIN(nested user/mount/net namespaces, mounting overlay/tmpfs/proc per container), plus the file/uid caps. They are userns-scoped (rootless):CAP_SYS_ADMINin the tile's own userns cannot touch the host. - A minimal seccomp floor (
containerDeny) instead of the block-list: only the host-damaging syscalls — module (un)loading,kexec,reboot, swap, time/accounting. The mount family,pivot_root,setns,mknod,ptrace,bpfall stay allowed. This is deliberate: the tile's filter is inherited by every container it spawns and can only be tightened, never relaxed, by Podman's own per-container profile — so a wider floor here would silently breakstrace/gdb/mount/unshareinside the dev containers. Cross- container reach is prevented by per-container pid/mount/net namespaces (Podman's job), not by the floor.
Everything else is unchanged: the tile is still rootless, still in its own user+mount+pid+ipc+uts+net namespaces, still reaches nothing on the host and no other tile. A backend carries no workspace-secret masks (unlike a terminal — it only binds its own dir + granted resources), so no mount/read guard is needed.
Governance
- Admin-only to approve — a reserved target, never same-scope auto-granted; it lands pending in the grants panel on import.
- The policy ceiling's
xbin-capsdeny class strips it (an org that forbids system capabilities for its tiles forbids container hosts too). - Approving it restarts the tile's backend (spawn-materialized, like
cap:net-admin).
Why keeping mount + caps is still safe (workspace secrets)
Dropping backendDeny (which blocks mount/pivot_root/setns/mknod) and
keeping CAP_SYS_ADMIN sounds alarming, but the secret boundary for a backend
was never the seccomp block-list — it's the empty mount namespace. The
mount/read guards are terminal defenses (a terminal binds the whole workspace
read-only, then masks + guards the secrets). A backend — ordinary or
container-host — mounts only its own component dir (ro), its own run dir,
the gateway socket, its granted resources, and the overlay rootfs. .xbin,
data/, homes/, and every other tile are not in its namespace at all —
absent, not masked. There is nothing to reveal.
Keeping the caps + allowing mount cannot bring them back, because the tile is
rootless (CAP_SYS_ADMIN in its own userns only):
- can't mount host block devices (needs initial-userns root);
- can't bind-mount a path it can't see — the host tree is
MNT_DETACH'd afterpivot_root, so host paths resolve against the overlay root where they don't exist; - can't
setnsinto the host or another tile (needsCAP_SYS_ADMINin the target's owning userns); - sees only its own PIDs (fresh pidns); its mounts are
MS_PRIVATEand never propagate to the host.
So a container-host backend mounts strictly less than an admin terminal (no workspace), and is admin-only + ceiling-deniable on top. The load-bearing boundary is the kernel's user-namespace isolation — the same one that makes rootless containers and admin terminals safe; a userns kernel escape is the residual risk, not specific to this feature. (One tile-author caveat: the tile's own granted resources are bound rw — bind-mounting one into a container shares the tile's own data with that container, which is the author's choice, not a workspace-secret leak.)
What a container-host tile still needs (environmental, in the tile)
The capability lifts the kernel blocks; the tile's own setup supplies the
userland:
- Rootless Podman (daemonless; nests more cleanly than dockerd) +
uidmapfuse-overlayfs+pasta/slirp4netns.
- Nested subuid/subgid: the sandbox already maps the tile to a delegated
sub-uid RANGE (via
newuidmap, when the host has/etc/subuiddelegated to the xbind user — the same prerequisiteaptneeds). Podman inside needs its own/etc/subuid//etc/subgidentry so it can subdivide that range for its containers' users;setupseeds it. Without a delegated range the tile falls back to single-uid and multi-user container images break — same limitation xbind documents for its own sandbox. - Persistent storage: a
filesystemresource, with Podman's--rootpointed at it, so images/containers survive restarts and don't sit in the throwaway tmpfs upper (which is RAM). The resource stays encrypted: a cap:containers scope's filesystem resources mount in gocryptfs single-tenant mode (D43, docs/resources.md — ownership/mode/whiteouts virtualized into encrypted xattrs), which is what lets the layer store's 0555 dirs, sub-uid chowns and file caps round-trip on resenc. Prefer theoverlaydriver with fuse-overlayfs as mount program (the sandbox binds /dev/fuse for cap:containers): build steps write diffs.vfsis the fallback — robust but quadratic (each layer copies the whole chain), which crawls through an encrypted store. - Container networking: a bound
netinterface. Podman's rootless network (pasta/slirp4netns) NATs the containers' egress out through the tile's own egress relay.net=hostis the simplest, most robust option for a dev box (containers use the host network directly);net=internetkeeps them contained at some perf cost (a userspace stack over the gVisor relay).
Worked example
builtin-tiles/devbox — creates/removes Podman containers and exposes their
shells over SSH: an exposes ssh stream port (→ a host TCP port via the
runtime L4 relay) fronted by an in-tile Go SSH server that authenticates the
owner's public key and routes ssh -p<port> <container>@host to
podman exec -it <container> (plans/ingress.md for the stream plane).
Decisions
- CT-1 — container support is an opt-in reserved capability
(
cap:containers), admin-only, ceiling-strippable — never on by default. The blast radius is one tile's sandbox, contained to its own namespaces. - CT-2 — the sandbox provides only the kernel relaxation (caps +
minimal seccomp floor); the runtime, subuid seeding, storage, and networking
are the tile's
setup/manifest, so xbind stays a minimal middlebox and the container runtime is a swappable tile concern (podman today, docker/ runsc possible). - CT-3 — the seccomp floor is minimal on purpose (host-damage only), because it is inherited by every nested container; per-container hardening is Podman's profile, layered on top.
Touchpoints
internal/sandbox (Spec.Containers, the init branch — caps, seccomp floor,
cgroup2 view at /sys/fs/cgroup) · internal/runner (ContainerCaps hook) ·
internal/broker (ContainersCap/ContainersFor, ceiling classification,
grant restart + store remount, resSingleTenant) · internal/resenc
(single-tenant Ensure + support probe) · hack/gocryptfs-patches/ (the
gocryptfs single-tenant mode itself, D43) · cmd/xbind (wire
run.ContainerCaps) · builtin-tiles/devbox (the worked example) ·
workspace-template/AGENTS.md (how to build a container-host tile).