Sandboxed execution for agents¶
An agent that is asked to do open-ended work writes code, and then something has to run that code. By default that something is the Airflow worker: a skill script, a hand-written tool that shells out, or generated glue in code mode all execute on the host that also holds your connections, your filesystem and your network position, and the code they run was generated a moment ago from inputs you do not control.
SandboxToolset gives the
model a disposable workspace for that code instead. It exposes four tools:
Tool |
What it does |
|---|---|
|
Runs a shell command. Pipes, redirection, |
|
Reads a text file, head-first, and reports the next |
|
Writes text to a file, creating parent directories. |
|
Lists a directory. Directories are shown with a trailing |
The sandbox is provisioned by a
SandboxBackend on the model’s first
tool call and torn down when the agent run ends. Two backends ship: a hosted one on
Modal for production and Kubernetes,
and a local microVM one on Docker Sandboxes
for development. The four tool names and shapes match pydantic-ai’s own sandbox
capabilities, so a model that has seen one already knows this one.
Adding this toolset gives the agent shell and file operations in a separate workspace. Every other tool keeps its existing permissions and runs where it ran before. Whether that is a new capability depends on what the agent already had: an agent with a skill script or a shell tool already ran generated code on the worker, and this is where that code should run instead. An agent that only had named database operations is being granted a general shell for the first time, in a workspace with less authority than the worker, and you are choosing to grant it.
Pages in this section¶
Quick start¶
An agent investigating a revenue anomaly. The warehouse is reached through a
SQLToolset, so the credential
stays in the task and the model only ever sees rows. The arithmetic happens in a
hosted sandbox with pandas baked into the image, so the sandbox needs no network,
and a bad pivot or a runaway loop costs a small sandbox rather than a worker slot:
# Packages come from the image, so the sandbox needs no network at all. The
# image is built by Modal the first time it is used and cached after that.
ANALYSIS_IMAGE = modal.Image.from_registry("python:3.12-slim").pip_install("pandas")
@dag(
schedule=None,
start_date=datetime(2024, 1, 1, tzinfo=timezone.utc),
catchup=False,
tags=["example", "sandbox"],
)
def example_sandbox_agent_investigation():
"""Work out why daily revenue moved, with the warehouse and the workspace kept apart."""
AgentOperator(
task_id="investigate",
prompt=(
"Revenue for {{ ds }} moved more than 10% against the trailing seven-day "
"average. Find out which region and channel explain the move, and how sure you are."
),
system_prompt=(
"You are a revenue analyst. Use the SQL tools to pull daily aggregates by region "
"and channel for the two weeks up to and including the date in question. Write "
"the rows into the sandbox as CSV and use pandas there for pivots and "
"comparisons rather than doing arithmetic in your head. The sandbox has no "
"network access and pandas is already installed; do not try to install anything. "
"If a script fails, read the traceback, fix it and run it again."
),
llm_conn_id="pydanticai_default",
output_type=Findings,
toolsets=[
# The warehouse credential is held by the hook, in the task process. The
# model calls named operations and sees rows; it never sees the password
# and cannot pass it into the sandbox except by typing rows it was shown.
SQLToolset(
db_conn_id="warehouse",
allowed_tables=["analytics.daily_revenue", "analytics.orders"],
max_rows=500,
),
# Everything the model writes and runs happens here: off the worker, no
# egress, nothing of Airflow's inside, destroyed when the run ends.
SandboxToolset(
ModalSandboxBackend(image=ANALYSIS_IMAGE, cpu=1),
# The default spec already denies all egress and injects no
# environment. Stated here so the intent is visible in the Dag.
spec=SandboxSpec(block_network=True),
),
],
)
The same toolset on the local sbx backend, for developing on a laptop. Here
the agent maps a vendor file whose columns drift onto a staging schema: it writes a
script, runs it, reads the traceback and fixes it, which is the loop nobody can
write down in advance. Swapping SbxSandboxBackend for ModalSandboxBackend
is the only change between this Dag and production:
@dag(
schedule=None,
start_date=datetime(2024, 1, 1, tzinfo=timezone.utc),
catchup=False,
tags=["example", "sandbox"],
)
def example_sandbox_agent_local():
"""Map a vendor's drifting columns onto the staging schema, developing against ``sbx``."""
AgentOperator(
task_id="map_columns",
prompt=(
"Here is a sample of this month's vendor export:\n\n"
f"{VENDOR_SAMPLE}\n"
"Our staging table has the columns order_id, customer_name, ordered_at (ISO "
"date), amount_usd (decimal) and country_code. Write a Python script that reads "
"the sample and produces a row for each order in the staging shape, run it, and "
"fix it until every row parses. Then report the column mapping you settled on and "
"anything you could not map."
),
system_prompt=(
"You have a sandbox with Python 3.12 and the standard library, no network, and "
"an empty working directory. Write the sample to a file first, then iterate on a "
"script until it runs cleanly. Read tracebacks and fix the code rather than "
"guessing."
),
llm_conn_id="pydanticai_default",
output_type=ColumnMapping,
toolsets=[
# ``sbx`` runs a microVM on this machine and needs the ``sbx`` CLI, a Docker
# login and KVM on Linux, which makes it a laptop and dev-host backend. Swap
# it for ``ModalSandboxBackend()`` and nothing else in this Dag changes.
SandboxToolset(SbxSandboxBackend(host_network_policy="deny-all")),
],
)
Install the Modal extra and authenticate as you would for Modal’s own CLI. On a
worker, set MODAL_TOKEN_ID and MODAL_TOKEN_SECRET in the environment
instead; nothing is read until the first sandbox is created, so a Dag file that
constructs the backend parses without credentials present:
pip install 'apache-airflow-providers-common-ai[modal]'
modal token new # writes ~/.modal.toml
What it is for, in practice¶
The rule is: a model inside a running task writes code you cannot name in advance, and that code needs a real shell, a real interpreter or packages. These are the jobs that rule describes, each paired with the version where a sandbox is the wrong call.
A vendor drops a CSV nobody has a schema for. Three date formats, an encoding error at row 41,000, pandas needed. Every step depends on the previous step’s output, and a bad read of the whole file into memory should exhaust a small sandbox, not a worker slot shared with other tasks. If the schema is stable and the mapping is known, this is a plain task with no agent.
Yesterday’s revenue moved and nobody knows why. The agent queries through a
SQL toolset with a table allowlist, writes the rows into the sandbox, and pivots
with pandas rather than doing arithmetic in tokens. The warehouse credential never
enters the sandbox. If it is always the same five queries, that is
LLMOperator over a fixed SQL
task; if the rows number in the millions, compute in the warehouse rather than
carrying them through the model’s context.
Migrate forty dbt models between warehouse dialects. The model files go into
the workspace, the image carries sqlglot and sqlfluff, and the loop of
convert, lint, read errors, fix runs for as long as it takes, fully offline. The
rewritten files are the deliverable, and getting forty files out is the gap
described under Getting a result out.
A failing task’s traceback, handed to an agent to propose a fix. The agent
reproduces the crash against a sample, tries a fix, reruns, and posts a diff for a
person to review. That code ran with the worker’s credentials the first time; the
reproduction should not. A written diagnosis with no execution is
LLMOperator reading the log.
Not a sandbox: flag 200 customers with an open ticket using two existing tools. That is code mode: a short loop over registered tools, no packages, no shell, sub-millisecond start.
Not a sandbox: run a vendor’s PII-scanner image over an S3 prefix. No model
writes anything. That is KubernetesPodOperator, a fixed payload with templated
arguments in its own pod.
Not only a sandbox: one team’s agent tasks must run off the shared workers.
That is a where-does-the-task-run requirement, so KubernetesExecutor with a
pod override, and the sandbox toolset inside the pod if those agents also write
code. The pod protects the cluster from the task; the sandbox protects the task
from its model.
Choosing the boundary¶
“Sandboxed” means different things at different layers, and picking the wrong layer is the most common way to end up with less protection than you think. Four boundaries exist, from smallest to largest:
Boundary |
What moves inside it |
What that protects you from |
|---|---|---|
A tool call |
What |
Model-written code damaging the worker host, reading its files, or reaching the network from it. |
The glue between tools |
Generated orchestration code, via code mode and the Monty interpreter. |
Generated code touching anything other than the tools you registered. Monty runs it in a worker subprocess on the worker host, a language-level sandbox rather than an OS one, and every tool it calls executes in the task process with the task’s authority. |
The agent process |
The whole agent loop, its LLM credentials and its message history. |
The agent’s own credentials leaking, and any other toolset on the same agent. Not available today. |
The whole task |
The complete Airflow task, supervisor included, as
|
Everything outside the task. Not the task from its own code: the agent and what it runs are both inside this boundary. |
SandboxToolset is the first row. It relocates exactly its four tools and
nothing else. Boundary size is not a security level on its own: the image, the
credentials you inject, the network policy and the resource limits decide the
actual isolation. Choose the smallest boundary that fits, then configure it.
The agent needs to |
Reach for |
|---|---|
Call operations you can name in advance |
|
Chain those tools with glue logic |
code mode. Monty confines generated code to the tools
you registered and starts in well under a millisecond, against roughly a
second to provision a hosted sandbox. Reach past it for a sandbox when the
model needs something Monty does not have: a shell, |
Write and run open-ended code: reshape data with no known schema, install a package, or fix its own failing script by reading the traceback |
|
Produce a large artifact for a downstream task |
Today, a |
A whole task’s worth of untrusted work isolated, with no agent involved |
|
Airflow’s own credentials kept away from the agent |
Not this. Scope the connections the task can see, and give the agent only the toolsets it needs. See Security boundary. |
Code mode and the sandbox compose. With both enabled, the three file tools
fold into run_code as callables the generated code can loop over, and
run_command stays a tool the model calls directly, so a shell command is
written as a shell command rather than quoted inside generated Python. Both paths
reach the same sandbox: there is one per agent run whichever way a call arrives.
What enforces the boundary¶
Swapping SbxSandboxBackend for ModalSandboxBackend changes how the tool-call
boundary is enforced and leaves its contents alone: the same four tools run in the
sandbox, and every other toolset stays on the worker.
Enforced by |
Isolation |
Where the code runs |
If the worker dies |
|---|---|---|---|
Monty, under code mode |
Language-level confinement. The worker subprocess isolates a crash but is not an OS security boundary. |
A subprocess on the worker host. Every tool the generated code calls runs in the task process. |
Nothing remote to reclaim. The subprocess lives on the worker host beside the task. |
A container on the worker host (no shipped backend; one you write) |
Namespaces over the host’s shared kernel. |
The worker host. |
Up to the backend. Without its own expiry or reaper, the container stays until someone removes it. |
|
A microVM with its own kernel. |
The worker host. |
Left running. No server-side lifetime, so the microVM and its workspace directory survive. |
|
Modal’s container runtime, which Modal documents as gVisor. |
Modal’s infrastructure, off the worker. |
Ended by Modal at |
When a run ends normally, the task calls the backend’s destroy. sbx runs its
removal command and waits up to two minutes for it; Modal sends a termination
request and returns without waiting for the sandbox to stop. Either can return with
the sandbox still present, and neither case fails the task. A SIGKILL, an
out-of-memory kill or a lost node skips that teardown entirely, and then only the
last column applies.
Why not KubernetesPodOperator or the KubernetesExecutor?¶
Because they answer a different question. Both isolate a task from the rest of your platform. Neither puts a boundary between an agent and the code that agent writes.
Run an AgentOperator under the KubernetesExecutor and the whole task,
agent loop included, gets its own pod. Model-written code still executes inside
that pod, with its service account, its mounted secrets, its connections and its
network position. The pod protects the cluster from the task; it does not protect
the task from what the model decided to run, because from the pod’s point of view
that code is the task.
KubernetesPodOperator does contain the work, and its image, command, arguments
and environment are all templated, so it can launch a different payload per run,
and that payload can be a complete agent. The difference is what each one is. The
pod is the environment that hosts the workload, and everything inside it runs with
the pod’s authority. SandboxToolset is a separate workspace that an
already-running workload reaches into repeatedly, with less authority than the
workload itself. Expressing the workspace as a pod means one pod per turn, with
scheduling latency and a lost filesystem between each, or letting the model run
whatever it wants inside the pod that also holds your credentials.
|
|
|
|---|---|---|
Protects |
The cluster, from the task |
The task and its credentials, from the model’s output |
Boundary sits |
Around the whole task, agent included |
Between the agent and the code it writes |
What runs inside is decided |
Before the process starts |
By the model, mid-run |
Credentials inside |
The task’s own: service account, secrets, connections |
Only what |
Running an agent in a pod and giving it a sandbox is the setup a Kubernetes
deployment usually wants: the pod bounds the task, the sandbox bounds the model.
The sbx backend cannot be the second half of that, because it needs KVM on the
worker host and an unprivileged pod cannot provide it; that is what the hosted
backend is for.
When to choose it¶
Choose it when the work is running code the model wrote, rather than calling a tool you picked in advance: exploratory analysis, installing a package for one task, a script the model writes, runs, and fixes from its own traceback. Every other toolset route answers “call this thing”; this one answers “here is somewhere to work”. This page has the worked scenarios above and the limitations below to read before designing a Dag around it.
Before reaching for it, check whether the actual need is narrower than that.
code_mode=True is a flag on AgentOperator. It changes how the
model invokes the tools it already has, letting it write code that calls several
of them instead of emitting one call per step. It does not give the agent somewhere
to run arbitrary code of its own. Because it needs no backend, it avoids the
production-readiness, network-isolation and reclamation caveats of the sbx
backend on Sandbox backends. It does not change what the agent can reach: the
generated code runs in Monty’s deny-by-default sandbox, but the tools it calls
still run in the worker, so a credential-bearing toolset on the same agent stays
within reach whether or not code mode is on. See Code mode and
Choosing the boundary.
What it cannot do
Only one of its two backends runs on Kubernetes.
SbxSandboxBackenddrives Docker Sandboxes on the worker host, and its own documentation says to use it for local development: it wants thesbxbinary on the host, an authenticated Docker account, a one-timesbx policy init, and on Linux KVM or nested virtualization, which an unprivileged container cannot provide. Production and Kubernetes useModalSandboxBackend, a hosted backend behind themodalextra that installs nothing on the worker and reclaims a sandbox at its own lifetime if the worker dies. Both implementSandboxBackend, and a third vendor can too.It does not contain the agent. Only what these tools do runs in the sandbox; the agent loop, the model calls, and every other toolset on the same agent stay in the worker with the worker’s credentials. It contains model-written code, so pairing it with a credential-bearing toolset on the same agent puts the credential back within reach. Choosing the boundary sets this out in full.
The
sbxbackend cannot enforce network isolation on its own. It appliesallow_egress_toas a per-sandbox policy rule, but only on top of adeny-allhost policy, since a local rule can narrow egress and never widen it;block_networkhas no per-sandbox enforcement at all. Ask for either against a host policy that is not alreadydeny-alland the backend raises rather than silently leaving the sandbox less restricted than the spec asked for.block_networkdefaults toTrue, so a bareSandboxSpec()with no arguments already asks for it and is refused under the defaulthost_network_policy="unknown". On Modal the same default maps onto the sandbox’s ownblock_networkand is enforced exactly; a hostname allowlist there is matched on the TLS handshake name and has to be opted into, for the reasons set out on Sandbox backends.Reclamation depends on the backend. A failed teardown is logged as a warning rather than raised, deliberately, so that a teardown blip cannot fail a finished run. On
sbxnothing else picks up the slack: there is no server-side TTL, so a worker killed outright leaves the microVM and its workspace directory behind, namedairflow-sandbox-*so an operator can find and remove them. On Modal the sandbox ends at its ownsandbox_timeoutwhatever became of the worker.Nothing survives the run, and a file the agent built can leave only through the model’s context, which is text-only and capped. Producing an artifact for a downstream task is a
@taskdriving a backend directly today, not the agent; Sandbox configuration and lifecycle has the example.
A real example. example_sandbox_toolset.py in this provider’s example
Dags has an agent investigating a revenue anomaly on the Modal backend beside a
SQLToolset, the same agent shape on sbx for a laptop, and a @task
producing a file through a sandbox; all three are on this page and Sandbox configuration and lifecycle. The two
system tests, example_sandbox_toolset_sbx.py and
example_sandbox_toolset_modal.py, run against a real backend and are
reachable from the System Tests entry in the sidebar.
Credentials and where it runs. This route does not end at an
Airflow connection. Airflow puts none of its context, connections, variables or
worker environment into the sandbox; only what you pass through
SandboxSpec goes in, and the
credential that provisions the sandbox never enters it. Authorization to the
backend sits outside Airflow: sbx login and sbx policy init on the
machine for sbx, or MODAL_TOKEN_ID and MODAL_TOKEN_SECRET on the
worker for Modal. Work runs in a per-run microVM on the worker host with
sbx, or off the worker entirely in Modal’s infrastructure. Its tool calls
act as barriers, as they do for the other
routes that build their own tools; see Tool calls as barriers.
Limitations¶
These apply to every backend. Each is explained in the section it belongs to; this is the list to read before designing a Dag around an agent with a sandbox.
Nothing survives the run, including across task retries. Lifecycle.
A file the agent built cannot leave except through the model’s context, which is text-only and capped. Getting a result out.
A credential cannot come from a connection or a secrets backend; the spec is fixed at parse time, and anything injected is readable by the model. Credentials.
Cannot be combined with
durable=Trueorenable_hitl_review=True;AgentOperatorraises. Lifecycle.A run that outlives
sandbox_timeoutfails the task. Lifecycle.The hostname allowlist is a weak control and refused unless opted into. The address allowlist is enforced properly but cannot serve a package registry whose addresses rotate. Modal.
Commands run as root and
workdiris not a jail. Modal.It does not contain the agent. Security boundary.
Security boundary¶
The sandbox does not contain the agent. The agent loop, the model calls and
every other toolset on the same agent still run in the worker process with the
worker’s credentials. An agent that has SandboxToolset and a toolset that
can reach connections has a contained code tool sitting beside a credential path
that is not contained: a prompt injection arriving in a queried row can steer the
model into calling the credentialed tool, and that call executes from the worker
with the connection’s full grants. Moving the sandbox to a hosted backend changes
none of this.
The controls for that risk are different controls. Give the agent only the
toolsets it needs. Point every connection at a least-privilege role. Prefer a
capability toolset, which lets the model use a connection without ever holding
it, over a credential in SandboxSpec.env, which the model can read. And treat
a table allowlist as a guardrail that contains intent rather than a boundary that
contains access.
Isolation is one of several controls an agent needs, and each has a limit:
Protection |
Question it answers |
Where it stops |
What provides it |
|---|---|---|---|
Tool selection and argument checks |
Which actions can the model request? |
An allowed action can still be harmful. |
The toolsets you register, |
Restricted interpreter |
What can generated glue execute directly? |
Tools it calls keep their full authority. |
|
Process, container or sandbox isolation |
Which filesystem, processes and host resources can code reach? |
A credential injected into the sandbox is readable inside it. |
|
Identity and entitlement |
Which data and operations may this workload use? |
Hiding a password does not remove the power to use it. |
Least-privilege roles on the connections you pass. Nothing in this provider enforces them. |
Network policy |
Which destinations can each component contact? |
A sandbox’s egress policy does not govern worker-side tools or model requests. |
|
Approval |
Which effects need sign-off before they happen? |
Reviewing the final answer does not undo writes made during the run. |
Keep writes out of the agent and gate the task that makes them.
|
Deadlines, resource limits, cleanup |
What bounds runaway work and orphaned resources? |
A resource request or a cost report is not an enforced ceiling. |
Command timeouts, |
Audit |
Can you establish what happened afterwards? |
A record does not prevent the action. |
LoggingToolset and tracing. |
A sandbox with no egress can still leak data. A secret printed inside comes back
through the tool result to the worker and the model’s context, and from there it
can reach the agent’s output, its message history and any traces you record.
Keeping a credential out of the prompt does not limit its use either. A
SQLToolset never shows the model its password, yet every query the toolset lets
through runs with the connection’s grants. Its read-only default and
allowed_tables narrow what reaches the database; the connection’s role sets the
limit.
Defense layers covers the same controls toolset by toolset.
What the sandbox does contain, verified against a live worker environment carrying sentinel credentials: nothing Airflow-shaped reaches it. No connection, variable, Fernet key or cloud credential appeared in the guest’s environment, in any process environment or on its filesystem, and the provisioning token is not usable from inside even when the vendor’s API is on the egress allowlist. Sandboxes cannot reach each other, so two sandboxes on one agent are isolated from one another as well as from the worker.