How copilot_here filters Docker requests through a socket broker
On this page8 sections ▾
If you just want the feature overview, check out the copilot_here site. If you want the setup steps, head to the setup guide. This post explains why I built the brokered Docker socket, how the internals work, and where the boundaries are.
#The problem
AI coding agents inside a sandbox need Docker access. Testcontainers spins up a database for integration tests. A build pipeline calls docker build. A test harness starts a Redis sidecar. All of these need a working Docker socket.
One way to provide access is to bind-mount the host's /var/run/docker.sock into the container:
# This exposes the host Docker daemon to the container.
docker run -v /var/run/docker.sock:/var/run/docker.sock myimageThat socket is the Docker daemon's control plane. Whatever can reach it can:
- Pull and run any image, including ones with host filesystem mounts
- Spawn privileged containers with full host kernel access
- Bind-mount
/etc,/root, or the Docker socket itself into a new container - Attach to running containers on the host
- Obtain broad control over the environment where the Docker daemon runs
On Docker Desktop, the daemon runs inside a Linux VM; on a typical Linux installation it runs on the host itself. The consequences depend on that environment and the daemon configuration.
For a human developer, exposing the socket may be a calculated risk. You trust yourself not to run docker run --privileged -v /:/host alpine sh. For an AI agent operating autonomously, that trust is harder to justify.
#What a broker changes
Instead of handing the container the real Docker socket, copilot_here starts a host-side broker process that creates its own socket. The container connects to the broker, the broker decides whether to forward the request to the real daemon.
The broker sits in the copilot_here host process (C#/.NET, AOT compiled). On Linux and macOS it listens on a Unix domain socket at /tmp/copilot-broker-{sessionId}.sock. On Windows it uses a TCP loopback port reached through host.docker.internal. Either way, the container sees a normal Docker socket at /var/run/docker.sock. It has no idea a broker is in the middle.
#Phase 1: Endpoint whitelist
Every Docker API call is an HTTP request — GET /containers/json, POST /containers/create, DELETE /images/sha256:abc123. The broker maintains an explicit allowlist of method + path pairs. Currently 65 endpoints are allowed by default:
{
"allowed_endpoints": [
{ "method": "GET", "path": "/_ping" },
{ "method": "GET", "path": "/containers/json" },
{ "method": "POST", "path": "/containers/create" },
{ "method": "POST", "path": "/containers/*/start" },
{ "method": "POST", "path": "/containers/*/stop" },
{ "method": "DELETE", "path": "/containers/*" },
{ "method": "GET", "path": "/images/json" },
{ "method": "POST", "path": "/images/create" },
{ "method": "GET", "path": "/networks" },
{ "method": "POST", "path": "/networks/create" }
]
}Path matching is segment-aware: * matches exactly one path segment, ** matches zero or more. API version prefixes (like /v1.43/) are stripped before matching. Anything not in the list gets a 403. Default-deny.
#Phase 2: Body inspection
Endpoint filtering handles the "which API calls are allowed" question. Body inspection handles "what configuration is allowed inside those calls." It runs on POST /containers/create, where these checks can reject a container configuration before the daemon starts it.
The inspector parses the JSON body and checks five things:
#Image allowlist
The image check controls which images can be used for new containers. The allowed_images list starts empty, meaning no sibling containers can be spawned at all until you explicitly add patterns. You need to name the images you want to permit before using them.
{
"body_inspection": {
"allowed_images": [
"mcr.microsoft.com/mssql/server:*",
"postgres:*",
"testcontainers/ryuk:*",
"redis:7*"
]
}
}Patterns use glob matching. * matches any sequence of characters including slashes and colons.
#Privilege rejection
Blocks HostConfig.Privileged = true. A privileged container has unrestricted access to the host kernel: device access, all capabilities, no seccomp filtering. Default: reject.
#Host namespace rejection
Blocks NetworkMode, PidMode, IpcMode, or UsernsMode set to "host". Sharing the host's network, PID, IPC, or user namespace breaks container isolation. Default: reject.
#Forbidden bind mounts
Inspects HostConfig.Binds and blocks mounts targeting:
/(host root)/etc,/root,/var,/usr,/bin,/sbin/proc,/sys/var/run/docker.sock,/run/docker.sock
Subpath matching is included, so /etc/passwd is caught by the /etc rule. Default: reject.
#Dangerous capabilities
Blocks CapAdd entries from a deny list: SYS_ADMIN, SYS_MODULE, SYS_PTRACE, SYS_RAWIO, SYS_BOOT, MAC_ADMIN, MAC_OVERRIDE, DAC_READ_SEARCH, NET_ADMIN, AUDIT_CONTROL. These capabilities grant access beyond the normal container defaults and can weaken isolation. Default: reject.
Each of these checks can be individually toggled off via the config file if a specific workflow requires it.
#Standard mode vs Airlock mode
#Standard mode
The simplest setup. The broker listens on a socket, the container mounts it, Docker calls go through the broker. The container can reach the internet normally — only Docker API calls are mediated.
#Airlock mode
When DinD is combined with Airlock, there's more going on. The app container sits on an internal-only network and cannot reach the outside world directly. A proxy container bridges the gap.
The proxy container runs a Rust HTTP/HTTPS proxy for regular traffic plus a socat bridge that forwards Docker API calls from port 2375 to the host broker. The app container's DOCKER_HOST points at tcp://proxy:2375.
When the broker inspects a POST /containers/create request in airlock mode, it rewrites the NetworkMode field. Siblings that would normally land on the default bridge network get placed on the airlock network instead. This means:
- Siblings are reachable from the app container via Docker DNS (e.g.,
mssql:1433) - Siblings are also network-isolated, meaning they can only reach the proxy, not the external network directly
- The airlock's HTTP/HTTPS filtering still applies to their traffic
Without this rewrite, Testcontainers would start a database on the bridge network and the airlocked app container couldn't talk to it.
Known limitation: The airlock network is internal: true, which means the workload container can't reach host-mapped ports. Testcontainers and similar frameworks that connect to siblings via host.docker.internal:<random-port> will time out in airlock mode. This is tracked in #101. The workaround is to use standard mode (--dind without airlock) where the broker still enforces all Docker API rules, you just lose the HTTP proxy network isolation.
#Design decisions
Why a userspace proxy instead of eBPF or seccomp? Portability. The broker runs on Linux, macOS, and Windows. eBPF is Linux-only and seccomp can't inspect HTTP request bodies. A userspace proxy works everywhere and can parse JSON.
Why default-deny with an empty image allowlist? Starting empty requires users to choose the images their workflow needs. It avoids permitting arbitrary images just because Docker can pull them.
Why inspect bodies and not just endpoints? Because POST /containers/create is a single endpoint that can produce wildly different security outcomes depending on the body. A container with "Privileged": true is a completely different threat than one without it. Endpoint filtering alone can't distinguish between the two.
Why hand-rolled HTTP framing instead of Kestrel? The broker needs to handle Docker's HTTP Upgrade protocol for exec and attach (WebSocket-style bidirectional streaming). After the request line is approved, the broker splices raw TCP streams rather than parsing the full HTTP body. This handles content-length, chunked transfer, and upgrade hijacking without a full HTTP server.
#Remaining attack surface
The default rules leave several operations available beyond the checks on new container configurations:
Exec into existing containers. POST /containers/*/exec is in the endpoint allowlist and has no body inspection. The route allowlist does not scope container IDs to the current session, so an allowed exec request can target an existing container visible to the daemon, including one outside the session. If the target container has access to a mounted secret or a network the agent cannot otherwise reach, exec gives the agent access through that container.
Docker build. POST /build is allowed. The daemon executes a Dockerfile's RUN steps in its build environment, not as ordinary shell commands directly on the host. A malicious Dockerfile could exfiltrate data, install backdoors in the built image, or probe the host network during build.
Archive write. PUT /containers/*/archive lets files be written into running containers. An agent could inject a script into a running sibling's filesystem and then exec it.
Tag trust. Image allowlist patterns like postgres:* trust every tag published to that repository. If an upstream image is compromised and a malicious tag is pushed, it would pass the allowlist. A specific version tag narrows the match (for example, postgres:16.2 instead of postgres:*), but tags can move. Pinning an image digest gives a fixed image reference; it still requires choosing an image you trust.
These operations can cause harm through a mistaken command, an untrusted build script or prompt injection, as well as deliberate misuse. The broker blocks the configured cases, such as a create request with Privileged: true, but its checks are not a complete boundary against hostile Docker API use.
The bind-path check described above inspects HostConfig.Binds. It should not be read as validation of every Docker storage option: the current body inspector does not check the separate HostConfig.Mounts representation. The implementation is available in DockerBrokerBodyInspector.cs, with the allowed routes in the default broker rules.
Airlock adds network filtering for containers on its internal network. It doesn't extend body inspection to exec or build requests, and allowed proxy destinations remain reachable. Choose it for the network restrictions described above, while accounting for the Testcontainers limitation.
#What's next
The broker is marked as beta. Whether body inspection gets extended to other endpoints (like exec or build) depends on feedback from people actually using it. If you hit a case where the current scope doesn't work for you, open an issue.
If you want to set it up, the setup guide walks through enabling the broker, configuring image allowlists, and tuning privilege controls per repo. Or check the copilot_here site for the quick overview.