One container per sandbox, running sandboxd, reached on a loopback-published
port.
Requires the optional :req dependency and an image containing the sandboxd
release. Unlike CrowdControl.Backend.Docker, which works with any image that
has sh and tail, this provider needs our agent inside the container.
Network posture, and why it is not isolation
This provider does not block egress, and it must not be described as though it does. That is a measured constraint of the Docker bridge driver, not an oversight:
On one container,
Internal: trueand a published port are mutually exclusive. Publishing requires at least one non-internal endpoint, and attaching one restores full internet egress.
Confirmed six independent ways (internal-only; NetworkMode: "none"; two
internal networks; Internal combined with each of the four
gateway_mode_ipv4 values; publish-then-disconnect). Worse, the failure is
silent: POST /containers/create answers 201 with "Warnings": [] and
HostConfig.PortBindings echoes the request verbatim, while
NetworkSettings.Ports quietly reads {"8080/tcp": null}.
So :egress is required and has no default, exactly as
CrowdControl.Backend.Docker requires an explicit :network_mode. A silent
default here would hand model-driven code general outbound access in the one
scenario SECURITY.md warns about:
egress: :allow— a private per-sandbox bridge. Full outbound access. Correct when the sandbox is supposed to reach an API, and honest about it.egress: :no_nat— the same bridge withcom.docker.network.bridge.enable_ip_masquerade=false. The internet becomes unreachable because return traffic has no SNAT, but the Docker host, every container on every other Docker network, and Docker's embedded DNS all stay reachable. It is "no NAT", not "dropped". On a network whose router knows a path back to the container subnet, egress is not guaranteed to fail at all.
For a strong, structural egress block and a reachable agent, use
CrowdControl.Provider.Compose: an internal-only sandbox plus a dual-homed
forwarder is the only shape that delivers both, and it needs a second
container to do it.
Options
:image— image containing thesandboxdrelease (required):egress—:allowor:no_nat(required; see above):agent_port— the portsandboxdlistens on inside the container, default8080:capture_path— default/var/log/cc/out.jsonl:ready_timeout— how longacquire/1waits forGET /v1/health, default30_000:docker_host,:timeout— asCrowdControl.Backend.Docker.API:cpus,:memory,:cap_drop,:security_opt,:pids_limit,:user,:readonly_rootfs,:tmpfs— hardening, shared verbatim withCrowdControl.Backend.DockerthroughCrowdControl.Backend.Docker.HostConfig:agent_env— extra environment for the agent process itself, not the CLI. The CLI's env goes throughCrowdControl.Backend.exec/4, in a request body.:req_adapter— test seam, threaded into the endpoint'sReqoptions
The published port is never persisted
Every stop/start/restart allocates a new ephemeral host port, and
while a container is stopped NetworkSettings.Ports is {} rather than
reporting the old one. reconnect/1 therefore always re-reads it. This is
measured behaviour, not caution.
Summary
Functions
Read the host port Docker assigned to the agent port.
Types
Functions
@spec host_port_from_inspect(map(), pos_integer()) :: {:ok, pos_integer()} | {:error, term()}
Read the host port Docker assigned to the agent port.
NetworkSettings.Ports is the only source of truth. Four failure shapes
were all observed against a live daemon and all of them mean "there is no
usable port", so each is an error rather than something to work around:
- the key is present with a
nullvalue — the binding was discarded because the network is internal; HostPortis""—gateway_mode_ipv4: "routed"reports a binding it did not make;Portsis{}— the container is not running;- the key is absent —
ExposedPortswas never set.
Public and @doc false-adjacent so the parsing is testable without a daemon.