n4nAI

Sandboxing AutoGen code execution with Docker containers

A step-by-step guide to sandboxing AutoGen code execution with Docker containers, including container setup, agent configuration, and verification.

n4n Team4 min read884 words

Audio narration

Coming soon — every post will get a voice note here.

AutoGen’s code-executing agents are powerful, but running arbitrary LLM-generated code on your host is a security risk. The standard approach — DockerCommandLineCodeExecutor — gives you isolation, but the documentation leaves gaps around networking, persistence, and production hardening. This guide walks through a complete, runnable setup for autogen code execution docker sandbox environments that you can actually ship.

Step 1: Define the container image

Start with a minimal base. You need Python, the AutoGen runtime dependencies, and any packages your agents will import. Create a Dockerfile in your project root:

# Dockerfile
FROM python:3.11-slim

# Prevent interactive prompts during build
ENV DEBIAN_FRONTEND=noninteractive

# Install system dependencies: git for pip installs from VCS, curl for healthchecks
RUN apt-get update && apt-get install -y --no-install-recommends \
    git \
    curl \
    && rm -rf /var/lib/apt/lists/*

# Create a non-root user for execution
RUN groupadd -r autogen && useradd -r -g autogen -m -d /home/autogen -s /bin/bash autogen

# Set working directory
WORKDIR /workspace

# Copy requirements first for layer caching
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy application code
COPY . .

# Ensure the workspace is writable by the autogen user
RUN chown -R autogen:autogen /workspace

# Switch to non-root user
USER autogen

# Default command keeps container alive for `docker exec` or attach
CMD ["sleep", "infinity"]

Your requirements.txt should pin versions:

# requirements.txt
autogen-agentchat==0.2.0
autogen-ext==0.2.0
docker==7.0.0

Build the image:

docker build -t autogen-sandbox:latest .

Verify the build:

docker run --rm autogen-sandbox:latest python -c "import autogen; print(autogen.__version__)"

You should see the version printed without errors.

Step 2: Configure the Docker executor

AutoGen’s DockerCommandLineCodeExecutor manages the container lifecycle. You need to decide on three things up front: network policy, volume mounts, and resource limits.

Create executor_config.py:

# executor_config.py
from autogen_ext.code_executors.docker import DockerCommandLineCodeExecutor

def create_executor(
    image: str = "autogen-sandbox:latest",
    work_dir: str = "/workspace",
    timeout: int = 60,
    memory_limit: str = "512m",
    cpu_limit: float = 1.0,
    network_disabled: bool = True,
    volumes: dict | None = None,
) -> DockerCommandLineCodeExecutor:
    """
    Create a configured Docker code executor.

    Args:
        image: The Docker image tag to use.
        work_dir: Working directory inside the container.
        timeout: Execution timeout in seconds.
        memory_limit: Docker memory limit (e.g., "512m", "1g").
        cpu_limit: CPU quota as fraction of one core (1.0 = 1 full core).
        network_disabled: If True, disable container networking.
        volumes: Host-to-container volume mappings. Keys are host paths,
                 values are dicts with 'bind' (container path) and 'mode' (ro/rw).
    """
    # Default: mount the current project read-only so agents can import local modules
    if volumes is None:
        import os
        host_workspace = os.path.abspath(".")
        volumes = {
            host_workspace: {"bind": work_dir, "mode": "ro"}
        }

    executor = DockerCommandLineCodeExecutor(
        image=image,
        work_dir=work_dir,
        timeout=timeout,
        container_kwargs={
            "mem_limit": memory_limit,
            "cpu_quota": int(cpu_limit * 100000),  # Docker uses microseconds
            "cpu_period": 100000,
            "network_disabled": network_disabled,
            "volumes": volumes,
            "user": "autogen",  # Run as non-root user
            "security_opt": ["no-new-privileges:true"],
            "cap_drop": ["ALL"],
            "read_only": True,  # Root filesystem read-only
            "tmpfs": {"/tmp": "size=100m,noexec,nosuid,nodev"},  # Writable /tmp
        },
    )
    return executor

Key hardening choices explained:

  • network_disabled=True prevents the container from making outbound connections. If your agents need to call APIs, you’ll need a more nuanced network policy (see Step 5).
  • read_only=True with a tmpfs mount at /tmp gives the executor a writable scratchpad without exposing the host filesystem.
  • cap_drop=["ALL"] and security_opt=["no-new-privileges:true"] strip Linux capabilities and prevent privilege escalation.
  • The user: "autogen" directive ensures code runs as the non-root user created in the Dockerfile.

Step 3: Wire the executor into your agent

AutoGen’s AssistantAgent (or CodeExecutorAgent in newer versions) accepts an executor instance. Here’s a minimal working example using the v0.2 API:

# agent_setup.py
from autogen_agentchat.agents import AssistantAgent
from autogen_agentchat.teams import RoundRobinGroupChat
from autogen_agentchat.messages import TextMessage
from autogen_ext.models.openai import OpenAIChatCompletionClient
from executor_config import create_executor

def create_coding_agent(
    model_client: OpenAIChatCompletionClient,
    executor: DockerCommandLineCodeExecutor,
    name: str = "coder",
    system_message: str | None = None,
) -> AssistantAgent:
    default_system = (
        "You are a helpful coding assistant. Write Python code to solve tasks. "
        "Execute code using the provided tool and return the result. "
        "Always print outputs so the user can see them."
    )
    return AssistantAgent(
        name=name,
        model_client=model_client,
        tools=[executor],
        system_message=system_message or default_system,
    )

async def run_task(task: str) -> str:
    # Initialize model client (replace with your provider)
    model_client = OpenAIChatCompletionClient(
        model="gpt-4o-mini",
        # api_key="...",  # Set via env var OPENAI_API_KEY
    )

    executor = create_executor()
    await executor.start()

    try:
        agent = create_coding_agent(model_client, executor)
        team = RoundRobinGroupChat([agent], max_turns=10)

        result = await team.run(task=task)
        return result.messages[-1].content if result.messages else "No output"
    finally:
        await executor.stop()
        await model_client.close()

Run it with a test script:

# test_agent.py
import asyncio
from agent_setup import run_task

async def main():
    task = "Write a Python function that computes the first 20 Fibonacci numbers and prints them."
    output = await run_task(task)
    print(output)

if __name__ == "__main__":
    asyncio.run(main())

Execute:

python test_agent.py

You should see the Fibonacci sequence printed in the agent’s response. The code ran inside the Docker container, not on your host.

Step 4: Verify isolation

Before trusting this in production, verify the sandbox boundaries work as expected.

Test 1: Host filesystem access

# test_isolation.py
import asyncio
from agent_setup import run_task

async def main():
    # Attempt to read a sensitive file from host
    task = (
        "Try to read /etc/passwd using Python's open() function. "
        "Print the first 5 lines if successful."
    )
    output = await run_task(task)
    print("=== Host filesystem test ===")
    print(output)

if __name__ == "__main__":
    asyncio.run(main())

Expected result: The agent should report a PermissionError or FileNotFoundError. The host’s /etc/passwd is not mounted, and the container’s own /etc/passwd is minimal.

Test 2: Network egress

# test_network.py
import asyncio
from agent_setup import run_task

async def main():
    task = (
        "Use the requests library to GET http://httpbin.org/ip. "
        "Print the response text."
    )
    output = await run_task(task)
    print("=== Network egress test ===")
    print(output)

if __name__ == "__main__":
    asyncio.run(main())

With network_disabled=True, this should fail with a connection error. If you need network access, see Step 5.

Test 3: Resource limits

# test_resources.py
import asyncio
from agent_setup import run_task

async def main():
    # Try to allocate more than 512MB
    task = (
        "Create a list that consumes 600MB of memory: "
        "data = [b'x' * 1024 * 1024 for _ in range(600)]. "
        "Print 'allocated' if successful."
    )
    output = await run_task(task)
    print("=== Memory limit test ===")
    print(output)

if __name__ == "__main__":
    asyncio.run(main())

The container should be OOM-killed, and the executor will surface a timeout or error. This confirms the memory limit is enforced.

Step 5: Production networking — allowlist only what you need

Real agents often need to call external APIs. Disabling networking entirely breaks that. Instead, use Docker’s network modes with a sidecar proxy or egress firewall. The simplest production-grade approach: create a dedicated Docker network with no internet gateway, then attach a proxy container that allowlists specific destinations.

Create docker-compose.yml:

# docker-compose.yml
version: "3.8"

services:
  autogen-sandbox:
    build: .
    image: autogen-sandbox:latest
    user: autogen
    read_only: true
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    tmpfs:
      - /tmp:size=100m,noexec,nosuid,nodev
    mem_limit: 512m
    cpus: "1.0"
    networks:
      - sandbox-internal
    volumes:
      - .:/workspace:ro
    command: sleep infinity

  egress-proxy:
    image: alpine/socat:latest
    # Example: forward TCP 8080 -> api.openai.com:443
    # In practice, use a proper proxy (mitmproxy, squid, or cloud NAT)
    command: >
      sh -c "apk add --no-cache openssl && 
      socat TCP-LISTEN:8080,fork,reuseaddr OPENSSL:api.openai.com:443,verify=0"
    networks:
      - sandbox-internal
    deploy:
      resources:
        limits:
          memory: 64M
          cpus: "0.1"

networks:
  sandbox-internal:
    driver: bridge
    internal: true  # No gateway to internet

Update executor_config.py to use this network:

# executor_config.py (add to create_executor)
def create_executor(
    # ... existing params ...
    network_name: str = "sandbox-internal",
    network_disabled: bool = False,  # Override: we use custom network
) -> DockerCommandLineCodeExecutor:
    # ... existing code ...
    container_kwargs = {
        # ... existing kwargs ...
        "network_disabled": network_disabled,
        "network_mode": network_name,  # Connect to internal network
        # Remove network_disabled if using network_mode
    }
    # Remove network_disabled from container_kwargs if network_mode is set
    container_kwargs.pop("network_disabled", None)
    # ... rest of function ...

Now agents can reach http://egress-proxy:8080 (which forwards to api.openai.com:443) but nothing else. Adjust the proxy configuration for your actual dependencies.

Step 6: Persist state across executions

By default, each executor.start() creates a fresh container. For multi-turn conversations where the agent needs to reuse variables or installed packages, reuse the same container.

Modify agent_setup.py:

# agent_setup.py (updated)
from autogen_agentchat.agents import AssistantAgent
from autogen_agentchat.teams import RoundRobinGroupChat
from autogen_ext.models.openai import OpenAIChatCompletionClient
from executor_config import create_executor

# Module-level executor for reuse
_shared_executor: DockerCommandLineCodeExecutor | None = None

async def get_shared_executor() -> DockerCommandLineCodeExecutor:
    global _shared_executor
    if _shared_executor is None:
        _shared_executor = create_executor()
        await _shared_executor.start()
    return _shared_executor

async def run_task_with_persistence(task: str) -> str:
    model_client = OpenAIChatCompletionClient(model="gpt-4o-mini")
    executor = await get_shared_executor()

    agent = create_coding_agent(model_client, executor)
    team = RoundRobinGroupChat([agent], max_turns=10)

    result = await team.run(task=task)
    return result.messages[-1].content if result.messages else "No output"

async def shutdown():
    global _shared_executor
    if _shared_executor:
        await _shared_executor.stop()
        _shared_executor = None

Test persistence:

# test_persistence.py
import asyncio
from agent_setup import run_task_with_persistence, shutdown

async def main():
    # Turn 1: Define a variable
    out1 = await run_task_with_persistence(
        "Create a variable `secret = 42` and print it."
    )
    print("Turn 1:", out1)

    # Turn 2: Reference the variable
    out2 = await run_task_with_persistence(
        "Print the value of `secret` defined in the previous turn."
    )
    print("Turn 2:", out2)

    await shutdown()

if __name__ == "__main__":
    asyncio.run(main())

Both turns should print 42. The container stays alive between calls, preserving the Python process state.

Step 7: Observability — capture logs and metrics

You need visibility into what the executor is doing. Hook into Docker’s logging driver and export container stats.

Update create_executor to add logging configuration:

# executor_config.py (add to container_kwargs)
container_kwargs = {
    # ... existing kwargs ...
    "log_config": {
        "type": "json-file",
        "config": {
            "max-size": "10m",
            "max-file": "3",
            "labels": "autogen_executor",
        },
    },
    "labels": {
        "autogen.executor": "true",
        "autogen.project": "my-project",
    },
}

Stream logs in real time:

# monitor.py
import docker
import json

def tail_executor_logs(container_name_prefix: str = "autogen"):
    client = docker.from_env()
    containers = client.containers.list(
        filters={"label": "autogen.executor=true"},
        all=True,
    )
    for container in containers:
        print(f"=== Logs for {container.name} ===")
        for line in container.logs(stream=True, follow=False, tail=50):
            log_entry = json.loads(line.decode("utf-8"))
            print(f"  [{log_entry.get('timestamp', '')}] {log_entry.get('log', '').strip()}")

For metrics, poll container.stats(stream=False) periodically and ship to Prometheus or your observability stack. The key metrics: memory_stats.usage, cpu_stats.cpu_usage.total_usage, and pids_stats.current.

Step 8: CI/CD integration

Build and test the sandbox image in your pipeline. This catches dependency drift and security regressions early.

.github/workflows/sandbox.yml:

name: Autogen Sandbox

on:
  push:
    paths:
      - "Dockerfile"
      - "requirements.txt"
      - "executor_config.py"
      - "agent_setup.py"
  pull_request:

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build sandbox image
        run: docker build -t autogen-sandbox:test .

      - name: Run unit tests in sandbox
        run: |
          docker run --rm \
            --memory=512m \
            --cpus=1.0 \
            --network=none \
            --user=autogen \
            --read-only \
            --tmpfs=/tmp:size=100m,noexec,nosuid,nodev \
            --cap-drop=ALL \
            --security-opt=no-new-privileges:true \
            -v ${{ github.workspace }}:/workspace:ro \
            autogen-sandbox:test \
            python -m pytest test_isolation.py test_network.py test_resources.py -v

      - name: Scan image for vulnerabilities
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: autogen-sandbox:test
          format: sarif
          output: trivy-results.sarif

      - name: Upload Trivy results
        uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: trivy-results.sarif

This workflow builds the image, runs your isolation tests inside the sandbox (with the same hardening flags), and scans for CVEs with Trivy.

Step 9: Handling provider fallback at the gateway layer

If your agents call LLMs through a gateway, the sandbox network policy should allow only the gateway endpoint — not every provider. This is where a gateway like n4n.ai simplifies the allowlist: one egress rule for the gateway, and the gateway handles provider fallback, cache-control forwarding, and per-token metering internally. Your egress-proxy then needs only a single destination.

Verification checklist

Before deploying, confirm each item:

  • Container runs as non-root user (autogen)
  • Root filesystem is read-only
  • No NET_RAW, NET_ADMIN, or other dangerous capabilities
  • Memory and CPU limits enforced (tested with OOM/CPU throttle)
  • Network either disabled or restricted to internal network with allowlisted egress
  • Host filesystem mounted read-only at most; no /var/run/docker.sock exposure
  • Logs captured and rotated
  • Image scanned for vulnerabilities in CI
  • Persistence behavior documented and tested (fresh container vs. reused)
  • Timeout configured on executor (60s default; adjust for your workloads)

Common pitfalls

Pip install at runtime: Agents sometimes try to pip install packages. This fails in a read-only container with no network. Pre-install all dependencies in the image, or mount a writable virtualenv directory (with network enabled for PyPI).

Large outputs: The default timeout may kill long-running computations. Increase timeout in create_executor or implement streaming results via file writes to the mounted volume.

Signal handling: DockerCommandLineCodeExecutor.stop() sends SIGTERM to the container. If your code spawns child processes, they may become orphans. Use docker kill --signal=KILL in a finally block for hard cleanup.

Version skew: AutoGen’s executor API changed between v0.1 and v0.2. Pin autogen-ext and test upgrades in a branch.


You now have a hardened, observable, CI-tested autogen code execution docker sandbox. The same pattern applies to other code-executing frameworks — swap the executor implementation, keep the Docker hardening.

Tagsautogendockersandboxingcode-execution

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All autogen code-executing agents posts →