Docker Socket Proxy
Crewship creates, starts, execs into and tears down the containers your agents run in. It does that by talking to the Docker Engine API, and by default it talks to it over/var/run/docker.sock.
Access to that socket is equivalent to root on the host. That is an
uncomfortable amount of privilege for a process whose day job is running LLM
agents over content it did not write, so this page documents the supported way
to narrow it: put a filtering proxy in front of the socket and point Crewship at
the proxy with DOCKER_HOST.
How it works
Crewship resolves its daemon endpoint fromDOCKER_HOST before falling back to
socket probing (internal/provider/docker/detect_all.go). Nothing special is
needed on our side — point it at a proxy and it uses the proxy.
docker-socket-proxy:
HAProxy with one http-request allow rule per API path prefix, each gated by an
environment variable, and a final http-request deny.
Deployment
docker/docker-compose.prod.yml in this repository ships the proxy already
wired. The relevant service:
docker/docker-compose.prod.yml
internal: true network and never publish its port. It has
no authentication of its own: anything that can reach tcp://…:2375 has
whatever the variables above allow.
The endpoints we actually use
Derived from the source, not from memory. Every row below has at least one call site in this repository, andgo run ./scripts/docker-api-surface fails the
build if that stops being true in either direction — a new call nobody listed,
or a listed endpoint nobody calls.
Containers — CONTAINERS: 1
ExecCreate is in this table rather than the next one on purpose: the Engine
API puts exec creation under /containers, so EXEC: 0 does not stop it.
Exec — EXEC: 1
Images — IMAGES: 1
Networks and volumes — NETWORKS: 1, VOLUMES: 1
Daemon probes — INFO: 1, PING: 1, VERSION: 1
Why POST: 1 is unavoidable
HAProxy’s first rule in this proxy is deny unless METH_GET || POST. METH_GET
matches GET and nothing else, so the single POST variable also gates PUT,
DELETE and HEAD. Creating a container, removing a volume, writing an
archive into a container and pinging the daemon all need it. There is no
read-only mode of Crewship that would let you leave it off.
Devcontainer provisioning is opt-in
Crews that carry a devcontainer or mise config get a provisioned, cached image (crewNeedsProvision in internal/api/crew_runtime_config.go). That path needs
two things the core allow-list does not grant:
The build path is not an SDK call.
internal/devcontainer/imagebuilder.go shells
out to the docker CLI with DOCKER_BUILDKIT=1 and DOCKER_HOST pinned to the
same endpoint, so it reaches the daemon as a second client of the same proxy.
Leave these off and crews with no devcontainer config are unaffected; a crew that
needs provisioning fails with a 403 on POST /commit (or on the build). If you
turn them on, be clear about what you are granting: BUILD lets the caller run
arbitrary RUN steps against arbitrary base images, and COMMIT lets it turn any
running container into an image. Both are opt-in for that reason.
The BuildKit path behind a proxy is documented from the code, not from a live
run — we have not driven a feature build end to end through the proxy. Depending
on daemon version BuildKit may also want
/grpc. If a feature build fails with a
403 on a path we do not list here, that is the gap, and it is worth an issue.What this does not protect against
This is the part that decides whether the control is worth deploying. Read it before you tell anyone the agent-container problem is handled. It cannot blockPrivileged: true. The proxy filters by URL path. It never
parses the request body, and Privileged, CapAdd and Binds all live in the
body of POST /containers/create — an endpoint we cannot function without. The
fence for privileged crews is the workspace allow_privileged_credentials flag,
enforced server-side at crew create and update
(Container Isolation). That is the control that
actually covers this risk, and a socket proxy does not change it either way.
It cannot block a host bind mount for the same reason. mounts[].source is
validated by internal/devcontainer/mount_validate.go and re-filtered at
container start. Again: our code, not the proxy.
The verbs we need are the dangerous ones. ContainerCreate plus
ExecCreate/ExecStart/ExecAttach plus CopyToContainer is, in combination,
“run arbitrary code in a container of your choosing, with a filesystem of your
choosing”. Anyone who reaches the proxy has that. An attacker with code execution
inside the Crewship process is inconvenienced, not stopped.
The granularity is a path prefix, not an endpoint. The table above is what
we call. What the proxy opens is five whole namespaces. CONTAINERS: 1 also
allows POST /containers/prune (removes every stopped container on the host),
POST /containers/{id}/kill, POST /containers/{id}/update (raise resource
limits), GET /containers/{id}/logs and GET /containers/{id}/export —
including for containers that have nothing to do with Crewship. IMAGES: 1 also
allows POST /images/prune and POST /images/{name}/push. VOLUMES: 1 also
allows POST /volumes/prune. The proxy has no way to express “only these
thirty-one calls”.
You have moved the trust, not removed it. The proxy container is now the one
holding the socket. It is a smaller, simpler process than Crewship, which is a
real gain — but it is still root-equivalent, and anything that can reach its port
inherits its allow-list. Keep it on an internal network.
This is a static derivation, not an end-to-end proof. The allow-list is
derived from call sites by scripts/docker-api-surface and gated in CI. Nothing
in CI runs the product against a live proxy, so a mismatch between “the endpoint
we call” and “the path the proxy matches” would not be caught here.
What it does remove
The gain is real, it is just narrower than “socket access is now safe”. With the core profile the daemon stops accepting, from Crewship’s network position:POST /buildandPOST /commit— image build and container-to-image commit (unless you opt in above)./swarm,/nodes,/tasks,/services— the entire swarm control plane, including joining the host to a swarm you control./secretsand/configs— swarm secret and config material./plugins— plugin install, which is arbitrary code on the host by design./system—POST /system/prune(host-wide destruction) and disk usage./auth— registry login with the daemon’s credentials./distribution,/grpc,/session,/events.
/var/run/docker.sock, this is
strictly better and you should deploy it. Just do not let it stand in for the
controls in Container Isolation and the
Threat Model, which are where the privileged-container
and mount risks are actually handled.
Keeping the list honest
An endpoint allow-list written once is wrong within a quarter. This one is derived from the code on every CI run:*client.Client by reflection, finds
every call site in the packages that import the Docker SDK, and compares the
result with scripts/docker-api-surface/allowlist.go. It fails when:
- a Docker call appears that the allow-list does not declare — the published
proxy config would
403on it in production; - a declared endpoint has no call site left — we would be asking operators to grant a permission we no longer need;
- a package that never touched Docker starts importing the SDK;
- a new subprocess is executed with
DOCKER_HOSTpinned, which is a second client of the same socket that no compile-time check would see; docker/docker-compose.prod.ymlor this page stops matching the table.
Troubleshooting
403 Forbidden from the daemon. The proxy denies with a plain 403 and logs
the path. docker logs crewship-docker-proxy shows which one; map it back to the
tables above. If the path is not listed here, go run ./scripts/docker-api-surface
should already have failed in CI — if it did not, that is a gap in the check.
Crew provisioning fails immediately after “cleanup caches”. That is the
POST /commit step. Set COMMIT: 1.
Nothing works and the server reports no Docker daemon. Detection uses
HEAD /_ping, which is a non-GET verb: check POST: 1 is set, not just
PING: 1.
Related
- Container Isolation — UID split, capability drop, the privileged-crew gate.
- Threat Model — what is and is not mitigated overall.
- Devcontainers — what provisioning does, and when the
COMMIT/BUILDpermissions come into play.