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=Trueprevents 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=Truewith atmpfsmount at/tmpgives the executor a writable scratchpad without exposing the host filesystem.cap_drop=["ALL"]andsecurity_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.sockexposure - 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.