Sandbox backends¶
Note
Experimental: this can change or be removed in a minor release of this provider. See Stable and experimental features.
Modal (hosted)¶
ModalSandboxBackend runs each
sandbox in Modal, provisioned over the API. Of the backends that ship with the
provider, this is the managed one to use in production, and with
OpenSandbox one of the two that run on
Kubernetes: nothing has to be installed on the worker, model-written code never
executes on the worker host, and Modal reclaims a sandbox at its own lifetime
whether or not the worker survives. It needs the modal extra and Modal
credentials, from a modal connection or the worker environment, as under
Quick start.
Constructor parameters:
modal_conn_id:modalconnection the token and, optionally, the Modalenvironmentcome from. Default"modal_default";Noneuses the worker’s credentials without looking for a connection. How a missing or partial connection resolves is on the Modal connection page. A credential problem fails the task, except in the toolset’s own teardown, which logs it so a finished run is not failed. The connection type comes from the Modal provider, which themodalextra installs and which needs Airflow 3.image: Registry tag for the sandbox image, or a preparedmodal.Imagecarrying pre-installed packages. Default"python:3.12-slim".app_name: Modal app the sandboxes are created under. Default"airflow-sandbox".create_app_if_missing: Create that app if it does not exist. DefaultTrue.sandbox_timeout: Maximum lifetime of a sandbox in seconds. Default3600. Modal’s own default is 300, which is below a plausible agent run.idle_timeout: Seconds of inactivity after which Modal reclaims the sandbox, orNoneto rely onsandbox_timeoutalone. DefaultNone; see Configuring a sandbox.workdir: Working directory for commands, created if the image lacks it. Default"/workspace". It is a starting directory, not a jail: absolute paths and..are passed through, and commands run as root, so the model can read and write anywhere in the sandbox filesystem. The sandbox boundary is what contains that.cpu,memory,gpu,region,cloud: passed through to Modal.Nonelets Modal choose. An unrecognizedregionorcloudfails the task rather than falling back.tags: Extra Modal tags on every sandbox, e.g.{"dag_id": "my_dag"}. The backend’s ownairflow_keys overwrite a tag of the same name.egress_enforcement:"strict"(default) or"sni". See below.
A sandbox can be provisioned by one task and used by another. Modal finds a
sandbox by id from any process, so this backend implements
AttachableSandboxBackend: a
@task calls create and later destroy, and a SandboxToolset with
attach_to uses the sandbox in between. The ownership rules ride on Modal tags,
airflow_owner from SandboxSpec.owner, airflow_holder while a run holds
the sandbox, airflow_expires_at so the attaching side knows the clock and
airflow_network so it knows the policy, and Sandbox.list(tags=...) finds
them. Reading tags back needs modal>=1.5.2, which is the extra’s floor. How
to use it is on Configuration.
Network policy. block_network=True maps exactly onto Modal’s own
block_network, which drops all outbound traffic including DNS. A spec that
names allow_egress_to is refused by default, because Modal cannot combine
an allowlist with block_network at all, and its hostname allowlist is enforced
by matching the name in the TLS handshake, which means:
TLS on port 443 to a listed host connects; any other host is refused at once.
Non-TLS traffic is dropped, not refused. A plain HTTP connection to a listed host stalls until the client gives up, around two minutes of TCP retries for one address, so with the 60 s default command budget the model reads
[timed out after 60s]and concludes its command was slow, never that the network stopped it.The destination address is not part of the decision. A connection opened to an unrelated address while presenting a listed name is routed to the listed host and answered by it: connecting to
8.8.8.8:443withpypi.orgin the handshake returns the certificate and content ofpypi.org. The allowlist is a name-routed egress proxy, not a filter on where packets may go.DNS resolution stays open for every hostname, listed or not, against authoritative servers outside Modal. A freshly generated label under a domain the operator controls resolves and returns its answer, so this is a two-way channel.
A host that shares a TLS endpoint with a listed one can be reached by presenting the listed name in the handshake and the other in the request. With
pypi.orgas the only allowed host, a TLS session opened tofiles.pythonhosted.orgwhile presentingpypi.orgwas allowed through and answered. Other tenants of the same CDN returned421 Misdirected Request; it is co-tenancy of the same TLS endpoint that matters, which you cannot check from outside and which can change without notice.
So the allowlist says which name a TLS session may be routed to, and nothing else.
Pass ModalSandboxBackend(egress_enforcement="sni") to say you accept that and
have the allowlist applied:
SandboxToolset(
ModalSandboxBackend(egress_enforcement="sni"),
spec=SandboxSpec(block_network=True, allow_egress_to=["pypi.org", "files.pythonhosted.org"]),
)
Entries must be bare hostnames or one leading *. label; a URL, a host:port,
an address or a single-label name is refused, because Modal applies the list without
checking it and any of those would silently match nothing.
An address allowlist is enforced properly, and needs no opt-in.
allow_egress_to_cidrs maps onto Modal’s outbound_cidr_allowlist, which
decides on the destination address for any port and protocol. Measured on
2026-09-22 with ["1.1.1.1/32"]: the listed address connected on 443 and on 53,
an unlisted address timed out on both, and block_network with the list set was
refused at create, as with the hostname list. This is the right mode for one
service at a fixed public address, which is the case the hostname list serves
worst. It cannot serve a package registry behind a CDN, whose addresses rotate
faster than a sandbox lives.
SandboxToolset(
ModalSandboxBackend(),
spec=SandboxSpec(block_network=True, allow_egress_to_cidrs=["203.0.113.0/24", "198.51.100.7/32"]),
)
Four things to know about it:
The address has to be public, and IPv4. Private ranges are unreachable from a Modal sandbox whatever the allowlist says: measured, connections to
10.20.0.1,172.16.0.1,192.168.1.1and the cloud metadata address timed out both under an open network and with those ranges on the allowlist, so a service on your own private network cannot be reached this way; it needs a public address, or a way onto your network that Modal provides and this backend does not configure. Modal’s allowlist also rejects IPv6 ranges outright, and the sandbox has no IPv6 route, so an IPv6 entry is refused here with the reason.Hostnames still resolve. Modal’s own resolver inside the sandbox answers every lookup, so
pypi.orgresolves to its addresses and a connection to them then times out. The tool description tells the model this so a successful lookup is not read as a reachable host. DNS is therefore still a channel out, as it is under the hostname list; onlyblock_network=Truewith no allowlist closes it.Entries are canonical CIDR. A bare address is written as
/32. A range with host bits set, such as203.0.113.1/24, is refused rather than widened to203.0.113.0/24, because that is not what was written. A hostname, a URL or ahost:portis refused, since Modal would accept it and match nothing.0.0.0.0/0and::/0are refused too: an allowlist of every address is an open network, andblock_network=Falseis how to ask for one.Combining the two lists weakens the address one. Modal applies them together, and traffic matching either passes. Measured, adding
pypi.orgto the hostname list beside["1.1.1.1/32"]made a TCP connection to8.8.8.8:443succeed, because port 443 is then routed by handshake name for every address. So a combined spec has the address list’s guarantee on every port except 443, and the hostname list’s caveats there. The hostname half keeps itsegress_enforcement="sni"opt-in when combined, and the backend logs a warning at create naming the weakening.
What the image needs. write_file and list_directory use Modal’s own
filesystem API, served by a helper Modal injects into the sandbox, so they need
nothing from the image. read_file deliberately does not: Modal’s read API takes
no length, so it cannot honor a read budget, and a single call on a file that
streams without end (/dev/zero, a FIFO, a procfs entry) would pull it into
worker memory unbounded. That tool runs the base class’s shell implementation,
which caps the read inside the guest, and the image needs stat, head and
base64. Any Debian or Ubuntu based image, including python:*-slim, has
them.
Symlinks. Because write_file goes through the native API, writing to a path
that is a symlink replaces the link with a regular file and leaves the original
target untouched, where a shell redirect would follow the link.
OpenSandbox (self-hosted remote)¶
OpenSandboxBackend
runs sandboxes through an OpenSandbox server.
The server may use Docker or Kubernetes; Airflow workers only use its HTTP API
and do not need access to the container runtime.
Install the SDK extra:
pip install "apache-airflow-providers-common-ai[opensandbox]"
Use a generic Airflow connection, resolved lazily on first use:
from airflow.providers.common.ai.sandbox import OpenSandboxBackend
from airflow.providers.common.ai.toolsets import SandboxToolset
SandboxToolset(OpenSandboxBackend(opensandbox_conn_id="opensandbox_default"))
The connection host is required; port is optional, schema defaults to
http, and password carries the API key when required. Extras may set
request_timeout (default 30 seconds) and use_server_proxy (default
true). Set opensandbox_conn_id=None to let the SDK read
OPEN_SANDBOX_DOMAIN and OPEN_SANDBOX_API_KEY.
SandboxSpec.env is sent at creation. A default spec sends a deny-all
network policy; allow_egress_to becomes explicit allow rules. With
block_network=False, the backend omits network policy entirely so a
deployment without the egress sidecar can still run an intentionally open
sandbox. Deny/allowlist policy requires the sidecar, so the backend reads the
enforced policy back after creation and destroys the sandbox if it does not
match the requested spec. allow_egress_to_cidrs is refused: OpenSandbox only
enforces CIDR targets in dns+nft mode, and the Python SDK does not expose
that enforcement mode on policy read-back, so this backend cannot prove the
address-layer restriction is active.
Every sandbox carries created-by: airflow metadata and an
airflow-sandbox-* name for attribution and cleanup. The server enforces a
sandbox lifetime (default 3600 seconds). If the SDK event stream stalls, the
worker abandons the call after the command budget plus a grace period, destroys
the sandbox, and reports sandbox_terminated so the toolset provisions a fresh
one. Output is bounded per stream after the SDK yields it; a single newline-free
line is the SDK-level exception, because the SDK assembles that line before the
backend sees it.
Constructor parameters:
image: image used by the server. Default"python:3.12-slim".cpuandmemory: resource limits. Defaults"1"and"2Gi".sandbox_timeout: server-side lifetime in seconds. Default3600.ready_timeout: provisioning/reconnect timeout. Default120.use_server_proxy: override the connection extra for file and command calls.
The runtime remains a deployment choice. The default Docker runtime shares the host kernel; choose a stronger runtime such as Kata when your threat model needs a VM boundary.
sbx (Docker Sandboxes, local)¶
SbxSandboxBackend runs each sandbox
in a Docker Sandboxes microVM by driving the sbx CLI. Each sandbox is a real
microVM with its own kernel.
Warning
Use this backend for local development, not production. Docker Sandboxes
is built for running coding agents against a checkout on your own machine, and
driving it from an Airflow worker is off-label use. A production worker would
need the sbx binary on the host, an authenticated Docker account
(sbx login), a one-time sbx policy init, and on Linux, KVM or nested
virtualization, which a worker in an unprivileged container cannot provide.
Orphans are not reclaimed. There is no server-side lifetime. If the worker is
killed outright, the microVM and its workspace directory survive; sandboxes are
named airflow-sandbox-* so an operator can find and remove them.
Installing the CLI is a Deployment Manager prerequisite (brew install
docker/tap/sbx or winget install Docker.sbx); the backend needs no Python
dependency. The template image must provide GNU coreutils timeout, base64,
stat, head, find, mkdir and dirname, which any Debian or Ubuntu
based image has.
Constructor parameters:
image: Container image for the sandbox. Default"python:3.12-slim".memory: Memory limit in binary units.sbxenforces a 1 GiB minimum. Default"2g".cpus: CPUs to allocate.None(default) uses thesbxdefault, which is every host CPU.sbx_path: Path to thesbxbinary. Default"sbx".create_timeout: Seconds allowed for provisioning; a first-run microVM boot plus an image pull can be slow. Default600.host_network_policy: Whatsbx policyis set to on this host."unknown"(default) makescreaterefuse any spec asking for a network guarantee this backend cannot make, and sinceblock_networkdefaults toTruethat includes a bareSandboxSpec(). Set"deny-all"after runningsbx policy init deny-all, or"allow-all"to state that egress is open and passSandboxSpec(block_network=False)to match.
What differs between the backends¶
Swapping the backend is one constructor argument, and tool names, spec and prompt do not change. Four behaviours do, so read them before assuming the same Dag behaves identically everywhere:
CPU.
sbxgives a sandbox every host CPU; Modal defaults to a request of 0.125 of one, so setcpu; OpenSandbox takescpuas a limit the server enforces.Egress allowlists.
sbxenforcesallow_egress_toat the host policy layer; Modal matches TLS handshake names, which is weaker and has to be opted into; OpenSandbox enforces it in an egress sidecar, and the backend reads the enforced policy back rather than trusting the create request.allow_egress_to_cidrsis enforced at the address layer on Modal, refused onsbx, and refused by OpenSandbox because its SDK cannot prove that the sidecar is running in thedns+nftmode required for CIDR enforcement.Command timeouts. A timeout destroys an
sbxsandbox and its files; Modal and a server-enforced OpenSandbox timeout preserve the sandbox and files. OpenSandbox destroys it only if the command event stream itself stalls past the client-side grace period.Symlinks.
write_filethrough a symlink follows the link onsbxand replaces it on Modal and OpenSandbox.Attaching. A Modal sandbox can be provisioned by one task and used by an agent in another (A sandbox another task owns). An
sbxmicroVM lives on the worker that created it and cannot be reached from another task, and OpenSandbox has no per-sandbox metadata the ownership rules could be kept in, so both refuseSandboxSpec.ownerand the toolset refusesattach_tofor them.
Bringing your own backend¶
Any vendor that can create a sandbox, run a command in it and destroy it can plug
in. Subclass SandboxBackend in your
own package and pass an instance to SandboxToolset.
Three methods are required: create, run_command and destroy. The
file operations ship as defaults implemented over run_command, because
reading, writing, listing and exporting a file are all expressible as shell
commands. The default export_file, behind SandboxToolset(exports=...),
copies a file in 4 MiB slices, one command each, and needs stat, tail,
head and base64 in the guest. It relies on run_command returning each
slice’s output intact, or setting stdout_truncated when it could not. Override
it when the vendor can stream a download, as the sbx and OpenSandbox backends do. Override the others
only when the vendor has a native file API:
from airflow.providers.common.ai.sandbox import (
SandboxBackend,
SandboxExecResult,
SandboxSpec,
)
class AcmeSandboxBackend(SandboxBackend):
name = "acme"
def create(self, *, spec: SandboxSpec | None = None) -> str:
return acme_sdk.create_sandbox().id
def run_command(self, sandbox, command, *, timeout, max_output_bytes):
r = acme_sdk.exec(sandbox, command, timeout=timeout)
return SandboxExecResult(exit_code=r.exit_code, stdout=r.stdout, stderr=r.stderr)
def destroy(self, sandbox) -> None:
acme_sdk.delete_sandbox(sandbox)
# Optional: inherited from SandboxBackend unless the vendor has
# something better than shelling out.
def read_file(self, sandbox, path, *, max_bytes) -> bytes:
return acme_sdk.download(sandbox, path, limit=max_bytes)
Four rules for an implementation:
Constructors run at Dag-parse time, so resolve credentials lazily, on first use.
destroymust be idempotent; destroying an already-gone sandbox is not an error.Raise
SandboxTerminalErrorwhen retrying cannot help andSandboxErrorwhen it might. The first fails the task for Airflow to retry; the second becomes a bounded prompt back to the model.If you cannot enforce something the
SandboxSpecasks for, raise. Never provision a weaker sandbox than the Dag author asked for.
If your sandboxes can be found again from another process, subclass
AttachableSandboxBackend instead,
and a @task can provision a sandbox for an agent task to attach to
(A sandbox another task owns). It adds two methods, read_tags and write_tags,
over whatever key-value metadata the vendor keeps on a sandbox, and the ownership
rules are written once on the base class on top of them. Three things the base
class relies on: read_tags raises SandboxTerminalError for a sandbox that
does not exist or has ended; write_tags replaces the whole set, because
releasing a claim is a rewrite without the holder key; and create stamps
SandboxSpec.owner under OWNER_TAG, the sandbox’s end time as Unix seconds
under EXPIRES_AT_TAG, and the network policy under NETWORK_TAG using
encode_network_policy(spec), all importable from
airflow.providers.common.ai.sandbox.base. If the vendor lets a caller of your
backend set metadata too, make your reserved keys overwrite theirs. Stamping the
working directory under WORKDIR_TAG is optional; without it the attaching
backend asks the sandbox. The toolset shortens run_command to the remaining
lifetime itself; a backend clamps only if its own file operations need it.