Contents · 18 sections↓
- 01How do you set up AutoGen Docker task isolation?
- 02How does DockerCommandLineCodeExecutor work?
- 03When to use the Docker executor (and when not to)
- 04What you need
- 05Step 1: Install and run the AutoGen Docker code executor
- 06Step 2: Configure the image, work_dir, timeout and cleanup
- 07Step 3: Run agent code with CodeExecutorAgent
- 08Step 4: Check what the default container protects against
- 09Step 5: Harden the container with a custom image and Docker limits
- 10Step 6: Give every task its own container
- 11The complete example
- 12Running it in production
- 13Common problems and fixes
- 14What are the alternatives to AutoGen's Docker executor?
- 15AutoGen Docker executor vs Promptise sandbox
- 16Limitations
- 17Frequently asked questions
- 18Next steps
AutoGen Docker task isolation means running the code your agents write inside a Docker container instead of on your machine, and giving each task its own container. This guide is for developers who use AutoGen's DockerCommandLineCodeExecutor and want to know what it isolates, what it doesn't, and how to set it up properly. At the end you will have a team of agents that runs every task in a fresh, locked-down container with no network and hard resource limits.
How do you set up AutoGen Docker task isolation?
Install autogen-ext[docker], create a DockerCommandLineCodeExecutor and pass it to a CodeExecutorAgent. For task isolation, create a new executor for every task inside async with: each one starts its own container with its own temporary work directory and removes both when the task ends. A shared executor keeps files, packages and processes from one task to the next. The default container still runs as root with internet access and no limits, so add a non-root image and Docker limits before you run untrusted code.
One executor per task gives one container per task. Leave container_name unset so each gets a unique name.
The defaults isolate your machine, not the network. Host environment variables and files outside work_dir stay out.
The executor has no network, memory, CPU or user parameters. Use a custom image and docker update right after start, as step 5 shows.
Never use a folder with secrets as `work_dir`. It is mounted read-write at /workspace.
AutoGen is archived. Version 0.7.5 is the last release, so pin it and plan a migration.
[02]
How does DockerCommandLineCodeExecutor work?
DockerCommandLineCodeExecutor lives in autogen_ext.code_executors.docker. It starts one long-lived container on start() (or async with) and runs every code block in it with docker exec:
your Python process (host) Docker
────────────────────────── ──────
executor.start() ───────────────────────────▶ create + start container from python:3-slim
name: autogen-code-exec-<uuid>
command: /bin/sh (idle, waits for exec)
mount: <work_dir> -> /workspace (read-write)
network: default bridge (internet access)
a fenced python or sh block arrives
│ written to <work_dir>/tmp_code_<sha256>.python
▼
execute_code_blocks() ──────────────────────▶ docker exec: timeout 60 python tmp_code_<sha256>.python
◀────────────── output + exit code ───────── (exit 124 and "Timeout" if the limit is hit)
executor.stop() ────────────────────────────▶ docker stop, then auto_remove deletes the containerThree things follow from this design:
State persists inside one executor. Files, packages and processes from one code block are there for the next: useful within a task, a leak between tasks.
The code file is written on the host first. The container reads it through the mount, and sees everything else in work_dir too.
The image needs `sh`, `python` and `timeout`. The executor wraps every run in the timeout command, so an image without coreutils or BusyBox fails.
A CodeExecutorAgent sits on top: it extracts fenced Python and sh blocks from messages, asks an optional approval_func, and passes approved blocks to the executor. A *task* here is one team.run(task=...) call.
[03]
When to use the Docker executor (and when not to)
Option | What is isolated | Use it when |
|---|---|---|
LocalCommandLineCodeExecutor | Nothing. Code runs on your machine as you | Trusted code in development only |
Docker executor, one shared container | Your machine; tasks share one container | One user, one long session that needs state |
Docker executor, one container per task | Your machine and every other task | Several tasks, users or parallel runs |
Per task, locked down (step 5) | Plus no network, non-root user, CPU, memory and process limits | Production and any untrusted input |
gVisor or a microVM (E2B, Azure dynamic sessions) | A separate kernel or VM per session | Multi-tenant services and hostile input |
Containers share the host kernel, and a kernel or runtime bug, such as the 2019 runc escape (CVE-2019-5736), can break the boundary. For code from anonymous users, add gVisor or use a microVM.
[04]
What you need
Python 3.10 or later. The examples ran on Python 3.12.
AutoGen 0.7.5, the last release: pip install "autogen-agentchat==0.7.5" "autogen-ext[docker,openai]==0.7.5".
Docker Desktop (macOS, Windows) or Docker Engine (Linux), running. docker info must print a Server: section. The examples ran on macOS with Docker Desktop and Docker Engine 27.4.
An OpenAI API key in OPENAI_API_KEY for step 3 and the complete example. They use gpt-5-mini.
About 30 minutes. The first start pulls python:3-slim, which takes a while.
[05]
Step 1: Install and run the AutoGen Docker code executor
Start with the executor alone to check that Python reaches Docker. This is the example from the AutoGen command-line executors docs, with a code block that prints where it runs:
import asyncio
from pathlib import Path
from autogen_core import CancellationToken
from autogen_core.code_executor import CodeBlock
from autogen_ext.code_executors.docker import DockerCommandLineCodeExecutor
async def main() -> None:
work_dir = Path("coding")
work_dir.mkdir(exist_ok=True)
async with DockerCommandLineCodeExecutor(work_dir=work_dir) as executor:
result = await executor.execute_code_blocks(
code_blocks=[CodeBlock(language="python", code="import platform; print('Hello from', platform.platform())")],
cancellation_token=CancellationToken(),
)
print(result)
asyncio.run(main())CommandLineCodeResult(exit_code=0, output='Hello from Linux-6.10.14-linuxkit-aarch64-with-glibc2.41\n', code_file='coding/tmp_code_630489251d3275d2f3d34b34d8e9c3e7c25d7c0daafed72a4e03c2939fe3d015.python')The code ran on Linux (the Docker Desktop VM), not macOS. The code file stays in coding/ because delete_tmp_files defaults to False. If Docker isn't running, start() fails with RuntimeError: Failed to connect to Docker. Please ensure Docker is installed and running.
[06]
Step 2: Configure the image, work_dir, timeout and cleanup
Every option is a constructor argument. This example pins the image, gives the task a temporary folder and sets a short timeout to show what happens when code runs too long:
import asyncio
import tempfile
from autogen_core import CancellationToken
from autogen_core.code_executor import CodeBlock
from autogen_ext.code_executors.docker import DockerCommandLineCodeExecutor
async def main() -> None:
with tempfile.TemporaryDirectory(prefix="task-") as work_dir:
executor = DockerCommandLineCodeExecutor(
image="python:3.12-slim", # pin a tag (or a digest) instead of python:3-slim
work_dir=work_dir, # host folder mounted read-write at /workspace
timeout=10, # seconds per code block, enforced with `timeout` in the container
auto_remove=True, # delete the container when it stops (default)
stop_container=True, # stop it on stop(), on context exit and at process exit (default)
delete_tmp_files=True, # remove the generated code files after each run
)
async with executor:
token = CancellationToken()
ok = await executor.execute_code_blocks(
[CodeBlock(language="sh", code="python --version && pwd && ls -la")], token
)
print(f"exit={ok.exit_code}\n{ok.output}")
slow = await executor.execute_code_blocks(
[CodeBlock(language="python", code="import time\nwhile True:\n time.sleep(1)")], token
)
print(f"exit={slow.exit_code} output={slow.output!r}")
print("container:", executor.container_name)
asyncio.run(main())exit=0
Python 3.12.15
/workspace
total 8
drwx------ 2 root root 64 Oct 20 18:00 .
drwxr-xr-x 1 root root 4096 Oct 20 18:00 ..
-rw-r--r-- 1 root root 33 Oct 20 18:00 tmp_code_1cea50637c90bac714e25d2f65a4cc025187eea779855fb74c941965855ecdca.sh
exit=124 output='\n Timeout'
container: autogen-code-exec-a5c2530b-c1d1-4a4b-ac10-67c687a07371The shell block ran in /workspace, the temporary folder on the host. The endless loop was stopped after 10 seconds with exit code 124 and Timeout in the output. The timeout applies to each code block, not to the whole task. Afterwards no container was left: auto_remove=True deleted it.
The parameters of DockerCommandLineCodeExecutor in autogen-ext 0.7.5:
Parameter | Default | What it does |
|---|---|---|
image | python:3-slim | Image to run. Pulled on first start if missing |
container_name | autogen-code-exec-<uuid> | Container name. start() force-removes any container with the same name |
timeout | 60 | Seconds per code block, enforced with timeout inside the container |
work_dir | a temporary folder | Host folder mounted read-write at /workspace. A temporary one is deleted on stop() |
bind_dir | work_dir | Host path to mount, when AutoGen itself runs in a container |
auto_remove | True | Delete the container when it stops |
stop_container | True | Stop the container on stop(), on context exit and at process exit |
extra_volumes | none | More mounts, for example {"/data": {"bind": "/mnt/data", "mode": "ro"}} |
extra_hosts | none | Extra /etc/hosts entries, for example {"api.internal": "10.0.0.5"} |
device_requests | none | GPUs, for example [DeviceRequest(count=-1, capabilities=[["gpu"]])] |
init_command | none | Shell command run when the container starts |
functions | none | Python functions written to a functions module the code can import |
delete_tmp_files | False | Delete the code files after each run |
Mount extra data read-only ("mode": "ro"). There is no parameter for the network, memory, CPU, processes or user; step 5 handles that.
[07]
Step 3: Run agent code with CodeExecutorAgent
Now connect the executor to agents. An AssistantAgent writes the code, and a CodeExecutorAgent without a model runs every fenced block it finds and replies with the output. A RoundRobinGroupChat alternates between them until the coder says TERMINATE:
import asyncio
import tempfile
from autogen_agentchat.agents import ApprovalRequest, ApprovalResponse, AssistantAgent, CodeExecutorAgent
from autogen_agentchat.conditions import MaxMessageTermination, TextMentionTermination
from autogen_agentchat.teams import RoundRobinGroupChat
from autogen_agentchat.ui import Console
from autogen_ext.code_executors.docker import DockerCommandLineCodeExecutor
from autogen_ext.models.openai import OpenAIChatCompletionClient
def review(request: ApprovalRequest) -> ApprovalResponse:
"""Called before every execution. Log it here, or ask a person."""
print(f"[approval] running {len(request.code.splitlines())} lines")
return ApprovalResponse(approved=True, reason="logged")
async def main() -> None:
model_client = OpenAIChatCompletionClient(model="gpt-5-mini") # reads OPENAI_API_KEY
with tempfile.TemporaryDirectory(prefix="task-") as work_dir:
async with DockerCommandLineCodeExecutor(image="python:3.12-slim", work_dir=work_dir, timeout=30) as executor:
coder = AssistantAgent(
"coder",
model_client=model_client,
system_message=(
"Solve tasks by writing one Python script in a ```python block. "
"A separate agent runs it and shows you the output. "
"When the output answers the task, reply with the answer and the word TERMINATE."
),
)
runner = CodeExecutorAgent("runner", code_executor=executor, approval_func=review)
team = RoundRobinGroupChat(
[coder, runner],
termination_condition=TextMentionTermination("TERMINATE") | MaxMessageTermination(8),
)
await Console(team.run_stream(task="How many prime numbers are there below 100,000?"))
await model_client.close()
asyncio.run(main())---------- TextMessage (user) ----------
How many prime numbers are there below 100,000?
---------- TextMessage (coder) ----------
```python
# Count prime numbers below 100,000 using the Sieve of Eratosthenes
def count_primes_below(n):
if n <= 2:
return 0
sieve = bytearray(b"\x01") * n
sieve[0:2] = b"\x00\x00" # 0 and 1 are not prime
p = 2
while p * p < n:
if sieve[p]:
step = p
start = p * p
sieve[start:n:step] = b"\x00" * (((n - 1 - start) // step) + 1)
p += 1
return sum(sieve)
if __name__ == "__main__":
n = 100000
count = count_primes_below(n)
print(count)
```
[approval] running 22 lines
---------- TextMessage (runner) ----------
9592
---------- TextMessage (coder) ----------
9592
TERMINATEThe coder wrote a sieve, the runner executed it in the container and returned 9592, and the coder answered. The [approval] line is review() running before the execution; without an approval_func, AutoGen 0.7.5 warns that code runs "without human oversight". Use it for logging or a human check, not as a security filter: the container is the boundary.
sources=["coder"] makes the runner execute code only from named agents, so a code block in a user message doesn't run.
[08]
Step 4: Check what the default container protects against
Before you trust the container, look at it from the inside. This probe sets a fake key on the host, puts a fake .env file in work_dir, and runs a shell script in the default container:
"""python probe.py (default executor) or python probe.py --locked (step 5)"""
import asyncio
import os
import sys
from pathlib import Path
from autogen_core import CancellationToken
from autogen_core.code_executor import CodeBlock
from autogen_ext.code_executors.docker import DockerCommandLineCodeExecutor
from locked_down import LockedDownExecutor
PROBE = """
echo "user: $(id -un)"
echo "internet: $(python -c 'import urllib.request as u; print(u.urlopen("https://pypi.org/simple/", timeout=5).status)' 2>&1 | tail -n 1)"
echo "host machine: $(getent hosts host.docker.internal || echo 'does not resolve')"
echo "memory limit: $(cat /sys/fs/cgroup/memory.max)"
echo "process limit: $(cat /sys/fs/cgroup/pids.max)"
echo "OPENAI_API_KEY: ${OPENAI_API_KEY:-not set}"
echo ".env file: $(cat /workspace/.env 2>&1)"
touch /usr/probe 2>/dev/null && echo "write /usr: allowed" || echo "write /usr: denied"
"""
async def main() -> None:
os.environ["OPENAI_API_KEY"] = "sk-host-only" # set on the host only
work_dir = Path("project")
work_dir.mkdir(exist_ok=True)
(work_dir / ".env").write_text("DATABASE_URL=postgres://admin:hunter2@db/prod\n")
if "--locked" in sys.argv:
executor = LockedDownExecutor(image="agent-sandbox:1.0", work_dir=work_dir)
else:
executor = DockerCommandLineCodeExecutor(work_dir=work_dir)
async with executor:
result = await executor.execute_code_blocks([CodeBlock(language="sh", code=PROBE)], CancellationToken())
print(result.output)
if __name__ == "__main__":
asyncio.run(main())user: root
internet: 200
host machine: fdc4:f303:9324::254 host.docker.internal
memory limit: max
process limit: max
OPENAI_API_KEY: not set
.env file: DATABASE_URL=postgres://admin:hunter2@db/prod
write /usr: allowedWhat the default container does protect:
Your environment variables. OPENAI_API_KEY was set on the host but not in the container.
Your files outside `work_dir`. Your home folder, SSH keys and cloud credentials stay out.
The Docker daemon. The Docker socket isn't mounted, so code can't start containers on your machine.
The kernel, partly. Docker's default seccomp profile and capability set apply.
What it doesn't protect:
Anything in `work_dir`. The .env file was readable, and the mount is read-write. Point work_dir at your project and the code can read your secrets and change your source.
The network. The container reached PyPI, so code can download anything and send data anywhere. host.docker.internal resolves too, so services on your machine, such as a local database, are reachable.
Other containers. Containers on the default bridge can reach each other, including other tasks.
Resources. max means no limit. One fork bomb or a 50 GB array can starve the whole Docker host.
Root. The code runs as root and can change everything in the container.
[09]
Step 5: Harden the container with a custom image and Docker limits
Hardening comes in two parts. A custom image fixes the user and preinstalls packages. Docker limits fix the network and resources. Start with the image:
# Sandbox image for agent-generated code: packages baked in, no root.
FROM python:3.12-slim
RUN pip install --no-cache-dir pandas==2.2.3 matplotlib==3.9.4
RUN useradd --create-home --uid 10001 sandbox
USER sandbox
WORKDIR /workspacedocker build -t agent-sandbox:1.0 .Baking packages in matters once the network is off, because pip install stops working, and it makes runs reproducible.
DockerCommandLineCodeExecutor creates the container itself and has no parameter for limits, but Docker can change a running container. docker update sets memory, CPU and process limits, and docker network disconnect removes the network. The container sits idle on /bin/sh until the first code block, so a subclass can apply both right after start(), before any agent code runs:
"""DockerCommandLineCodeExecutor with no network, resource limits and a fast stop."""
import asyncio
from autogen_ext.code_executors.docker import DockerCommandLineCodeExecutor
async def docker_cli(*args: str, check: bool = True) -> None:
proc = await asyncio.create_subprocess_exec(
"docker", *args, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE
)
_, err = await proc.communicate()
if check and proc.returncode != 0:
raise RuntimeError(f"docker {' '.join(args)} failed: {err.decode().strip()}")
class LockedDownExecutor(DockerCommandLineCodeExecutor):
"""Locks the container down right after start, before any code runs."""
def __init__(self, *args, memory: str = "512m", cpus: float = 1.0, pids: int = 128,
network: bool = False, **kwargs) -> None:
super().__init__(*args, **kwargs)
self._memory, self._cpus, self._pids, self._network = memory, cpus, pids, network
async def start(self) -> None:
await super().start()
try:
await docker_cli(
"update", "--memory", self._memory, "--memory-swap", self._memory,
"--cpus", str(self._cpus), "--pids-limit", str(self._pids), self.container_name,
)
if not self._network:
await docker_cli("network", "disconnect", "bridge", self.container_name)
except Exception:
await self.stop() # never leave a half-configured container running
raise
async def stop(self) -> None:
# The container's PID 1 is /bin/sh, which ignores SIGTERM, so a plain
# `docker stop` waits out the 10-second grace period. Remove it at once.
await docker_cli("rm", "--force", self.container_name, check=False)
await super().stop()The same probe with python probe.py --locked:
user: sandbox
internet: urllib.error.URLError: <urlopen error [Errno -3] Temporary failure in name resolution>
host machine: does not resolve
memory limit: 536870912
process limit: 128
OPENAI_API_KEY: not set
.env file: DATABASE_URL=postgres://admin:hunter2@db/prod
write /usr: deniedThe code now runs as sandbox, the network is gone (DNS fails, so host.docker.internal doesn't resolve either), memory is capped at 512 MiB and processes at 128. The .env file is still readable: no container setting fixes that, so keep secrets out of work_dir. limits.py runs three bad code blocks in a LockedDownExecutor with a 256 MiB limit and a 10-second timeout:
memory exit=137 last line: ''
network exit=1 last line: 'ERROR: No matching distribution found for requests'
runaway exit=124 last line: 'Timeout'The 2 GB allocation was killed by the out-of-memory killer (exit 137, no output), pip install found no package index, and the busy loop hit the timeout.
The subclass also replaces stop(). The container's main process is /bin/sh, and a shell running as PID 1 ignores SIGTERM, so docker stop waits out Docker's 10-second grace period. Here a plain stop() took about 10 seconds and docker rm --force a fraction of a second, a difference you pay on every task.
A read-only root filesystem, --cap-drop, no-new-privileges and another runtime such as gVisor can only be set when a container is created. Set them as Docker daemon defaults (see "Running it in production") or use a sandbox that sets them for you.
[10]
Step 6: Give every task its own container
Now the isolation between tasks. This script runs task A, which writes a file to /workspace, a file to /tmp and leaves a process running, and then task B, which looks for all three. shared uses one executor for both. per-task creates a new executor per task. parallel runs three tasks at once:
"""python per_task.py shared | per-task | parallel"""
import asyncio
import sys
import time
from autogen_core import CancellationToken
from autogen_core.code_executor import CodeBlock
from locked_down import LockedDownExecutor
TASK_A = """
import subprocess
open("/workspace/notes.txt", "w").write("customer 42: overdue invoice")
open("/tmp/cache.json", "w").write("{}")
subprocess.Popen(["sleep", "600"], start_new_session=True) # outlives this script
print("task A: wrote two files, left a process running")
"""
TASK_B = """
import os, socket
procs = [open(f"/proc/{p}/cmdline").read().replace("\\0", " ").strip()
for p in os.listdir("/proc") if p.isdigit()]
print("task B in", socket.gethostname(),
"| workspace:", [f for f in os.listdir("/workspace") if not f.startswith("tmp_code")],
"| /tmp/cache.json:", os.path.exists("/tmp/cache.json"),
"| sleep running:", any(p.startswith("sleep") for p in procs))
"""
def new_executor() -> LockedDownExecutor:
# No work_dir or container_name: a temporary folder and a unique name per executor.
return LockedDownExecutor(image="agent-sandbox:1.0", timeout=30, delete_tmp_files=True)
async def run(executor: LockedDownExecutor, code: str) -> str:
result = await executor.execute_code_blocks([CodeBlock(language="python", code=code)], CancellationToken())
return result.output.strip()
async def run_task(code: str) -> str:
async with new_executor() as executor: # fresh container, removed on exit
return await run(executor, code)
async def main(mode: str) -> None:
if mode == "shared":
async with new_executor() as executor:
print(await run(executor, TASK_A))
print(await run(executor, TASK_B))
elif mode == "per-task":
print(await run_task(TASK_A))
print(await run_task(TASK_B))
else:
start = time.perf_counter()
for line in await asyncio.gather(*(run_task(TASK_B) for _ in range(3))):
print(line)
print(f"3 containers in {time.perf_counter() - start:.1f}s")
asyncio.run(main(sys.argv[1]))$ python per_task.py shared
task A: wrote two files, left a process running
task B in f6da1b7b9551 | workspace: ['notes.txt'] | /tmp/cache.json: True | sleep running: True$ python per_task.py per-task
task A: wrote two files, left a process running
task B in c79fb5e0ea90 | workspace: [] | /tmp/cache.json: False | sleep running: False$ python per_task.py parallel
task B in 972b9c71012d | workspace: [] | /tmp/cache.json: False | sleep running: False
task B in 69a30754e753 | workspace: [] | /tmp/cache.json: False | sleep running: False
task B in 7a4dfcbde130 | workspace: [] | /tmp/cache.json: False | sleep running: False
3 containers in 1.1sWith a shared container, task B saw task A's notes, its cache file and its background process. With one container per task it saw nothing: a new container, a new work_dir, a different hostname. In the parallel run, three containers started, ran and were removed in about a second, thanks to the fast stop from step 5.
Rules for per-task isolation:
Create the executor inside the task, never one module-level executor.
Leave `container_name` unset. start() force-removes any container with the same name, so two tasks with a fixed name kill each other: the first one's code returns exit code 137 with no output.
Leave `work_dir` unset, or make a new temporary folder per task. Copy only the input files the task needs into it.
Keep one executor for the whole task. The coder often needs files and packages from an earlier code block, so the boundary is the task, not the code block.
[11]
The complete example
The complete example combines the locked-down executor, a fresh container per task and a two-agent team. Each task gets a temporary folder with only its input file, and two tasks run at the same time:
autogen-docker-isolation/
├── Dockerfile # non-root image with pandas (step 5)
├── locked_down.py # LockedDownExecutor (step 5)
├── run_task.py # one container per task, two tasks in parallel
└── sales.csv # the input file"""Run agent tasks, each in its own locked-down Docker container.
docker build -t agent-sandbox:1.0 .
python run_task.py
"""
import asyncio
import shutil
import tempfile
from pathlib import Path
from autogen_agentchat.agents import ApprovalRequest, ApprovalResponse, AssistantAgent, CodeExecutorAgent
from autogen_agentchat.conditions import MaxMessageTermination, TextMentionTermination
from autogen_agentchat.teams import RoundRobinGroupChat
from autogen_ext.models.openai import OpenAIChatCompletionClient
from locked_down import LockedDownExecutor
SYSTEM = (
"Solve the task by writing one short Python script in a ```python block. Input files "
"are in /workspace. pandas is installed; there is no internet access. A separate agent "
"runs the script and shows you the output. When the output answers the task, reply "
"with a one-sentence answer, then TERMINATE on its own line."
)
def review(request: ApprovalRequest) -> ApprovalResponse:
print(f"[approval] {len(request.code.splitlines())} lines") # log, or ask a person
return ApprovalResponse(approved=True, reason="logged")
async def run_task(task: str, inputs: list[Path], model_client: OpenAIChatCompletionClient) -> str:
with tempfile.TemporaryDirectory(prefix="task-") as work_dir:
for path in inputs:
shutil.copy(path, work_dir) # the container sees these files and nothing else
executor = LockedDownExecutor(
image="agent-sandbox:1.0", work_dir=work_dir, timeout=60, memory="1g", delete_tmp_files=True
)
async with executor: # a fresh container for this task, removed at the end
coder = AssistantAgent("coder", model_client=model_client, system_message=SYSTEM)
runner = CodeExecutorAgent("runner", code_executor=executor, approval_func=review)
team = RoundRobinGroupChat(
[coder, runner],
termination_condition=TextMentionTermination("TERMINATE") | MaxMessageTermination(10),
)
result = await team.run(task=task)
answer = result.messages[-1].content.replace("TERMINATE", "").strip()
return f"{executor.container_name[:26]}: {answer}"
async def main() -> None:
model_client = OpenAIChatCompletionClient(model="gpt-5-mini") # reads OPENAI_API_KEY
tasks = [
("Which region had the highest total revenue in sales.csv?", [Path("sales.csv")]),
("By what percent did total revenue change from July to August in sales.csv?", [Path("sales.csv")]),
]
answers = await asyncio.gather(*(run_task(task, inputs, model_client) for task, inputs in tasks))
print("\n".join(answers))
await model_client.close()
if __name__ == "__main__":
asyncio.run(main())docker build -t agent-sandbox:1.0 .
export OPENAI_API_KEY=sk-...
python run_task.py[approval] 53 lines
[approval] 40 lines
autogen-code-exec-0a22063b: The region with the highest total revenue is west with a total of 120100.
autogen-code-exec-6b6c1edc: Total revenue increased by 4.63% from July to August.Each answer came from a different container, and both are correct: west has 120,100 in revenue, and the total rose from 148,900 to 155,800, or 4.63%.
[12]
Running it in production
Harden the Docker daemon
Flags the executor can't pass can be set for every container in /etc/docker/daemon.json (in Docker Desktop: Settings, Docker Engine), using keys from the dockerd reference:
{
"no-new-privileges": true,
"userns-remap": "default",
"icc": false
}no-new-privileges blocks setuid escalation, userns-remap maps container root to an unprivileged host user, and icc: false stops containers on the default bridge from talking to each other. For a kernel-level boundary, install gVisor and set "default-runtime": "runsc". These defaults apply to every container, so use a dedicated Docker host for agent code.
Keep secrets out of the sandbox
The container never inherits your environment, so keep it that way: no keys in init_command or extra_volumes, and never your project folder as work_dir. If code needs an API, expose it as a tool on the host or pass a short-lived token. See secrets management for AI agents.
Run AutoGen itself in a container
When your AutoGen app runs in Docker, the AutoGen docs recommend "Docker out of Docker": mount the host's Docker socket into the app container and set bind_dir to the host path of the work folder. The socket gives root-equivalent control of the host, so it belongs in the orchestrator only, never in the code container.
Clean up leaked containers
If the process is killed hard, the exit handler never runs and containers stay (so does every container with stop_container=False). Remove leftovers on a schedule:
docker ps -aq --filter name=autogen-code-exec- | xargs -r docker rm --forcePlan for capacity and audit
Cap parallel tasks with an asyncio.Semaphore, pin and pre-pull the image, and log every approved code block with the task ID. For human sign-off on risky code, see human approval for agent actions.
Plan the migration
The AutoGen README says the project is archived, gets no bug or security fixes, and points to Microsoft Agent Framework. Pin autogen-ext==0.7.5, keep Docker updated, and read AutoGen alternatives and migration.
[13]
Common problems and fixes
Symptom | Cause | Fix |
|---|---|---|
| The Docker daemon isn't running, or its socket is elsewhere | Start Docker Desktop or |
| Your user can't open /var/run/docker.sock | Add the user to the docker group (root-equivalent) or use rootless Docker |
| work_dir is outside Docker Desktop's shared folders (/Users, /Volumes, /private, /tmp, /var/folders) | Move work_dir under a shared folder, or add it in Settings, Resources, File sharing |
Slow or failing mounts on Windows | work_dir on a Windows path with WSL 2 | Run Python inside WSL 2 and keep work_dir in its Linux file system |
exit_code=124 and output Timeout | A code block ran longer than timeout, often a pip install with no network | Raise timeout, bake packages into the image, or split the work |
exit_code=137 with empty output | Out of memory, or another executor with the same container_name removed the container | Raise the memory limit; leave container_name unset |
Every task takes 10 seconds longer at the end | docker stop waits 10 seconds because /bin/sh as PID 1 ignores SIGTERM | Remove the container with docker rm --force in stop(), as LockedDownExecutor does |
| Code ran before start() or after stop() | Use async with, and keep the team run inside it |
On Linux, bind mounts keep numeric user IDs, so a non-root container user can't write to a work_dir it doesn't own. Build the image with your UID or chown the folder. Docker Desktop on macOS maps ownership.
[14]
What are the alternatives to AutoGen's Docker executor?
If you need stronger isolation or less setup, these are the realistic options besides Promptise (next section):
Option | Boundary | Hosted | Network by default |
|---|---|---|---|
AutoGen DockerCommandLineCodeExecutor | Docker container | No | On |
AutoGen ACADynamicSessionsCodeExecutor | Azure Container Apps dynamic session (Hyper-V) | Yes, Azure | Configurable |
E2B | Firecracker microVM per sandbox | Yes (enterprise self-hosting) | On |
AG2 1.x DockerSandbox | Docker container | No | Off, 512 MB memory |
Azure dynamic sessions isolate each session with Hyper-V in a prewarmed pool (Azure docs). It is the strongest option inside AutoGen itself, as a paid Azure service.
E2B gives each sandbox its own kernel in a Firecracker microVM (E2B enterprise): easy VM isolation for many users, but code and data run on E2B's infrastructure unless you arrange self-hosting.
AG2, the community fork, has a DockerSandbox in 1.x with no network and 512 MB by default.
[15]
AutoGen Docker executor vs Promptise sandbox
Promptise Foundry, the framework behind this site, has its own Docker sandbox. Both run code in a container on the host kernel, with different defaults and data access:
AutoGen executor (0.7.5) | Promptise sandbox (1.4.3) | |
|---|---|---|
Container | One per executor; you make one per task | One per agent with sandbox=True; a fresh one per run with code-action |
Network | On | Off; restricted (DNS, HTTP, HTTPS) or full on request |
Root filesystem | Writable | Read-only, with a size-capped workspace |
Privileges | Image user, Docker's default capabilities | Image user, about 40 capabilities dropped, no-new-privileges |
Default limits | None (add them as in step 5) | 4 GB memory, 2 CPUs, 256 processes, 300 s timeout |
Host files | work_dir mounted read-write | Nothing mounted; data comes in through tools |
Kernel isolation | gVisor as the daemon default | backend="gvisor" |
Status | Archived | Maintained |
The step 4 probe in a Promptise sandbox with default settings:
"""The same probe in a Promptise Foundry sandbox with default settings."""
import asyncio
import os
from promptise.sandbox import SandboxManager
from probe import PROBE # the shell script from step 4
async def main() -> None:
os.environ["OPENAI_API_KEY"] = "sk-host-only" # set on the host only
async with SandboxManager({}) as manager: # defaults: no network, read-only root, limits
session = await manager.create_session()
result = await session.execute(PROBE)
print(result.stdout)
asyncio.run(main())user: root
internet: urllib.error.URLError: <urlopen error [Errno -3] Temporary failure in name resolution>
host machine: does not resolve
memory limit: 4294967296
process limit: 256
OPENAI_API_KEY: not set
.env file: cat: /workspace/.env: No such file or directory
write /usr: deniedNo network, no host folder, a read-only root and limits, without a subclass. The code still runs as root, though without most capabilities, and the default limits are higher than step 5's, so set memory_limit and pids_limit. With an agent, agent_pattern="code-action" runs each request as one program in a fresh container, with tool calls bridged to tools on the host.
AutoGen is the better choice when you already run AutoGen teams, want a coder and an executor agent to iterate over many code blocks, or need GPUs, host mounts, a Jupyter kernel or Azure dynamic sessions; the Promptise sandbox has no GPU or mount option. Promptise is the better choice for hardened defaults without extra code, for agent code that uses your tools under the same approvals and budgets instead of holding credentials, and for a maintained framework. See the AI agent code execution sandbox guide and code-action agents.
[16]
Limitations
Post-start hardening is not complete hardening. docker update and a network disconnect cover memory, CPU, processes and the network, and init_command runs before they apply. A read-only root, dropped capabilities and another runtime need daemon defaults or a different executor.
`LockedDownExecutor` needs the `docker` CLI, talking to the same daemon as the Python SDK.
A container is not a VM. Use gVisor or a microVM for hostile, multi-tenant input.
AutoGen gets no more fixes. Bugs in autogen-ext 0.7.5 stay unfixed.
[17]
Frequently asked questions
Does AutoGen run code in Docker by default?
In AutoGen 0.4 and later, a CodeExecutorAgent uses whatever executor you pass it, and the docs recommend DockerCommandLineCodeExecutor. In AutoGen 0.2 (and early AG2), use_docker became the default in version 0.2.8.
How do I get AutoGen Docker task isolation with one container per task?
Create a new DockerCommandLineCodeExecutor inside each task with async with, leave container_name and work_dir unset, and build the CodeExecutorAgent and team inside the same block. Each task then gets a fresh container and a temporary folder, and both are removed when the block exits. Tasks can run in parallel with asyncio.gather.
Is AutoGen code execution secure?
DockerCommandLineCodeExecutor keeps code away from your files and environment variables, but by default it runs as root with internet access, no resource limits and read-write access to work_dir. That is fine for trusted experiments, not for untrusted input. Add a non-root image, cut the network, set limits, keep secrets out of work_dir, and use gVisor or a microVM for multi-tenant use.
How do I disable network access for DockerCommandLineCodeExecutor?
The executor has no network parameter in autogen-ext 0.7.5. Disconnect the container from the default bridge right after it starts with docker network disconnect bridge <container_name>, as LockedDownExecutor does. Preinstall packages in a custom image, because pip install fails without a network.
AutoGen Docker executor vs Promptise sandbox: which should I use?
Use AutoGen's executor if you already run AutoGen teams, want agents to iterate over code blocks in one container, or need GPUs or host mounts. Use the Promptise sandbox if you want no network, a read-only root, dropped capabilities and limits by default, and want agent code to reach your data through tools instead of mounted folders. Both share the host kernel unless you add gVisor.
Does this work with AG2 or AutoGen 0.2?
The ideas do, but the imports don't. AutoGen 0.2 and AG2 0.x use autogen.coding.DockerCommandLineCodeExecutor with similar options. AG2 1.x replaced it with ag2.extensions.docker.DockerSandbox, which has network and memory settings built in.
[18]
Next steps
Run agent code in a hardened sandbox and try to escape it: AI agent code execution sandbox.
Move off archived AutoGen: AutoGen alternatives and migration.
Let the model write one program over your tools: code-action agents.
Approve risky actions before they run: human-in-the-loop approval.
Serve many users safely: multi-tenant AI agents.
Compare frameworks: Python AI agent frameworks compared.
Reference: AutoGen command-line code executors, Docker resource constraints and the Promptise sandbox docs.