Run Docker/Compose inside bottles without bypassing isolation #392

Open
opened 2026-07-16 22:14:33 -04:00 by didericis-claude · 17 comments
Collaborator

Problem

Agent tasks routinely involve containers — docker compose up to verify a scaffolded stack, a throwaway database for a test run, building an image to check the Dockerfile works. Bottles today have no way to do any of that, so those tasks either fail or get done outside the bottle, which defeats the point.

The core need: run containers inside a bottle, without weakening what a bottle is.

Status

Spiked in #449 (closed unmerged; branch spike/rootless-docker-macos is pushed). Findings in this comment and docs/research/rootless-docker-in-apple-container-spike.md.

Short version:

  • Rootless Docker is impossible on the macOS backend. Apple Container's capability bounding set omits CAP_SYS_ADMIN, which the kernel requires to write a multi-range uid_map via newuidmap. No packaging fix exists; the only way through is granting the bottle CAP_SYS_ADMIN, which is close to root in practical terms.
  • Rootless podman works, with zero added capabilities, because with no subordinate UID range it falls back to a single-UID self-mapping. Verified live: docker compose up -d --wait pulls an image, starts a service, and serves from the workspace inside a registered bottle. The agent still types docker and docker compose.

So the mechanism question looks settled: guest-local rootless podman, exposing a Docker-compatible socket.

Hard non-goal

Do not mount the host Docker socket (/var/run/docker.sock), on any backend.

Open questions

Is docker_access: true the right interface — or an interface we want at all?
The flag is hypothetical. It exists in the spike branch because the spike needed some switch, not because we decided bottles should have one. Arguments both ways:

  • Against a flag: podman needs no privilege grant, so there's nothing dangerous to gate. The tooling could just be present in agent images, and the flag is one more thing to configure and document.
  • For a flag: it still gates real costs — ~100MB of image weight, a resident service per bottle, and relaxing the modes on /dev/fuse and /dev/net/tun (which the host must do as root, and which the majority of bottles that never touch containers shouldn't get).

If we keep a flag, docker_access is the wrong name — it no longer implies Docker and grants no "access" to anything on the host. Something like nested_containers describes what it does.

We'll experiment before committing to either.

Other open questions:

  • Are nested containers useful enough to ship given current network limitations? Right now they can't reach out — DNS inside a nested container fails entirely. Compose can run images and serve locally; a service that needs to call out needs proxy env, the CA, and DNS wired in first.
  • Registry reach. Docker Hub and GHCR are blocked because the egress proxy strips agent-set Authorization and both need a client-fetched per-scope bearer token. quay.io, mcr.microsoft.com, registry.k8s.io, and unauthenticated internal registries work today. Options: a gateway that performs the registry token dance itself, a per-route opt-in to preserve client auth, or pre-seeding images at provision.
  • Nested containers run within the bottle boundary, not inside a new one. The single-UID mapping means root inside a nested container is the agent user outside it. This is by design: the goal is to run project Docker dependencies inside an existing bottle, not to add another isolation layer. Should be stated wherever the feature is documented — it's for build/test use, not sandboxing untrusted code.

Blocked on

  • #451 — base bump to trixie. Bookworm's podman 4.3.1 silently returns exit code 0 for every docker run through the compat API, so agent scripts would see failures as successes.
  • A decision on the registry-auth strip (above).
  • docs/research/egress-proxy-oom-on-large-downloads.md — a large pull OOM-kills the shared egress proxy and takes egress down for every bottle. Pre-existing, but pulling images makes it easy to hit.

Backend scope

The working implementation is on macos-container, not the docker backend this issue originally scoped itself to. Worth deciding whether the docker and firecracker backends need an equivalent or whether this is macOS-only for now.

## Problem Agent tasks routinely involve containers — `docker compose up` to verify a scaffolded stack, a throwaway database for a test run, building an image to check the Dockerfile works. Bottles today have no way to do any of that, so those tasks either fail or get done outside the bottle, which defeats the point. **The core need: run containers inside a bottle, without weakening what a bottle is.** ## Status Spiked in #449 (closed unmerged; branch `spike/rootless-docker-macos` is pushed). Findings in [this comment](https://gitea.dideric.is/didericis/bot-bottle/issues/392#issuecomment-4797) and `docs/research/rootless-docker-in-apple-container-spike.md`. Short version: - **Rootless Docker is impossible** on the macOS backend. Apple Container's capability bounding set omits `CAP_SYS_ADMIN`, which the kernel requires to write a multi-range `uid_map` via `newuidmap`. No packaging fix exists; the only way through is granting the bottle `CAP_SYS_ADMIN`, which is close to root in practical terms. - **Rootless podman works**, with zero added capabilities, because with no subordinate UID range it falls back to a single-UID self-mapping. Verified live: `docker compose up -d --wait` pulls an image, starts a service, and serves from the workspace inside a registered bottle. The agent still types `docker` and `docker compose`. So the mechanism question looks settled: **guest-local rootless podman**, exposing a Docker-compatible socket. ## Hard non-goal **Do not mount the host Docker socket** (`/var/run/docker.sock`), on any backend. ## Open questions **Is `docker_access: true` the right interface — or an interface we want at all?** The flag is hypothetical. It exists in the spike branch because the spike needed *some* switch, not because we decided bottles should have one. Arguments both ways: - *Against a flag:* podman needs no privilege grant, so there's nothing dangerous to gate. The tooling could just be present in agent images, and the flag is one more thing to configure and document. - *For a flag:* it still gates real costs — ~100MB of image weight, a resident service per bottle, and relaxing the modes on `/dev/fuse` and `/dev/net/tun` (which the host must do as root, and which the majority of bottles that never touch containers shouldn't get). If we keep a flag, `docker_access` is the wrong name — it no longer implies Docker and grants no "access" to anything on the host. Something like `nested_containers` describes what it does. **We'll experiment before committing to either.** Other open questions: - **Are nested containers useful enough to ship given current network limitations?** Right now they can't reach out — DNS inside a nested container fails entirely. Compose can run images and serve locally; a service that needs to call out needs proxy env, the CA, and DNS wired in first. - **Registry reach.** Docker Hub and GHCR are blocked because the egress proxy strips agent-set `Authorization` and both need a client-fetched per-scope bearer token. quay.io, mcr.microsoft.com, registry.k8s.io, and unauthenticated internal registries work today. Options: a gateway that performs the registry token dance itself, a per-route opt-in to preserve client auth, or pre-seeding images at provision. - **Nested containers run within the bottle boundary, not inside a new one.** The single-UID mapping means `root` inside a nested container is the agent user outside it. This is by design: the goal is to run project Docker dependencies inside an existing bottle, not to add another isolation layer. Should be stated wherever the feature is documented — it's for build/test use, not sandboxing untrusted code. ## Blocked on - #451 — base bump to trixie. Bookworm's podman 4.3.1 silently returns exit code `0` for every `docker run` through the compat API, so agent scripts would see failures as successes. - A decision on the registry-auth strip (above). - `docs/research/egress-proxy-oom-on-large-downloads.md` — a large pull OOM-kills the shared egress proxy and takes egress down for every bottle. Pre-existing, but pulling images makes it easy to hit. ## Backend scope The working implementation is on `macos-container`, not the `docker` backend this issue originally scoped itself to. Worth deciding whether the docker and firecracker backends need an equivalent or whether this is macOS-only for now.
didericis added the Kind/Feature
Priority
High
2
labels 2026-07-16 22:17:34 -04:00
Owner

@didericis-codex reasses macos docker access story now (macos containers are live)

@didericis-codex reasses macos docker access story now (macos containers are live)
Collaborator

Reassessed now that macos-container is live.

The socket mount is technically plausible now: Apple container supports host bind mounts, and its documented --ssh behavior is itself a host Unix-socket mount. We could add the equivalent of:

--mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock

However, I don't think that gives us a sound macOS Docker-access story:

  • The daemon would be Docker Desktop's daemon, outside the bottle. Possession of that socket lets the agent start privileged containers, mount arbitrary macOS paths accepted by Docker Desktop, and interfere with bot-bottle's Docker-managed infrastructure. In practice it collapses the sandbox boundary, not merely adds an in-bottle capability.
  • The current macOS backend copies/provisions the working tree inside the Apple-container guest. Compose bind paths such as .:/app are sent to Docker Desktop and resolved in the daemon's host context, not in the guest, so they will be missing or point at a stale/different host tree.
  • Published ports land in Docker Desktop's host/VM context, not the agent guest's localhost. Common docker compose up && curl localhost:... verification flows therefore still would not behave like native Docker access.

So my recommendation is:

  1. Keep #392 scoped to the legacy Docker backend's explicitly unsafe socket-sharing mode.
  2. On macos-container, reject docker_access: true with a clear backend-specific error for now rather than silently providing misleading/boundary-breaking behavior.
  3. Track macOS Docker access separately as a guest-local DinD spike: run a daemon inside the bottle VM (likely requiring additional capabilities, storage-driver/kernel validation, and explicit CPU/memory sizing). That preserves guest path and localhost semantics and keeps Docker's effects inside the bottle VM.

If guest-local DinD proves infeasible with Apple's current kernel/runtime, a narrowly scoped host-side Docker broker could be considered, but it would need path/context translation and strict lifecycle/API restrictions; raw Docker Desktop socket forwarding should not be the fallback.

Reassessed now that `macos-container` is live. The socket mount is technically plausible now: Apple `container` supports host bind mounts, and its documented `--ssh` behavior is itself a host Unix-socket mount. We could add the equivalent of: ``` --mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock ``` However, I don't think that gives us a sound macOS Docker-access story: - The daemon would be Docker Desktop's daemon, outside the bottle. Possession of that socket lets the agent start privileged containers, mount arbitrary macOS paths accepted by Docker Desktop, and interfere with bot-bottle's Docker-managed infrastructure. In practice it collapses the sandbox boundary, not merely adds an in-bottle capability. - The current macOS backend copies/provisions the working tree inside the Apple-container guest. Compose bind paths such as `.:/app` are sent to Docker Desktop and resolved in the daemon's host context, not in the guest, so they will be missing or point at a stale/different host tree. - Published ports land in Docker Desktop's host/VM context, not the agent guest's localhost. Common `docker compose up && curl localhost:...` verification flows therefore still would not behave like native Docker access. So my recommendation is: 1. Keep #392 scoped to the legacy Docker backend's explicitly unsafe socket-sharing mode. 2. On `macos-container`, reject `docker_access: true` with a clear backend-specific error for now rather than silently providing misleading/boundary-breaking behavior. 3. Track macOS Docker access separately as a guest-local DinD spike: run a daemon inside the bottle VM (likely requiring additional capabilities, storage-driver/kernel validation, and explicit CPU/memory sizing). That preserves guest path and localhost semantics and keeps Docker's effects inside the bottle VM. If guest-local DinD proves infeasible with Apple's current kernel/runtime, a narrowly scoped host-side Docker broker could be considered, but it would need path/context translation and strict lifecycle/API restrictions; raw Docker Desktop socket forwarding should not be the fallback.
Owner

@didericis-codex do a quick research spike here on running docker within macos containers/see if anyone has done it and made a guide yet

@didericis-codex do a quick research spike here on running docker within macos containers/see if anyone has done it and made a guide yet
Collaborator

Quick research spike: yes, someone has now documented and tested real Docker Engine + Compose inside Apple container, and the result is promising:

  • Apple container で docker compose を動かす (published 2026-06-19) reports a working setup on macOS 26.5 / Apple Silicon / container 1.0.0. It uses a persistent container machine built from Ubuntu + systemd, installs docker.io and docker-compose-v2, and runs the real daemon inside that VM. The author verifies docker run, published ports, Compose bridge networking, service-name DNS, and daemon restart.
  • The key blocker is the default Apple kernel: it lacks netfilter, so Docker bridge/NAT/Compose networking does not work. Their fix is container system kernel set --recommended --force, which installs Apple's recommended Kata kernel with the needed netfilter support. This matches Apple's own Containerization documentation, which recommends the Kata kernel as a container-oriented kernel with the required configuration.
  • There is also a new docker-for-apple-container project translating a subset of Docker/Compose commands to Apple's host CLI. Interesting, but it is not a fit for an untrusted bottle: the shim needs access to the host container CLI/API, supports only a subset, and would let the agent orchestrate resources outside its guest.

Verdict: guest-local Docker is demonstrably feasible, but the proven recipe uses container machine, not an ordinary container run container. For bot-bottle that likely means a dedicated docker_access launch variant rather than a small argv flag:

  1. require/check the recommended netfilter-capable kernel;
  2. build a systemd machine image containing Docker + Compose;
  3. create one ephemeral machine per Docker-enabled bottle and run/provision the agent within it;
  4. preserve our gateway-only egress and identity guarantees (the largest unknown—machines may not expose the same custom-network/capability controls as container run);
  5. tear down the whole machine so Docker images, volumes, networks, and children disappear with the bottle.

The guide removes the main feasibility concern. The remaining work is a small live-Mac spike against bot-bottle's networking: create an ephemeral machine on/behind the shared gateway, verify proxied pulls/builds, docker compose up, service DNS, guest-local bind mounts, curl localhost:<published-port>, and teardown. I would pursue that before either socket forwarding or a host broker.

Quick research spike: yes, someone has now documented and tested real Docker Engine + Compose inside Apple `container`, and the result is promising: - [Apple container で docker compose を動かす](https://zenn.dev/7kaji/articles/370a8dd7f678d1) (published 2026-06-19) reports a working setup on macOS 26.5 / Apple Silicon / `container` 1.0.0. It uses a persistent `container machine` built from Ubuntu + systemd, installs `docker.io` and `docker-compose-v2`, and runs the real daemon inside that VM. The author verifies `docker run`, published ports, Compose bridge networking, service-name DNS, and daemon restart. - The key blocker is the default Apple kernel: it lacks netfilter, so Docker bridge/NAT/Compose networking does not work. Their fix is `container system kernel set --recommended --force`, which installs Apple's recommended Kata kernel with the needed netfilter support. This matches Apple's own [Containerization documentation](https://github.com/apple/containerization#linux-kernel), which recommends the Kata kernel as a container-oriented kernel with the required configuration. - There is also a new [`docker-for-apple-container`](https://github.com/appautomaton/docker-for-apple-container) project translating a subset of Docker/Compose commands to Apple's host CLI. Interesting, but it is not a fit for an untrusted bottle: the shim needs access to the host `container` CLI/API, supports only a subset, and would let the agent orchestrate resources outside its guest. Verdict: guest-local Docker is demonstrably feasible, but the proven recipe uses **`container machine`**, not an ordinary `container run` container. For bot-bottle that likely means a dedicated `docker_access` launch variant rather than a small argv flag: 1. require/check the recommended netfilter-capable kernel; 2. build a systemd machine image containing Docker + Compose; 3. create one ephemeral machine per Docker-enabled bottle and run/provision the agent within it; 4. preserve our gateway-only egress and identity guarantees (the largest unknown—machines may not expose the same custom-network/capability controls as `container run`); 5. tear down the whole machine so Docker images, volumes, networks, and children disappear with the bottle. The guide removes the main feasibility concern. The remaining work is a small live-Mac spike against bot-bottle's networking: create an ephemeral machine on/behind the shared gateway, verify proxied pulls/builds, `docker compose up`, service DNS, guest-local bind mounts, `curl localhost:<published-port>`, and teardown. I would pursue that before either socket forwarding or a host broker.
Owner

@didericis-codex before implementing that spike, evaluate the security considerations. Also weigh pros and cons of running container machine by default (if it provides more capabilities without significant security boundary concerns and isn't significantly slower/bigger when spinning up images might we worth using by default)

@didericis-codex before implementing that spike, evaluate the security considerations. Also weigh pros and cons of running `container machine` by default (if it provides more capabilities without significant security boundary concerns and isn't significantly slower/bigger when spinning up images might we worth using by default)
Collaborator

Agreed. Before touching the live-Mac spike, I’ll treat this as an architecture/security decision rather than only a Docker feasibility test.

I’ll compare the current container run model with one ephemeral container machine per bottle across:

  • isolation boundary and privilege surface (machine control, kernel choice, systemd/Docker daemon, nested containers, device and filesystem exposure);
  • whether our source-IP attribution, identity token, gateway-only egress, capability dropping, and teardown invariants can still be enforced;
  • lifecycle and cleanup failure modes, including orphaned machines, Docker children, images, volumes, and networks;
  • startup latency, idle/runtime memory, disk/image duplication and cache behavior;
  • feature gains beyond Docker access (systemd, fuller kernel/networking, background services and closer VM semantics);
  • operational complexity and compatibility with the existing Firecracker backend.

I’ll explicitly evaluate both designs: machines only for docker_access bottles, and container machine as the default macOS backend. If the machine model preserves the security boundary and the measured startup/resource cost is modest, making it the default may be cleaner than maintaining two substantially different macOS launch paths. If it weakens attribution/egress guarantees or carries material per-bottle overhead, it should remain an opt-in variant.

The next deliverable here will be that threat-model/tradeoff assessment, with measurements where possible; the implementation spike comes after it.

Agreed. Before touching the live-Mac spike, I’ll treat this as an architecture/security decision rather than only a Docker feasibility test. I’ll compare the current `container run` model with one ephemeral `container machine` per bottle across: - isolation boundary and privilege surface (machine control, kernel choice, systemd/Docker daemon, nested containers, device and filesystem exposure); - whether our source-IP attribution, identity token, gateway-only egress, capability dropping, and teardown invariants can still be enforced; - lifecycle and cleanup failure modes, including orphaned machines, Docker children, images, volumes, and networks; - startup latency, idle/runtime memory, disk/image duplication and cache behavior; - feature gains beyond Docker access (systemd, fuller kernel/networking, background services and closer VM semantics); - operational complexity and compatibility with the existing Firecracker backend. I’ll explicitly evaluate both designs: machines only for `docker_access` bottles, and `container machine` as the default macOS backend. If the machine model preserves the security boundary and the measured startup/resource cost is modest, making it the default may be cleaner than maintaining two substantially different macOS launch paths. If it weakens attribution/egress guarantees or carries material per-bottle overhead, it should remain an opt-in variant. The next deliverable here will be that threat-model/tradeoff assessment, with measurements where possible; the implementation spike comes after it.
Collaborator

Security/architecture assessment completed. My conclusion is do not make container machine the default, and do not implement the Docker spike until we can prove host-enforced network confinement for machines.

What changes—and what does not

Both ordinary Apple container run workloads and container machines already have a Virtualization.framework VM boundary, so moving to a machine does not obviously add a stronger host-isolation boundary. The gain is a fuller, persistent Linux environment: /sbin/init, systemd services, mutable persistent storage, and a kernel suitable for Docker networking.

That fuller environment materially increases the guest privilege surface. An agent able to use a guest-local Docker daemon should be treated as root-equivalent inside its entire machine: it can launch privileged children, mount the machine filesystem, alter guest routing/firewall state, create raw sockets, and retain state in Docker images/volumes. This is acceptable only if the outer machine remains a disposable VM with no sensitive host mounts and host-enforced egress.

It is still much safer than forwarding Docker Desktop's socket: guest Docker authority ends at the machine boundary and guest paths/localhost have the expected semantics. It must not receive the host Docker socket or host container control API.

Current blockers against bot-bottle's invariants

The current macOS backend launches an agent with:

  • --network <bot-bottle-host-only-network>;
  • --cap-drop CAP_NET_RAW;
  • no host workspace bind mount—the workspace is copied into the guest;
  • registration by DHCP source IP plus a per-bottle identity token;
  • cleanup through enumerated bot-bottle-* containers and networks.

Those controls are not available on the documented container machine create interface. Apple's current command reference exposes CPU, memory, kernel, nested virtualization, and home-mount, but no custom network, DNS, mount list, capability drop, read-only rootfs, or labels. See Apple's machine command reference.

Two defaults are unsafe for bot-bottle:

  1. home-mount defaults to rw, exposing the operator's entire macOS home. We would have to require --home-mount none; read-only is still excessive because it exposes credentials and source.
  2. Memory defaults to half of host RAM. We would need an explicit per-bottle limit.

More importantly, without custom-network attachment, a machine cannot currently be placed on the backend's gateway-only network. Docker/root inside the machine can also use raw sockets and change its guest network state, so the present “drop NET_RAW, source IP is unforgeable” proof does not carry over. The identity token is useful defense in depth, but the architecture explicitly requires the source-IP invariant too; it is not a substitute for host-enforced confinement.

Putting the gateway inside the same machine would not solve this: an agent with Docker/root-equivalent control could disable or bypass it. A safe design needs the outer host/runtime to enforce that the machine has no route except to a separately trusted gateway.

Default-machine tradeoff

Benefits

  • Real systemd and long-running services.
  • Working guest-local Docker/Compose semantics: bind paths, service DNS, published ports, and localhost all live in the same guest.
  • A broader Linux development environment, potentially useful for databases and background daemons beyond Docker.
  • One lifecycle object can contain the agent plus all Docker children; deleting the machine should delete its persistent storage. Apple documents machine rm as stopping and deleting the machine, and the machine guide describes OCI-based machine images and init support.

Costs and regressions

  • No security-boundary improvement over today's per-container VM, but substantially more guest authority and attack surface.
  • Current networking, capability, labeling/enumeration, provisioning, commit, and exec code cannot be reused directly.
  • Default read-write home sharing is dangerous and must always be disabled.
  • Persistent machines create new orphan/reconciliation cases; current cleanup only enumerates container list, not container machine ls.
  • A systemd + Docker image, writable machine disk, and Docker layer/volume store are inherently larger than the current agent image plus minimal init.
  • Apple calls machines “fast, lightweight,” but publishes no comparative cold-start/RSS/disk benchmark. The only defensible quantitative default from the docs is memory = half the host, which is unacceptable without overriding it.
  • Making every bottle a machine would impose these costs on agents that do not need Docker or systemd and would replace a backend that is already working and tested.

Recommendation

  1. Keep container run as the macOS default. It has the smaller privilege surface and already satisfies bot-bottle's tested attribution/egress model.
  2. Do not raw-forward Docker Desktop's socket.
  3. Treat container machine as a future opt-in implementation for docker_access, not a default, and gate even the live spike on one prerequisite: demonstrate a host-enforced host-only/custom-network path for machines (or an equivalent outer firewall design that the guest cannot change).
  4. If that becomes possible, create machines with home-mount=none, explicit CPU/RAM/disk limits, no nested virtualization, no host control sockets, a pinned netfilter-capable kernel, and one ephemeral machine per bottle.
  5. Add machine-specific orphan reconciliation: enumerate by a reserved prefix, unregister policy first/fail closed, stop, delete, then verify the machine and its disk are gone.
  6. Run the existing sandbox-escape suite unchanged against the machine variant before calling it supported, plus Docker-specific tests for privileged-child containment, compose DNS, bind paths, published localhost ports, restart, and teardown.

Measurements still required on the live Mac

I cannot produce honest startup/RSS/disk numbers from this Linux runner because Apple container requires Apple Silicon/macOS. If the networking prerequisite is resolved, the spike should record, for both container run and container machine, ten warm and cold samples of create-to-first-exec, peak and settled host memory, initial disk allocation, post-Docker-install disk, post-compose up disk, and deletion time/residual bytes.

Decision for now: machines only remain worth exploring for explicit Docker access; they are not suitable as the default backend with the current CLI/security controls.

Security/architecture assessment completed. My conclusion is **do not make `container machine` the default, and do not implement the Docker spike until we can prove host-enforced network confinement for machines**. ## What changes—and what does not Both ordinary Apple `container run` workloads and container machines already have a Virtualization.framework VM boundary, so moving to a machine does not obviously add a stronger host-isolation boundary. The gain is a fuller, persistent Linux environment: `/sbin/init`, systemd services, mutable persistent storage, and a kernel suitable for Docker networking. That fuller environment materially increases the guest privilege surface. An agent able to use a guest-local Docker daemon should be treated as root-equivalent **inside its entire machine**: it can launch privileged children, mount the machine filesystem, alter guest routing/firewall state, create raw sockets, and retain state in Docker images/volumes. This is acceptable only if the *outer* machine remains a disposable VM with no sensitive host mounts and host-enforced egress. It is still much safer than forwarding Docker Desktop's socket: guest Docker authority ends at the machine boundary and guest paths/localhost have the expected semantics. It must not receive the host Docker socket or host `container` control API. ## Current blockers against bot-bottle's invariants The current macOS backend launches an agent with: - `--network <bot-bottle-host-only-network>`; - `--cap-drop CAP_NET_RAW`; - no host workspace bind mount—the workspace is copied into the guest; - registration by DHCP source IP plus a per-bottle identity token; - cleanup through enumerated `bot-bottle-*` containers and networks. Those controls are not available on the documented `container machine create` interface. Apple's current command reference exposes CPU, memory, kernel, nested virtualization, and `home-mount`, but **no custom network, DNS, mount list, capability drop, read-only rootfs, or labels**. See [Apple's machine command reference](https://github.com/apple/container/blob/main/docs/command-reference.md#container-machine-create). Two defaults are unsafe for bot-bottle: 1. `home-mount` defaults to `rw`, exposing the operator's entire macOS home. We would have to require `--home-mount none`; read-only is still excessive because it exposes credentials and source. 2. Memory defaults to half of host RAM. We would need an explicit per-bottle limit. More importantly, without custom-network attachment, a machine cannot currently be placed on the backend's gateway-only network. Docker/root inside the machine can also use raw sockets and change its guest network state, so the present “drop NET_RAW, source IP is unforgeable” proof does not carry over. The identity token is useful defense in depth, but the architecture explicitly requires the source-IP invariant too; it is not a substitute for host-enforced confinement. Putting the gateway inside the same machine would not solve this: an agent with Docker/root-equivalent control could disable or bypass it. A safe design needs the outer host/runtime to enforce that the machine has no route except to a separately trusted gateway. ## Default-machine tradeoff ### Benefits - Real systemd and long-running services. - Working guest-local Docker/Compose semantics: bind paths, service DNS, published ports, and `localhost` all live in the same guest. - A broader Linux development environment, potentially useful for databases and background daemons beyond Docker. - One lifecycle object can contain the agent plus all Docker children; deleting the machine should delete its persistent storage. Apple documents `machine rm` as stopping and deleting the machine, and [the machine guide](https://github.com/apple/container/blob/main/docs/container-machine.md) describes OCI-based machine images and init support. ### Costs and regressions - No security-boundary improvement over today's per-container VM, but substantially more guest authority and attack surface. - Current networking, capability, labeling/enumeration, provisioning, commit, and exec code cannot be reused directly. - Default read-write home sharing is dangerous and must always be disabled. - Persistent machines create new orphan/reconciliation cases; current cleanup only enumerates `container list`, not `container machine ls`. - A systemd + Docker image, writable machine disk, and Docker layer/volume store are inherently larger than the current agent image plus minimal init. - Apple calls machines “fast, lightweight,” but publishes no comparative cold-start/RSS/disk benchmark. The only defensible quantitative default from the docs is memory = half the host, which is unacceptable without overriding it. - Making every bottle a machine would impose these costs on agents that do not need Docker or systemd and would replace a backend that is already working and tested. ## Recommendation 1. **Keep `container run` as the macOS default.** It has the smaller privilege surface and already satisfies bot-bottle's tested attribution/egress model. 2. **Do not raw-forward Docker Desktop's socket.** 3. Treat `container machine` as a future opt-in implementation for `docker_access`, not a default, and gate even the live spike on one prerequisite: demonstrate a host-enforced host-only/custom-network path for machines (or an equivalent outer firewall design that the guest cannot change). 4. If that becomes possible, create machines with `home-mount=none`, explicit CPU/RAM/disk limits, no nested virtualization, no host control sockets, a pinned netfilter-capable kernel, and one ephemeral machine per bottle. 5. Add machine-specific orphan reconciliation: enumerate by a reserved prefix, unregister policy first/fail closed, stop, delete, then verify the machine and its disk are gone. 6. Run the existing sandbox-escape suite unchanged against the machine variant before calling it supported, plus Docker-specific tests for privileged-child containment, compose DNS, bind paths, published localhost ports, restart, and teardown. ## Measurements still required on the live Mac I cannot produce honest startup/RSS/disk numbers from this Linux runner because Apple `container` requires Apple Silicon/macOS. If the networking prerequisite is resolved, the spike should record, for both `container run` and `container machine`, ten warm and cold samples of create-to-first-exec, peak and settled host memory, initial disk allocation, post-Docker-install disk, post-`compose up` disk, and deletion time/residual bytes. Decision for now: **machines only remain worth exploring for explicit Docker access; they are not suitable as the default backend with the current CLI/security controls.**
Owner

@didericis-codex Is there a way we could create a container machine just for running docker, and then attach the docker socket from just that machine to a running container? That would theoretically mean agent bottles can keep their current network boundaries and docker could stay container, correct? Only issue there is how to secure the network for the machine while also making it accessible to the container, which gets into the same network escape issues, so maybe that doesn't solve anything.

@didericis-codex Is there a way we could create a container machine *just* for running docker, and then attach the docker socket *from just that machine* to a running container? That would theoretically mean agent bottles can keep their current network boundaries and docker could stay container, correct? Only issue there is how to secure the network for the machine while also making it accessible to the container, which gets into the same network escape issues, so maybe that doesn't solve anything.
Collaborator

Yes—that is a materially better architecture than turning the agent itself into a machine, but it only solves part of the problem.

The shape would be:

agent bottle VM (`container run`)
        |
        | authenticated Docker API
        v
per-bottle Docker machine VM
        |
        v
Docker children

The agent keeps its current --network and CAP_NET_RAW restrictions. Possession of the Docker socket makes it root-equivalent only inside the disposable Docker machine, not inside the agent VM or on macOS. That preserves the important host boundary and avoids exposing Docker Desktop or the host container API.

I would not literally mount the machine's Unix socket into the agent, though. These are separate VMs, and the documented machine interface has no socket-publishing equivalent. It would likely need an authenticated TCP Docker endpoint (mTLS, unique per bottle) reachable over a deliberately constructed network or through a narrow host-side relay.

Your concern is the remaining hard blocker: the Docker machine must itself be host-confined. Every Docker child can otherwise egress directly, bypassing bot-bottle's gateway and DLP. Because the agent controls the daemon, we must assume it can run privileged children and modify all networking inside that machine; confinement therefore has to be outside the machine. The current container machine CLI still does not expose the custom-network attachment we use for agent bottles.

There are also two semantic gaps:

  • Compose bind mounts are resolved by the remote daemon inside the Docker machine, not inside the agent bottle. We would need an explicit workspace synchronization/share mechanism with careful write semantics.
  • Published ports appear on the Docker machine, not the agent's localhost. We would need discovery and forwarding, or document remote-daemon semantics instead of pretending it is local Docker.

So: yes, this preserves the existing agent boundary and is the best machine-based direction, but it is closer to a per-bottle remote Docker builder/service than ordinary socket sharing. It becomes viable if we can provide:

  1. host-enforced gateway-only networking for the Docker machine;
  2. a per-bottle authenticated daemon endpoint inaccessible to other bottles;
  3. workspace synchronization;
  4. controlled port forwarding;
  5. deletion/reconciliation of the entire worker machine and its storage.

If Apple adds machine network selection—or we can enforce the equivalent safely at the host layer—I would spike this design before a machine-as-agent design. Until then, it runs into the same outer-network escape problem you identified.

Yes—that is a materially better architecture than turning the agent itself into a machine, but it only solves part of the problem. The shape would be: ``` agent bottle VM (`container run`) | | authenticated Docker API v per-bottle Docker machine VM | v Docker children ``` The agent keeps its current `--network` and `CAP_NET_RAW` restrictions. Possession of the Docker socket makes it root-equivalent only inside the disposable Docker machine, not inside the agent VM or on macOS. That preserves the important host boundary and avoids exposing Docker Desktop or the host `container` API. I would not literally mount the machine's Unix socket into the agent, though. These are separate VMs, and the documented machine interface has no socket-publishing equivalent. It would likely need an authenticated TCP Docker endpoint (mTLS, unique per bottle) reachable over a deliberately constructed network or through a narrow host-side relay. Your concern is the remaining hard blocker: the Docker machine must itself be host-confined. Every Docker child can otherwise egress directly, bypassing bot-bottle's gateway and DLP. Because the agent controls the daemon, we must assume it can run privileged children and modify all networking inside that machine; confinement therefore has to be outside the machine. The current `container machine` CLI still does not expose the custom-network attachment we use for agent bottles. There are also two semantic gaps: - Compose bind mounts are resolved by the remote daemon inside the Docker machine, not inside the agent bottle. We would need an explicit workspace synchronization/share mechanism with careful write semantics. - Published ports appear on the Docker machine, not the agent's `localhost`. We would need discovery and forwarding, or document remote-daemon semantics instead of pretending it is local Docker. So: **yes, this preserves the existing agent boundary and is the best machine-based direction**, but it is closer to a per-bottle remote Docker builder/service than ordinary socket sharing. It becomes viable if we can provide: 1. host-enforced gateway-only networking for the Docker machine; 2. a per-bottle authenticated daemon endpoint inaccessible to other bottles; 3. workspace synchronization; 4. controlled port forwarding; 5. deletion/reconciliation of the entire worker machine and its storage. If Apple adds machine network selection—or we can enforce the equivalent safely at the host layer—I would spike this design before a machine-as-agent design. Until then, it runs into the same outer-network escape problem you identified.
Owner

@didericis-codex do a research spike on typical solutions when needing to manage docker containers from within another container. I'm guessing connecting to a remote docker instance is the typical solution. For context, main reason this is high priority is that at least for me, docker is an essential tool for most projects/bot-bottle is nerfed without them. This is already affecting my ability to do a proper containerized build of the platform repo. Alternatively could look into other container solutions we could run from within the macos container that might not have the same issues. But we want some equivalent to docker compose we can run within the agent container without bypassing our secure boundaries.

@didericis-codex do a research spike on typical solutions when needing to manage docker containers from within another container. I'm guessing connecting to a remote docker instance is the typical solution. For context, main reason this is high priority is that at least for me, docker is an essential tool for most projects/bot-bottle is nerfed without them. This is already affecting my ability to do a proper containerized build of the platform repo. Alternatively could look into other container solutions we could run from within the macos container that might not have the same issues. But we want some equivalent to `docker compose` we can run within the agent container without bypassing our secure boundaries.
Collaborator

Understood, and that priority changes the weighting: this is not an occasional privileged escape hatch; Docker/Compose-equivalent project builds are a core bottle capability.

I’ll research the established patterns rather than assuming remote Docker is the answer. The comparison will cover:

  • remote Docker daemon/worker per bottle, including mTLS, workspace transfer, BuildKit contexts, Compose bind mounts, published ports, lifecycle, and network confinement;
  • Docker-outside-of-Docker/socket brokers that expose a restricted API instead of a raw host socket;
  • rootless Docker or Podman inside the existing Apple-container VM, including required kernel features, user namespaces, networking, Compose compatibility, and whether rootless networking can be forced through the bottle gateway;
  • daemonless builders such as BuildKit/buildctl, Buildah, Kaniko, and nerdctl/containerd where they cover build workflows, while distinguishing them from the broader docker compose up requirement;
  • sandbox-oriented nested runtimes such as Sysbox or rootless Podman, with attention to whether Apple Container's kernel supports them.

The hard acceptance criterion will be: the agent can run a normal containerized project workflow—including Compose services, builds, service DNS, bind-mounted source, and localhost/published-port checks—without gaining access to macOS, other bottles, the host runtime, or an unfiltered egress path.

I’ll optimize for a useful near-term path, not only the architecturally ideal end state. If a per-bottle remote worker is the safest practical option, I’ll identify the smallest implementation that unblocks the platform build and clearly separate required security controls from later ergonomics.

Understood, and that priority changes the weighting: this is not an occasional privileged escape hatch; Docker/Compose-equivalent project builds are a core bottle capability. I’ll research the established patterns rather than assuming remote Docker is the answer. The comparison will cover: - remote Docker daemon/worker per bottle, including mTLS, workspace transfer, BuildKit contexts, Compose bind mounts, published ports, lifecycle, and network confinement; - Docker-outside-of-Docker/socket brokers that expose a restricted API instead of a raw host socket; - rootless Docker or Podman inside the existing Apple-container VM, including required kernel features, user namespaces, networking, Compose compatibility, and whether rootless networking can be forced through the bottle gateway; - daemonless builders such as BuildKit/buildctl, Buildah, Kaniko, and nerdctl/containerd where they cover build workflows, while distinguishing them from the broader `docker compose up` requirement; - sandbox-oriented nested runtimes such as Sysbox or rootless Podman, with attention to whether Apple Container's kernel supports them. The hard acceptance criterion will be: the agent can run a normal containerized project workflow—including Compose services, builds, service DNS, bind-mounted source, and localhost/published-port checks—without gaining access to macOS, other bottles, the host runtime, or an unfiltered egress path. I’ll optimize for a useful near-term path, not only the architecturally ideal end state. If a per-bottle remote worker is the safest practical option, I’ll identify the smallest implementation that unblocks the platform build and clearly separate required security controls from later ergonomics.
Collaborator

Research spike complete. The main finding is that remote Docker is common, but rootless Docker inside the existing Apple-container VM is the best first spike for bot-bottle. It is the only option that plausibly preserves normal local Compose semantics and the current outer security boundary without introducing a second-machine filesystem/port translation layer.

Established patterns

1. Host socket sharing (“Docker-outside-of-Docker”)

Mounting /var/run/docker.sock is the simplest and most common CI/development pattern. It has native CLI/Compose compatibility and shares the host cache.

It is unacceptable here. Docker explicitly warns that remote/socket access can grant root access to the daemon host. An agent could start privileged containers, mount host paths, inspect other workloads, and alter bot-bottle infrastructure. An API-filtering proxy is not a reliable repair: enough Docker API to support arbitrary Compose files, builds, mounts, and privileged options is effectively arbitrary execution on the daemon host.

Verdict: reject for both Docker Desktop and any shared daemon.

2. Privileged Docker-in-Docker

The official docker:dind image runs an isolated daemon and gives correct local paths, ports, networks, and Compose behavior. However, the official recipe requires --privileged; Docker's documentation explicitly cautions that this grants full access to the containing environment. See the official Docker image documentation.

Inside a dedicated VM this is less catastrophic than privileged DinD on a shared Linux host, but granting the agent rootful Docker requires the outer guest capabilities we intentionally removed. In particular, it undermines the current CAP_NET_RAW/source-IP attribution proof and lets the agent reconfigure the guest network.

Verdict: do not use in the current shared-network agent VM.

3. Per-bottle remote Docker worker

Docker natively supports remote daemons over SSH or mutually authenticated TLS, and Compose supports deployment to a remote Docker host. See Docker's daemon access guidance and remote Compose documentation.

This is the standard answer when the client must not control its own host. A disposable worker VM plus unique per-bottle mTLS credentials would safely scope “Docker root” to that VM.

The ergonomics are not transparent, though. Docker documents that bind mounts are resolved on the daemon host, not the client, and cannot mount client files. Published ports also terminate on the worker rather than the agent's localhost. See Docker bind-mount constraints. We would need workspace synchronization, path parity, port forwarding, worker network confinement, and orphan cleanup.

Verdict: sound fallback architecture, but larger and less compatible than it initially appears.

4. Rootless Docker inside the existing agent VM

Docker rootless mode runs both dockerd and its children inside a user namespace as a non-root user. Its documented prerequisites are newuidmap/newgidmap and subordinate UID/GID ranges; it does not require a rootful daemon. See Docker rootless mode.

This is a strong fit for bot-bottle:

  • The Docker daemon, images, volumes, bind-mounted workspace, Compose networks, and published ports all live inside the existing bottle VM.
  • docker compose up, relative bind mounts, service DNS, and curl localhost:<port> retain normal local semantics.
  • Docker authority cannot exceed the unprivileged node user in the outer guest.
  • We do not add outer CAP_NET_ADMIN, CAP_NET_RAW, --privileged, host mounts, or host runtime sockets.
  • All rootless networking ultimately exits through the existing agent VM NIC. That NIC remains attached only to bot-bottle's host-only gateway network, so inner workloads cannot manufacture an independent route to macOS or the internet.
  • The whole Docker state disappears with the bottle unless deliberately committed.

There are real limitations: privileged ports below 1024 need extra setup, and Docker says cgroup resource flags require cgroup v2 plus systemd delegation. We can initially reject privileged port mappings and rely on the outer bottle's CPU/RAM boundary even if per-inner-container limits are unavailable. See Docker's rootless limitations.

The unknown is Apple Container kernel/runtime compatibility: unprivileged user namespaces, subordinate-ID helpers, overlay/fuse-overlayfs, cgroup behavior, and rootless networking must be tested. The documented working rootful-Docker machine recipe used the recommended Kata kernel for netfilter. RootlessKit may need fewer outer privileges, but this is not proven on our current image/kernel.

Verdict: first implementation spike.

5. Rootless Podman + Compose

Podman is daemonless and designed for rootless operation. Root inside a child maps to the invoking unprivileged user, and Podman supports rootless bind mounts and user-mode networking. See Podman's rootless tutorial.

There are two Compose routes:

  • podman-compose, an independent Compose implementation focused on rootless/daemonless operation;
  • the Podman Docker-compatible API socket used by Docker Compose.

This has similar kernel prerequisites to rootless Docker and potentially a smaller always-running surface, but compatibility is weaker: podman-compose is not Docker Compose, and the Docker API layer is a compatibility subset. Since the requirement is “normal project Docker/Compose workflows,” changing engines introduces avoidable failures in real repositories.

Verdict: second spike/fallback if rootless Docker fails for a specific technical reason.

6. BuildKit, Buildah, Kaniko, nerdctl/containerd

Rootless/daemonless BuildKit is excellent for Dockerfile builds and can run remotely over TLS. Its official rootless container recipe still needs relaxed seccomp/AppArmor/system-path restrictions, and it does not provide the runtime half of Compose. See BuildKit's official documentation.

Kaniko/Buildah similarly address image builds, not compose up, service DNS, or published-port testing. Nerdctl needs containerd and recreates most of the nested-runtime problem.

Verdict: useful future build-only optimization, not a solution to the stated requirement.

7. Sysbox/system-container runtimes

Sysbox makes nested rootful Docker safer by changing the outer host runtime. Apple Container does not expose Sysbox as a runtime, so adopting it would mean replacing the macOS backend rather than adding a bottle capability.

Verdict: not applicable.

Recommended near-term spike

Implement a throwaway, live-Mac proof before changing the manifest:

  1. Build a dedicated agent image variant containing Docker Engine rootless extras, Compose v2, uidmap, and storage/network helpers.
  2. Configure at least 65,536 subordinate UIDs/GIDs for node.
  3. Launch it through the existing container run path with the same custom network and --cap-drop CAP_NET_RAW; add no capabilities.
  4. After bottle registration, start dockerd-rootless.sh as node via the normal token-bearing exec path.
  5. Set DOCKER_HOST=unix:///run/user/<uid>/docker.sock.
  6. Configure both daemon pulls and build/container proxy defaults to use the bottle's authenticated egress proxy. Direct unproxied traffic must continue to fail because the outer VM has no internet route.
  7. Run an unmodified representative Compose project from the copied workspace.

Acceptance/security tests

The spike only passes if all of these hold:

  • docker build and docker compose build can pull through the gateway.
  • docker compose up provides service-name DNS.
  • .:/workspace sees and can update the bottle's workspace.
  • curl localhost:<published-high-port> reaches a service.
  • direct egress from both a child container and a Dockerfile RUN fails without proxy configuration;
  • allowlisted egress succeeds through the gateway and secret-bearing HTTP/DNS/git attempts remain blocked;
  • an inner --privileged container cannot access macOS, the Apple container API, other bottles, the gateway control plane, or spoof another outer source IP;
  • Docker cannot see the host's Docker Desktop images/containers;
  • stopping/deleting the bottle removes the daemon, children, images, volumes, networks, socket, and storage;
  • the existing sandbox-escape integration suite still passes unchanged;
  • record cold/warm daemon startup, Compose startup, disk growth, and settled memory.

Fail closed if rootless Docker requires adding outer NET_ADMIN, NET_RAW, SYS_ADMIN, --privileged, a host socket, or broad host mounts. In that case, test rootless Podman under the same no-new-capabilities rule. If both fail, proceed to the per-bottle remote worker design.

Product recommendation

Keep the manifest intent as docker_access: true, but make its backend implementation platform-specific:

  • legacy Docker backend: raw socket sharing remains explicitly unsafe and should probably require a stronger name/confirmation than a benign boolean;
  • macOS backend: rootless Docker inside the existing bottle VM, if the spike passes;
  • Firecracker: eventually the same guest-local rootless approach, likely easier to validate because we control the kernel.

This path directly targets the urgent platform-repository build need while preserving the rule that no agent gets host runtime authority or an egress path outside bot-bottle's gateway.

Research spike complete. The main finding is that **remote Docker is common, but rootless Docker inside the existing Apple-container VM is the best first spike for bot-bottle**. It is the only option that plausibly preserves normal local Compose semantics *and* the current outer security boundary without introducing a second-machine filesystem/port translation layer. ## Established patterns ### 1. Host socket sharing (“Docker-outside-of-Docker”) Mounting `/var/run/docker.sock` is the simplest and most common CI/development pattern. It has native CLI/Compose compatibility and shares the host cache. It is unacceptable here. Docker explicitly warns that remote/socket access can grant root access to the daemon host. An agent could start privileged containers, mount host paths, inspect other workloads, and alter bot-bottle infrastructure. An API-filtering proxy is not a reliable repair: enough Docker API to support arbitrary Compose files, builds, mounts, and privileged options is effectively arbitrary execution on the daemon host. **Verdict:** reject for both Docker Desktop and any shared daemon. ### 2. Privileged Docker-in-Docker The official `docker:dind` image runs an isolated daemon and gives correct local paths, ports, networks, and Compose behavior. However, the official recipe requires `--privileged`; Docker's documentation explicitly cautions that this grants full access to the containing environment. See the [official Docker image documentation](https://hub.docker.com/_/docker/). Inside a dedicated VM this is less catastrophic than privileged DinD on a shared Linux host, but granting the agent rootful Docker requires the outer guest capabilities we intentionally removed. In particular, it undermines the current `CAP_NET_RAW`/source-IP attribution proof and lets the agent reconfigure the guest network. **Verdict:** do not use in the current shared-network agent VM. ### 3. Per-bottle remote Docker worker Docker natively supports remote daemons over SSH or mutually authenticated TLS, and Compose supports deployment to a remote Docker host. See Docker's [daemon access guidance](https://docs.docker.com/engine/security/protect-access/) and [remote Compose documentation](https://docs.docker.com/compose/how-tos/production/). This is the standard answer when the client must not control its own host. A disposable worker VM plus unique per-bottle mTLS credentials would safely scope “Docker root” to that VM. The ergonomics are not transparent, though. Docker documents that bind mounts are resolved on the **daemon host**, not the client, and cannot mount client files. Published ports also terminate on the worker rather than the agent's localhost. See [Docker bind-mount constraints](https://docs.docker.com/engine/storage/bind-mounts/#considerations-and-constraints). We would need workspace synchronization, path parity, port forwarding, worker network confinement, and orphan cleanup. **Verdict:** sound fallback architecture, but larger and less compatible than it initially appears. ### 4. Rootless Docker inside the existing agent VM Docker rootless mode runs both `dockerd` and its children inside a user namespace as a non-root user. Its documented prerequisites are `newuidmap`/`newgidmap` and subordinate UID/GID ranges; it does not require a rootful daemon. See [Docker rootless mode](https://docs.docker.com/engine/security/rootless/). This is a strong fit for bot-bottle: - The Docker daemon, images, volumes, bind-mounted workspace, Compose networks, and published ports all live inside the existing bottle VM. - `docker compose up`, relative bind mounts, service DNS, and `curl localhost:<port>` retain normal local semantics. - Docker authority cannot exceed the unprivileged `node` user in the outer guest. - We do **not** add outer `CAP_NET_ADMIN`, `CAP_NET_RAW`, `--privileged`, host mounts, or host runtime sockets. - All rootless networking ultimately exits through the existing agent VM NIC. That NIC remains attached only to bot-bottle's host-only gateway network, so inner workloads cannot manufacture an independent route to macOS or the internet. - The whole Docker state disappears with the bottle unless deliberately committed. There are real limitations: privileged ports below 1024 need extra setup, and Docker says cgroup resource flags require cgroup v2 plus systemd delegation. We can initially reject privileged port mappings and rely on the outer bottle's CPU/RAM boundary even if per-inner-container limits are unavailable. See [Docker's rootless limitations](https://docs.docker.com/engine/security/rootless/tips/). The unknown is Apple Container kernel/runtime compatibility: unprivileged user namespaces, subordinate-ID helpers, overlay/fuse-overlayfs, cgroup behavior, and rootless networking must be tested. The documented working rootful-Docker machine recipe used the recommended Kata kernel for netfilter. RootlessKit may need fewer outer privileges, but this is not proven on our current image/kernel. **Verdict:** first implementation spike. ### 5. Rootless Podman + Compose Podman is daemonless and designed for rootless operation. Root inside a child maps to the invoking unprivileged user, and Podman supports rootless bind mounts and user-mode networking. See Podman's [rootless tutorial](https://github.com/containers/podman/blob/main/docs/tutorials/rootless_tutorial.md). There are two Compose routes: - `podman-compose`, an independent Compose implementation focused on rootless/daemonless operation; - the Podman Docker-compatible API socket used by Docker Compose. This has similar kernel prerequisites to rootless Docker and potentially a smaller always-running surface, but compatibility is weaker: `podman-compose` is not Docker Compose, and the Docker API layer is a compatibility subset. Since the requirement is “normal project Docker/Compose workflows,” changing engines introduces avoidable failures in real repositories. **Verdict:** second spike/fallback if rootless Docker fails for a specific technical reason. ### 6. BuildKit, Buildah, Kaniko, nerdctl/containerd Rootless/daemonless BuildKit is excellent for Dockerfile builds and can run remotely over TLS. Its official rootless container recipe still needs relaxed seccomp/AppArmor/system-path restrictions, and it does not provide the runtime half of Compose. See [BuildKit's official documentation](https://github.com/moby/buildkit#running-buildkit-without-root-privileges). Kaniko/Buildah similarly address image builds, not `compose up`, service DNS, or published-port testing. Nerdctl needs containerd and recreates most of the nested-runtime problem. **Verdict:** useful future build-only optimization, not a solution to the stated requirement. ### 7. Sysbox/system-container runtimes Sysbox makes nested rootful Docker safer by changing the **outer host runtime**. Apple Container does not expose Sysbox as a runtime, so adopting it would mean replacing the macOS backend rather than adding a bottle capability. **Verdict:** not applicable. ## Recommended near-term spike Implement a throwaway, live-Mac proof before changing the manifest: 1. Build a dedicated agent image variant containing Docker Engine rootless extras, Compose v2, `uidmap`, and storage/network helpers. 2. Configure at least 65,536 subordinate UIDs/GIDs for `node`. 3. Launch it through the **existing** `container run` path with the same custom network and `--cap-drop CAP_NET_RAW`; add no capabilities. 4. After bottle registration, start `dockerd-rootless.sh` as `node` via the normal token-bearing exec path. 5. Set `DOCKER_HOST=unix:///run/user/<uid>/docker.sock`. 6. Configure both daemon pulls and build/container proxy defaults to use the bottle's authenticated egress proxy. Direct unproxied traffic must continue to fail because the outer VM has no internet route. 7. Run an unmodified representative Compose project from the copied workspace. ## Acceptance/security tests The spike only passes if all of these hold: - `docker build` and `docker compose build` can pull through the gateway. - `docker compose up` provides service-name DNS. - `.:/workspace` sees and can update the bottle's workspace. - `curl localhost:<published-high-port>` reaches a service. - direct egress from both a child container and a Dockerfile `RUN` fails without proxy configuration; - allowlisted egress succeeds through the gateway and secret-bearing HTTP/DNS/git attempts remain blocked; - an inner `--privileged` container cannot access macOS, the Apple `container` API, other bottles, the gateway control plane, or spoof another outer source IP; - Docker cannot see the host's Docker Desktop images/containers; - stopping/deleting the bottle removes the daemon, children, images, volumes, networks, socket, and storage; - the existing sandbox-escape integration suite still passes unchanged; - record cold/warm daemon startup, Compose startup, disk growth, and settled memory. Fail closed if rootless Docker requires adding outer `NET_ADMIN`, `NET_RAW`, `SYS_ADMIN`, `--privileged`, a host socket, or broad host mounts. In that case, test rootless Podman under the same no-new-capabilities rule. If both fail, proceed to the per-bottle remote worker design. ## Product recommendation Keep the manifest intent as `docker_access: true`, but make its backend implementation platform-specific: - legacy Docker backend: raw socket sharing remains explicitly unsafe and should probably require a stronger name/confirmation than a benign boolean; - macOS backend: rootless Docker inside the existing bottle VM, if the spike passes; - Firecracker: eventually the same guest-local rootless approach, likely easier to validate because we control the kernel. This path directly targets the urgent platform-repository build need while preserving the rule that no agent gets host runtime authority or an egress path outside bot-bottle's gateway.
didericis-codex changed title from Docker socket access for agent containers (docker_access bottle flag) to Run Docker/Compose inside bottles without bypassing isolation 2026-07-21 01:44:04 -04:00
Author
Collaborator

Spike outcome

Spiked in #449 (now closed unmerged; branch spike/rootless-docker-macos is pushed and reusable). Write-ups: docs/research/rootless-docker-in-apple-container-spike.md and docs/research/egress-proxy-oom-on-large-downloads.md.

What actually works

Not rootless Docker — that turns out to be impossible on the macOS backend. Apple Container's capability bounding set omits CAP_SYS_ADMIN, which the kernel requires to write a multi-range uid_map via newuidmap. Nothing in packaging can route around it; the only fix is granting the bottle CAP_SYS_ADMIN, which is close to root in practical terms and would undercut the containment this issue is about. (Same constraint that makes upstream's dind-rootless need --privileged.)

Rootless podman does work, with zero added capabilities. With no subordinate UID range configured it falls back to a single-UID self-mapping that an unprivileged process can write itself, so newuidmap is never invoked. Verified live on macOS 26.5.1 / Apple Container 1.0.0: docker compose up -d --wait pulls an image, starts a service, and serves the workspace on a published port — inside a registered bottle, with no host socket and no privilege grant. The agent still types docker and docker compose; they talk to podman's Docker-compatible API socket.

Note the backend divergence from this issue's non-goals: the working implementation is on macos-container, not the docker backend.

What "docker access" would mean in practice

Four constraints a consumer of this feature has to accept, none of them hypothetical:

  1. Nested containers run within the bottle boundary, not inside a new one. The single-UID mapping collapses every UID in a nested container onto the agent's own uid — root inside is the agent user outside. This is by design: the goal is to run project Docker dependencies inside an existing bottle, not to add another isolation layer. Fine as a build/test workflow; not suitable for sandboxing untrusted code.
  2. Docker Hub and GHCR don't work. The egress proxy strips agent-set Authorization by design, and both require a client-fetched per-scope bearer token. Measured: token fetch 200, manifest request with a valid token 401, manifest with no auth 401 — identical. quay.io, mcr.microsoft.com, registry.k8s.io, and unauthenticated internal registries work today with a plain route.
  3. Nested containers have no outbound network yet. DNS inside them fails entirely (wget: bad address 'quay.io'). Compose can run images and serve locally; services that need to call out need proxy env, the CA, and DNS wired in first. Fail-closed, which is the right default, but it bounds what this feature currently delivers.
  4. A large pull OOM-kills the shared egress proxy — a ~165MB download drove mitmdump to ~1GB RSS and the cgroup killed it. The gateway is a per-host singleton with no restart-on-death, so one bottle's pull removes egress for every bottle until it's recreated. This is a pre-existing defect that pulling images makes easy to hit; it is not specific to containers.

Suggested reframing

If this issue stays open, it's worth rewriting around what the spike established:

  • Non-goal: mounting the host Docker socket, on any backend.
  • Mechanism: guest-local rootless podman on macos-container, exposing a Docker-compatible socket.
  • Blocked on: #451 (base bump to trixie — bookworm's podman 4.3.1 silently returns exit code 0 for every docker run, so agent scripts see failures as successes), plus a decision on the registry-auth strip and the egress-proxy OOM.
  • Scope check: whether nested containers that cannot themselves egress are useful enough to ship.
## Spike outcome Spiked in #449 (now closed unmerged; branch `spike/rootless-docker-macos` is pushed and reusable). Write-ups: `docs/research/rootless-docker-in-apple-container-spike.md` and `docs/research/egress-proxy-oom-on-large-downloads.md`. ### What actually works **Not** rootless Docker — that turns out to be impossible on the macOS backend. Apple Container's capability bounding set omits `CAP_SYS_ADMIN`, which the kernel requires to write a multi-range `uid_map` via `newuidmap`. Nothing in packaging can route around it; the only fix is granting the bottle `CAP_SYS_ADMIN`, which is close to root in practical terms and would undercut the containment this issue is about. (Same constraint that makes upstream's `dind-rootless` need `--privileged`.) **Rootless podman does work**, with zero added capabilities. With no subordinate UID range configured it falls back to a single-UID self-mapping that an unprivileged process can write itself, so `newuidmap` is never invoked. Verified live on macOS 26.5.1 / Apple Container 1.0.0: `docker compose up -d --wait` pulls an image, starts a service, and serves the workspace on a published port — inside a registered bottle, with no host socket and no privilege grant. The agent still types `docker` and `docker compose`; they talk to podman's Docker-compatible API socket. Note the backend divergence from this issue's non-goals: the working implementation is on `macos-container`, not the `docker` backend. ### What "docker access" would mean in practice Four constraints a consumer of this feature has to accept, none of them hypothetical: 1. **Nested containers run within the bottle boundary, not inside a new one.** The single-UID mapping collapses every UID in a nested container onto the agent's own uid — `root` inside is the agent user outside. This is by design: the goal is to run project Docker dependencies inside an existing bottle, not to add another isolation layer. Fine as a build/test workflow; not suitable for sandboxing untrusted code. 2. **Docker Hub and GHCR don't work.** The egress proxy strips agent-set `Authorization` by design, and both require a client-fetched per-scope bearer token. Measured: token fetch `200`, manifest request with a valid token `401`, manifest with no auth `401` — identical. quay.io, mcr.microsoft.com, registry.k8s.io, and unauthenticated internal registries work today with a plain route. 3. **Nested containers have no outbound network yet.** DNS inside them fails entirely (`wget: bad address 'quay.io'`). Compose can run images and serve locally; services that need to call out need proxy env, the CA, and DNS wired in first. Fail-closed, which is the right default, but it bounds what this feature currently delivers. 4. **A large pull OOM-kills the shared egress proxy** — a ~165MB download drove `mitmdump` to ~1GB RSS and the cgroup killed it. The gateway is a per-host singleton with no restart-on-death, so one bottle's pull removes egress for every bottle until it's recreated. This is a pre-existing defect that pulling images makes easy to hit; it is not specific to containers. ### Suggested reframing If this issue stays open, it's worth rewriting around what the spike established: - **Non-goal:** mounting the host Docker socket, on any backend. - **Mechanism:** guest-local rootless podman on `macos-container`, exposing a Docker-compatible socket. - **Blocked on:** #451 (base bump to trixie — bookworm's podman 4.3.1 silently returns exit code 0 for every `docker run`, so agent scripts see failures as successes), plus a decision on the registry-auth strip and the egress-proxy OOM. - **Scope check:** whether nested containers that cannot themselves egress are useful enough to ship.
Owner

Docker Hub and GHCR don't work. The egress proxy strips agent-set Authorization by design, and both require a client-fetched per-scope bearer token. Measured: token fetch 200, manifest request with a valid token 401, manifest with no auth 401 — identical. quay.io, mcr.microsoft.com, registry.k8s.io, and unauthenticated internal registries work today with a plain route.

@didericis-claude should add a flag for egress routes that turns off authorization stripping from certain routes. Can add it for docker hub and ghcr in bottle configs that use docker. Create a new PR for this/reference this issue.

> Docker Hub and GHCR don't work. The egress proxy strips agent-set Authorization by design, and both require a client-fetched per-scope bearer token. Measured: token fetch 200, manifest request with a valid token 401, manifest with no auth 401 — identical. quay.io, mcr.microsoft.com, registry.k8s.io, and unauthenticated internal registries work today with a plain route. @didericis-claude should add a flag for egress routes that turns off authorization stripping from certain routes. Can add it for docker hub and ghcr in bottle configs that use docker. Create a new PR for this/reference this issue.
Author
Collaborator

Sounds right. The flag belongs on individual egress route entries — something like strip_auth: false (or an explicit preserve_auth: true) — so the gateway can make a per-route decision rather than applying a global policy. Docker Hub (registry-1.docker.io) and GHCR (ghcr.io) would get it set in whatever route block is emitted for bottles with nested container access.

Opening a PR now.

Sounds right. The flag belongs on individual egress route entries — something like `strip_auth: false` (or an explicit `preserve_auth: true`) — so the gateway can make a per-route decision rather than applying a global policy. Docker Hub (`registry-1.docker.io`) and GHCR (`ghcr.io`) would get it set in whatever route block is emitted for bottles with nested container access. Opening a PR now.
Owner

@didericis-claude this commit was incorrect -> ccd987a501. Issue is not fully close until we verify that we can use podman to run docker within a bottle. The last outstanding issue should be this:

Nested containers have no outbound network yet. DNS inside them fails entirely (wget: bad address 'quay.io'). Compose can run images and serve locally; services that need to call out need proxy env, the CA, and DNS wired in first. Fail-closed, which is the right default, but it bounds what this feature currently delivers.

RE this issue:

A large pull OOM-kills the shared egress proxy — a ~165MB download drove mitmdump to ~1GB RSS and the cgroup killed it. The gateway is a per-host singleton with no restart-on-death, so one bottle's pull removes egress for every bottle until it's recreated. This is a pre-existing defect that pulling images makes easy to hit; it is not specific to containers.

Check to see if we've filed a new issue for it (thought we have). If we didn't, file one. But should not block the pr. We should simply turn off dlp for the image registries in the egress config.

So acceptance criteria for final PR to close this issue:

  • On macos host, once bot-bottle-dev has been updated so the egress options for docker registries are updated (do not strip authorization, do not do dlp), and once the dns issue is resolved (skip initially/make sure it wasn't a phantom issue), you should be able to clone the bot-bottle-platform repo from within a bottle (along with it's submodules) and run the integration tests in the bottle with docker compose
@didericis-claude this commit was incorrect -> https://gitea.dideric.is/didericis/bot-bottle/commit/ccd987a5019495fa2092fdc28b773fadf1140b60. Issue is not fully close until we verify that we can use podman to run docker within a bottle. The last outstanding issue should be this: > Nested containers have no outbound network yet. DNS inside them fails entirely (wget: bad address 'quay.io'). Compose can run images and serve locally; services that need to call out need proxy env, the CA, and DNS wired in first. Fail-closed, which is the right default, but it bounds what this feature currently delivers. RE this issue: > A large pull OOM-kills the shared egress proxy — a ~165MB download drove mitmdump to ~1GB RSS and the cgroup killed it. The gateway is a per-host singleton with no restart-on-death, so one bottle's pull removes egress for every bottle until it's recreated. This is a pre-existing defect that pulling images makes easy to hit; it is not specific to containers. Check to see if we've filed a new issue for it (thought we have). If we didn't, file one. But should not block the pr. We should simply turn off dlp for the image registries in the egress config. So acceptance criteria for final PR to close this issue: - On macos host, once `bot-bottle-dev` has been updated so the egress options for docker registries are updated (do not strip authorization, do not do dlp), and once the dns issue is resolved (skip initially/make sure it wasn't a phantom issue), you should be able to clone the `bot-bottle-platform` repo from within a bottle (along with it's submodules) and run the integration tests in the bottle with `docker compose`
Author
Collaborator

Confirmed — the Closes #392 in ccd987a (PR #453, the preserve_auth flag) auto-closed this on merge, and that was premature. preserve_auth is one prerequisite, not the whole feature: nothing has verified podman-driven docker compose end-to-end inside a bottle yet, and the nested-container DNS/outbound gap is still open. Reopened.

OOM: no separate issue existed — filed as #455 (Kind/Bug, Priority/High), split out from the finding here with the dmesg/RSS evidence and the two fix options (restart-on-death vs. cap scanned-body size). Per your note it does not block this PR: we sidestep it on the registry hosts by disabling DLP there (below), and #455 tracks the general fix.

Egress config for docker registries (the bot-bottle-dev change): each registry route gets

preserve_auth: true          # keep the agent's per-scope bearer token (already landed)
dlp:
  outbound_detectors: false  # no body buffering → no OOM on layer pulls
  inbound_detectors: false
  • registry-1.docker.io + auth.docker.io, ghcr.io — need preserve_auth: true (token dance) and DLP off.
  • quay.io, mcr.microsoft.com, registry.k8s.io — already reachable on a plain route; just add DLP off so large pulls don't hit #455.

(The manifest already supports both: preserve_auth from #453 and dlp.{outbound,inbound}_detectors: false in manifest_egress.py.)

DNS gap (last real blocker per your comment): I'll treat "verify it wasn't a phantom" as step one — the earlier wget: bad address 'quay.io' was from inside a nested container, whose resolver isn't wired to the bottle's. Before adding any plumbing I'll confirm whether podman's default netns/aardvark-dns resolves once the gateway route + CA + proxy env are present in the container, and only wire DNS/proxy through if it's genuinely broken.

Acceptance criteria for the closing PR (must run on the macOS/Apple-container host — I can't exercise Apple container from the Linux CI sandbox, so I'll run these live and paste results):

  • bot-bottle-dev egress updated: registry routes preserve_auth: true + DLP off
  • DNS from within a bottle's nested containers resolves (or confirmed phantom)
  • clone bot-bottle-platform with submodules from inside a bottle
  • run its integration tests in-bottle via docker compose

Still gated on #451 (trixie base bump) — bookworm's podman 4.3.1 returns exit 0 for every docker run through the compat API, so the tests above would report false passes until that lands.

Confirmed — the `Closes #392` in ccd987a (PR #453, the `preserve_auth` flag) auto-closed this on merge, and that was premature. `preserve_auth` is one prerequisite, not the whole feature: nothing has verified podman-driven `docker compose` end-to-end inside a bottle yet, and the nested-container DNS/outbound gap is still open. **Reopened.** **OOM:** no separate issue existed — filed as #455 (`Kind/Bug`, `Priority/High`), split out from the finding here with the `dmesg`/RSS evidence and the two fix options (restart-on-death vs. cap scanned-body size). Per your note it does **not** block this PR: we sidestep it on the registry hosts by disabling DLP there (below), and #455 tracks the general fix. **Egress config for docker registries** (the `bot-bottle-dev` change): each registry route gets ``` preserve_auth: true # keep the agent's per-scope bearer token (already landed) dlp: outbound_detectors: false # no body buffering → no OOM on layer pulls inbound_detectors: false ``` - `registry-1.docker.io` + `auth.docker.io`, `ghcr.io` — need `preserve_auth: true` (token dance) **and** DLP off. - `quay.io`, `mcr.microsoft.com`, `registry.k8s.io` — already reachable on a plain route; just add DLP off so large pulls don't hit #455. (The manifest already supports both: `preserve_auth` from #453 and `dlp.{outbound,inbound}_detectors: false` in `manifest_egress.py`.) **DNS gap** (last real blocker per your comment): I'll treat "verify it wasn't a phantom" as step one — the earlier `wget: bad address 'quay.io'` was from *inside a nested container*, whose resolver isn't wired to the bottle's. Before adding any plumbing I'll confirm whether podman's default netns/`aardvark-dns` resolves once the gateway route + CA + proxy env are present in the container, and only wire DNS/proxy through if it's genuinely broken. **Acceptance criteria for the closing PR** (must run on the macOS/Apple-container host — I can't exercise Apple `container` from the Linux CI sandbox, so I'll run these live and paste results): - [ ] `bot-bottle-dev` egress updated: registry routes `preserve_auth: true` + DLP off - [ ] DNS from within a bottle's nested containers resolves (or confirmed phantom) - [ ] clone `bot-bottle-platform` **with submodules** from inside a bottle - [ ] run its integration tests in-bottle via `docker compose` Still gated on **#451** (trixie base bump) — bookworm's podman 4.3.1 returns exit `0` for every `docker run` through the compat API, so the tests above would report false passes until that lands.
Sign in to join this conversation.
3 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: didericis/bot-bottle#392