Metadata-Version: 2.4
Name: maf-sandbox-docker
Version: 0.15.1
Summary: Plain Docker containers as a local and CI sandbox backend for Microsoft Agent Framework agents — any Docker-compatible engine, zero dependencies beyond maf-sandbox.
Keywords: docker,containers,sandbox,agent-framework,microsoft-agent-framework,isolation
Author: SOKOLAI BV
Author-email: SOKOLAI BV <info@sokolai.com>
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Typing :: Typed
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Operating System :: OS Independent
Classifier: Topic :: Software Development :: Testing
Requires-Dist: maf-sandbox>=0.33.0,<0.36
Requires-Python: >=3.12, <3.15
Project-URL: Homepage, https://www.sokol.ai
Project-URL: Source, https://github.com/sokolaidev/maf-extensions
Project-URL: Issues, https://github.com/sokolaidev/maf-extensions/issues
Description-Content-Type: text/markdown

# maf-sandbox-docker

[![PyPI](https://img.shields.io/pypi/v/maf-sandbox-docker)](https://pypi.org/project/maf-sandbox-docker/) [![Python](https://img.shields.io/pypi/pyversions/maf-sandbox-docker)](https://pypi.org/project/maf-sandbox-docker/) [![License](https://img.shields.io/badge/license-MIT-green)](https://github.com/sokolaidev/maf-extensions/blob/main/LICENSE)

> **Experimental.** This package is early-stage (pre-1.0, `Development Status :: 4 - Beta`) — its API may change or be removed in a future release without notice. Importing it emits a one-time `MafSandboxDockerExperimentalWarning`; suppress it with `warnings.filterwarnings("ignore", category=maf_sandbox_docker.MafSandboxDockerExperimentalWarning)` once you've read the notice.

This package is not affiliated with, endorsed by, or a product of Docker Inc. or Microsoft — it is a third-party sandbox backend for [Microsoft Agent Framework](https://aka.ms/AgentFramework).

```
app  ->  maf_sandbox  ->  maf_sandbox_docker  ->  the container
```

The sandbox backend for everyone `wslc` leaves out: plain Docker containers, driven through the `docker` command-line client, on any machine with a Docker-compatible engine — macOS, Linux, Windows with WSL 2, and every GitHub Actions `ubuntu-latest` runner. No subscription, no login, and no dependency but [`maf-sandbox`](https://github.com/sokolaidev/maf-extensions/tree/main/packages/maf-sandbox) itself. A workload written against the protocol runs here unchanged, which is what makes it a workload rather than an integration.

## Quickstart

```bash
pip install maf-sandbox-docker
```

```python
from maf_sandbox import Isolation, SandboxRouter
from maf_sandbox_docker import DockerSandboxBackend, DockerSandboxConfig

router = SandboxRouter([DockerSandboxBackend(DockerSandboxConfig())], min_isolation=Isolation.CONTAINER)
```

[`samples/06_docker_codeact`](https://github.com/sokolaidev/maf-extensions/tree/main/samples/06_docker_codeact) runs those two lines end to end: an agent that executes model-written Python in a container and reads the result back out. Its siblings `03_acas_codeact` and `04_wslc_codeact` are the same program on a microVM-isolated Azure backend and on `wslc`, and the diff between any two of them is two imports and one constructor.

## Requirements

**A Docker-compatible engine, reachable through the `docker` client.** Docker Desktop (macOS, Linux, Windows with WSL 2) and Docker Engine (Linux, rootful or rootless) are what this backend supports. The client's own configuration — `DOCKER_HOST`, the active context, TLS settings — is inherited, because every call is a subprocess that inherits this process's environment; point `DockerSandboxConfig.docker_path` at a different client binary to use another one. Colima, OrbStack, Rancher Desktop and Podman expose Docker-compatible sockets and may work through the same client (Podman's default outbound network is called `podman`, so set `outbound_network="podman"` in allowlist mode), but they are not officially supported and nothing here is verified against them.

**Docker Engine 28.0.0 or newer, to serve `Egress.ALLOWLIST` with hosts on the list.** Such a sandbox is attached to an internal network whose bridge holds no host address. That is the bridge driver's `gateway_mode_ipv4=isolated` and its IPv6 twin `gateway_mode_ipv6=isolated`, both of which arrived in that engine. Both are set, because a daemon with IPv6 enabled would otherwise keep the v6 half addressed, and the route back with it. An older daemon rejects the options, and the acquire fails saying so rather than serving the workload on an addressed bridge — a bridge address is a route to the host that the allowlist does not cover. The floor is only for that case, because it is only there that a network is built at all: `Egress.CLOSED`, and an `ALLOWLIST` spec whose `egress_allow` is empty, both run on `--network none` and on any supported engine.

Every call spawns the `docker` client, so the host's event loop has to be one that can start subprocesses — asyncio's default Proactor loop on Windows does, and a host that installs `WindowsSelectorEventLoopPolicy` has to undo that first, or every acquire fails with a message saying so.

**Hosts this backend does not serve:** Windows without WSL (Docker Desktop's Hyper-V backend is documented by Docker but not its default, needs Pro or Enterprise, and is not verified here; Windows Home has no route at all), GitHub Actions' `windows-latest` (Windows containers only) and `macos-latest` (no Docker, no nested virtualization). For WSL-less Windows the eventual answer is a separate backend over Docker's "Docker Sandboxes" micro-VM product.

## What this backend declares

**`Isolation.CONTAINER`.** A container shares the host kernel, below `SandboxRouter`'s default `min_isolation=Isolation.MICROVM` floor — construct the router with `min_isolation=Isolation.CONTAINER` and it admits this backend; leave the floor at its default and construction raises `SandboxBackendNotPermitted`. A Docker Desktop or Colima VM does not lift the rung: one shared VM kernel serves every container, the same shape `wslc`'s WSL 2 utility VM has, and the ladder classifies that at `container`. The declaration is a **constant** — no configuration raises it, because a security level the backend cannot verify must not become one the router repeats.

**`declarations.egress_modes = {closed}` by default, `{allowlist, closed}` with a proxy configured.** With no proxy configured every container is created `--network none`: a network namespace with only loopback, enforced by whichever kernel runs the container. That serves a workload declaring `Egress.CLOSED`, and **refuses** one declaring `Egress.ALLOWLIST` — the router never substitutes a mode, so denying everything is no longer offered as a stricter stand-in for a host list. Never `UNRESTRICTED`: a container backend always cuts or proxies, so it cannot serve a workload that asked to run open.

Set `egress_proxy_image` and `ALLOWLIST` joins the set: each sandbox gets its own internal network and a dual-homed filtering proxy, and the spec's allowlist is enforced by topology — the container has no route out except the proxy, which opens a CONNECT tunnel only to the hosts the spec names. The `HTTP_PROXY`/`HTTPS_PROXY` variables set on the workload are how ordinary clients find the proxy, not what enforces the allowlist; the topology is. TLS is not decrypted, and the sandbox never resolves an external name itself. The proxy is shipped as source, not as an image you must trust: build it from the packaged recipe, whose only pinned dependency is its Azure Linux base.

```python
from maf_sandbox_docker import proxy_build_context, DockerSandboxConfig

print(f"docker build -t maf-egress-proxy:local {proxy_build_context()}")  # run this once
config = DockerSandboxConfig(egress_proxy_image="maf-egress-proxy:local")
```

**`Capability.FILES_OUT`, never `Capability.FILES_LIST`.** This backend reads declared outputs back out — `docker cp <container>:<path> -` streams a tar whose first 512-byte header carries the size, the entry type and any link target, so a file is statted and read from one stream with no stat command and no shell in the image. It does **not** enumerate directories: Docker has no engine-level primitive for it, which is exactly why the protocol splits enumeration into `FILES_LIST`. A kind that cannot name its outputs in advance requires that capability and is refused here — served instead by a backend, like ACAS, that has native listing.

**Every path component is checked, not just the last one.** A symlink is refused on the tar entry's type bit only when it is the entry being tarred; the engine resolves the path daemon-side, so a guest that points `out` at `/etc` gets a stat of `out/hostname` describing a regular file with the parent link nowhere in it. `stat_file` and `read_file` therefore stat every parent component from the **filesystem root** down — not from the working directory, whose own ancestors the guest can replace just as easily: with `/maf-sandbox -> /` unchecked, `/maf-sandbox/work` stats as a real directory and serves `/`. The check itself is `maf_sandbox.paths.refuse_symlinked_ancestors`, not a copy living here: this backend passes it the unconfined tar-header stat above. A link is refused as a *confinement* failure and any other non-directory as an ordinary `ENOTDIR` — the entry comes back as `EntryKind.SYMLINK` or `EntryKind.OTHER`, so a caller can tell an escape from a guest tripping over its own fifo. One residual stays open: the check and the read are separate calls and `docker cp` has no no-follow form, so a guest that swaps a stat-ed component for a link in between is followed.

Whether that is actually enforced is not this package's own claim either. `maf_sandbox.conformance` is the shared suite every backend serving `FILES_OUT` answers, and this is the one backend that answers it **against a real engine on every pull request** — a container on the runner, a hostile layout planted in it through the public surface, and the probes attacking that.

**`declarations.os_families = {POSIX}` on a Linux daemon, and nothing on any other — asked for with `create`.** A workload states the guest shape its commands are written for in `SandboxSpec.requires_os_family`, and the router refuses a backend whose `os_families` does not hold it. This backend reads the answer from its own daemon (`docker version --format '{{.Server.Os}}'`) rather than taking it as configuration: a host would be restating what the engine already knows, and a value it typed could only go stale against the engine that has to back it. The read needs an `await`, so it lives in a factory — `__init__` makes no engine calls, and a blocking read in a constructor would do subprocess I/O on your event loop against a daemon that can hang rather than refuse.

```python
from maf_sandbox_docker import DockerSandboxBackend, DockerSandboxConfig


async def wired() -> DockerSandboxBackend:
    # Asks the daemon once, and declares what it answered.
    return await DockerSandboxBackend.create(DockerSandboxConfig())


# Unchanged, and declares no family — which refuses only a spec that names one.
backend = DockerSandboxBackend(DockerSandboxConfig())
```

**A `windows` daemon declares nothing, not `WINDOWS`.** Everything this backend runs in a guest is POSIX: `sh -c` for a string command, `rm -rf` for a removal, and `/`-rooted path arithmetic that refuses a backslash outright. Declaring `WINDOWS` would be a promise no code path here backs, and would move the failure from the guest's first command to the router's certificate. A daemon that will not answer declares nothing for the same reason: silence refuses a spec that asks for a family and serves every spec that does not, which is what this backend did before it asked at all.

**The declaration is a snapshot, so a cold acquire re-asks.** The client resolves `DOCKER_HOST` and the active context on *every* call, so switching Docker Desktop to Windows containers moves the engine under a running backend. The router matched the old answer when your tool was attached and cannot ask again — so an `acquire` that is about to create *or restart* a container reads the daemon once more, ahead of the container and its network, and raises `SandboxOsFamilyNotSupported` if the answer changed. A restart counts because one that fails falls through to a create, and one that succeeds hands out a container from whichever daemon is answering now. Reusing an already-running container does not re-ask — that would cost a round trip on every tool call — and a backend built by the plain constructor never asks at all.

## The backend

`DockerSandboxBackend` implements `maf_sandbox.SandboxBackend`:

| | |
|---|---|
| `acquire(key, spec)` | get-or-create, keyed `(scope, thread, agent, kind)`. A running container is reused, a stopped one started, a missing one created; an absent image is pulled explicitly first so a cold pull does not ride the lifecycle timeout |
| `write_file(path, content, *, working_directory)` | a confined tar on stdin to `cp - <container>:/`, carrying the file and an explicit entry for every missing directory at or below `working_directory`, each stamped with the user `Config.User` resolves to — or with root's `0:0` on an image that names a user and answers no identity probe ([#741](https://github.com/sokolaidev/maf-extensions/issues/741)); `str` is UTF-8, `bytes` is written as given |
| `stat_file` / `read_file` | the `FILES_OUT` pull surface — stat from the first tar header of `docker cp`, read from the same stream; symlinks and other non-regular entries refused on the header type, every parent component refused unless it is a real directory, a body over the caller's cap refused rather than truncated |
| `dispose(key)` | `rm -f` on every kind's container the key names, with the proxy and network of an allowlisted one |
| `dispose_scope(scope, thread)` | delete every container for a conversation — **by label, read back from docker**, not from process memory |
| `isolation` | `container`, unconditionally |
| `declarations.egress_modes` | `{closed}`, or `{closed, allowlist}` when `egress_proxy_image` is set |
| `declarations.capabilities` | `{EXEC, FILES_IN, FILES_OUT, FILES_DELETE, HOST_TOOLS}` |
| `declarations.limits` | the transfer ceilings a spec may not exceed, per direction |
| `declarations.os_families` | `{posix}` when the daemon reports `linux`, and `frozenset()` for every other answer — filled by `DockerSandboxBackend.create`, empty from the plain constructor |

Container names are derived from the key and kind rather than remembered, so `acquire` and `dispose` agree on one without a registry to keep in sync. Labels are the durable record `dispose_scope` selects on, and their values are digested when they are long or carry a separator — the same mapping on both sides, because transforming one and not the other makes a purge quietly select nothing.

No bind mounts, no host paths, and never the Docker socket cross into a sandbox — files go in and out only through `docker cp`. The hardening flags `--security-opt no-new-privileges` and `--pids-limit` go on every container; `--cap-drop ALL`, `--memory` and `--cpus` are opt-in through the config.

`stop` is never used. A container whose init process ignores `SIGTERM` takes ten seconds to stop and a fraction of a second to remove, and there is nothing in a sandbox worth waiting for.

## Upgrading to 0.10

**The four optional declarations moved into one `BackendDeclarations`.** `maf-sandbox` 0.26 replaced `capabilities`, `limits`, `egress_modes` and `os_families` as backend attributes with one `declarations` object holding them as fields, and this backend follows it. A host that read them off the backend gets an `AttributeError`:

| Was | Is |
| --- | --- |
| `backend.capabilities` | `backend.declarations.capabilities` |
| `backend.limits` | `backend.declarations.limits` |
| `backend.egress_modes` | `backend.declarations.egress_modes` |
| `backend.os_families` | `backend.declarations.os_families` |

Nothing about what this backend declares changed — the values, and how they are derived from the config, are exactly as they were. `maf-sandbox`'s own README carries the reasoning and what a backend author has to do.

## Upgrading to 0.7

`0.7.0` requires `maf-sandbox` 0.19, which made the egress mode a thing a workload declares and a set a backend enforces.

**`egress` is replaced by `egress_modes: frozenset[Egress]`.** A host that read `backend.egress` gets an `AttributeError`; read `backend.egress_modes` instead. Nothing in the wiring changes — the set is still derived from `egress_proxy_image` exactly as the single value was.

**Without a proxy image this backend now refuses an allowlist workload rather than confining it further.** That is the upgrade's one behavioural break, and it is most likely to reach you through a kind whose default asks for one — `maf-sandbox-bicep` 0.9 does:

```
SandboxEgressNotEnforced: sandbox backend 'docker' cannot enforce the 'allowlist'
egress the 'bicep' workload runs in (it enforces closed).
```

Configure `egress_proxy_image` if the workload is meant to reach the hosts it names, or ask the kind for `Egress.CLOSED` if it is meant to run offline. The old behaviour — serve it anyway, warn, and let the workload fail at the first fetch — is gone deliberately: a fetch failure deep in a tool call is a worse report than a refusal at attach.

---

Maintained by [SOKOLAI BV](https://www.sokol.ai).
