PromptisePromptise
Docs
GitHub
Promptise - AI Framework LogoPromptise

The foundation layer for agentic intelligence. Build, secure and operate autonomous AI systems with Promptise Foundry.

pip install promptise

[01] Foundry

  • MCPcast
  • The Promptise Agent
  • Reasoning Engine
  • MCP
  • Agent Runtime
  • Prompt Engineering
  • Execution Engine
  • Agent Identity

[02] Resources

  • Documentation
  • GitHub
  • Guides
  • Learning Paths
  • Questions

[03] Company

  • About
  • Terms of Service
  • Privacy Policy
  • Cookie Policy
  • Subprocessors

© 2026 Promptise by Manser Ventures. All rights reserved.

Open source · Python

← Guides> Guardrails and security

AutoGen Docker Task Isolation: Secure the Code Executor

Set up AutoGen Docker task isolation: configure DockerCommandLineCodeExecutor, run one container per task, cut the network, set limits and fix errors.

Level
Intermediate
Reading time
20 min
Published
Oct 20, 2026
Last updated
Oct 20, 2026
By
Promptise Team
  • Code Execution and Sandboxing
  • AI Agent Frameworks
  • Multi-Agent Systems
  • Human-in-the-Loop
  • Multi-Tenancy
  • Deployment
Contents · 18 sections↓
  1. 01How do you set up AutoGen Docker task isolation?
  2. 02How does DockerCommandLineCodeExecutor work?
  3. 03When to use the Docker executor (and when not to)
  4. 04What you need
  5. 05Step 1: Install and run the AutoGen Docker code executor
  6. 06Step 2: Configure the image, work_dir, timeout and cleanup
  7. 07Step 3: Run agent code with CodeExecutorAgent
  8. 08Step 4: Check what the default container protects against
  9. 09Step 5: Harden the container with a custom image and Docker limits
  10. 10Step 6: Give every task its own container
  11. 11The complete example
  12. 12Running it in production
  13. 13Common problems and fixes
  14. 14What are the alternatives to AutoGen's Docker executor?
  15. 15AutoGen Docker executor vs Promptise sandbox
  16. 16Limitations
  17. 17Frequently asked questions
  18. 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.

[01]

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:

Text
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 container

Three 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:

Pythonhello_executor.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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())
Output
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:

Pythonconfigured.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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())
Output
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-67c687a07371

The 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:

Pythonteam.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
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())
Output
---------- 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
TERMINATE

The 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:

Pythonprobe.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
"""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())
Output
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:     allowed

What 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:

DockerfileDockerfile
1
2
3
4
5
6
7
8
# 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 /workspace
>_Terminal
docker 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:

Pythonlocked_down.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
"""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:

Output
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:     denied

The 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:

Output
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:

Pythonper_task.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
"""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]))
Output
$ 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
Output
$ 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
Output
$ 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.1s

With 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:

Text
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
Pythonrun_task.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
"""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())
>_Terminal
docker build -t agent-sandbox:1.0 .
export OPENAI_API_KEY=sk-...
python run_task.py
Output
[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:

JSON
{
  "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:

>_Terminal
docker ps -aq --filter name=autogen-code-exec- | xargs -r docker rm --force

Plan 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

RuntimeError: Failed to connect to Docker. Please ensure Docker is installed and running.

The Docker daemon isn't running, or its socket is elsewhere

Start Docker Desktop or sudo systemctl start docker; check docker info; set DOCKER_HOST for Colima or Podman

DockerException: Error while fetching server API version with PermissionError(13, 'Permission denied') (Linux)

Your user can't open /var/run/docker.sock

Add the user to the docker group (root-equivalent) or use rootless Docker

APIError: 500 ... Mounts denied: The path ... is not shared from the host (macOS)

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

ValueError: Container is not running. Must first be started with either start or a context manager.

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:

Pythonpromptise_probe.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
"""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())
Output
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:     denied

No 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.

Contents

  1. 01How do you set up AutoGen Docker task isolation?
  2. 02How does DockerCommandLineCodeExecutor work?
  3. 03When to use the Docker executor (and when not to)
  4. 04What you need
  5. 05Step 1: Install and run the AutoGen Docker code executor
  6. 06Step 2: Configure the image, work_dir, timeout and cleanup
  7. 07Step 3: Run agent code with CodeExecutorAgent
  8. 08Step 4: Check what the default container protects against
  9. 09Step 5: Harden the container with a custom image and Docker limits
  10. 10Step 6: Give every task its own container
  11. 11The complete example
  12. 12Running it in production
  13. 13Common problems and fixes
  14. 14What are the alternatives to AutoGen's Docker executor?
  15. 15AutoGen Docker executor vs Promptise sandbox
  16. 16Limitations
  17. 17Frequently asked questions
  18. 18Next steps

Learning paths

Want more structure? Paths put guides in order, like a short course.

See the paths →

> Guardrails and security

Related guides

All guides in this topic →
  • // 01Advanced · 22 minAI Agent Identity: Authenticate Agents to MCP ServersGive your AI agent its own verifiable identity: short-lived JWTs from your IdP, checked by MCP servers with JWKS. Real runs, refresh and honest limits.
  • // 02Advanced · 20 minSPIFFE for AI Agents: Workload Identity Without API KeysUse SPIFFE and SPIRE for AI agent identity: X.509 vs JWT-SVIDs, calling APIs and MCP servers, cloud workload identity compared, with runnable code.
  • // 03Advanced · 18 minMulti-Tenant AI Agents: Keep Each Customer's Data ApartBuild a multi-tenant AI agent in Python: per-user tokens, tenant-scoped MCP tools and isolated sessions, with runs proving one tenant can't read another.
  • // 04Intermediate · 15 minAI Agent Audit Logging: Tamper-Evident Logs in PythonLog every AI agent tool call to an HMAC-chained file, then catch edits, deletions and reordering with one command. Real tamper tests, real output.
  • // 05Intermediate · 20 minMCP Security Best Practices: A Checklist for MCP ServersMCP security best practices as a checklist: token audience binding, per-tool scopes, input validation, human approval, rate limits and signed audit logs.
  • // 06Intermediate · 17 minAI Agent Production Checklist: What to Check Before LaunchAn AI agent production checklist: security, approvals, guardrails, limits, cost, observability, evals, deployment and incident response, with code.

Keep going.

Browse every guide by topic and level, or follow a learning path that puts them in order.

All guidesLearning paths