This release is a pre-release and may not be stable for production use.
unionai-sandbox
Run untrusted code safely from Python — embedded in your process, or in a sandbox pod on Flyte.
A session is a locked-down machine you drive with run(): explicit isolation (bubblewrap / user-namespaces + Landlock + seccomp), a per-host network allow-list, and a filesystem allow-list. Install packages and write files like a long-lived VM.
Install
pip install unionai-sandbox
Quickstart
Both transports speak the same contract — open → run() / put_bytes() / get_bytes() → close — so the only thing that changes is where the sandbox runs. Pick the backend explicitly; there is no auto-detection and no silent downgrade.
| On-device — in your process | Remote — a Flyte pod |
from union import sandbox as sb
# runs in THIS process/container
async with sb.on_device.session(
backend="userns",
) as sbx:
p = await sbx.run("uname -a", stdout=True)
out, _ = await p.communicate_text()
|
from union import sandbox as sb
# launches a sandbox-server pod, connects
async with await sb.session() as sbx:
p = await sbx.run("uname -a", stdout=True)
out, _ = await p.communicate_text()
|
Installing is just a run. A session keeps one venv on its work dir, so run("uv pip install X") persists and a later run("import X", script_type="python") sees it — install once, import anywhere. The work dir persists across runs (it's the session's disk); /tmp does not. Each run() is still its own isolated process; only the venv and work dir are shared.
async with await sb.session(network_mode="allowlist", network_allowlist=sb.constants.PYPI_HOSTS) as sbx:
await sbx.run("uv pip install pandas")
await sbx.run("import pandas; print(pandas.__version__)", script_type="python")
Using it inside a Flyte task
Remote pod — sb.session() launches a dedicated sandbox-server pod and connects over Connect-RPC. Deploy the default envs once per cluster:
flyte deploy --all python/union/sandbox/task/_server.py
On-device in the task's own pod — no extra pod. backend="userns" runs in a vanilla pod; backend="bubblewrap" needs CAP_SYS_ADMIN + unconfined AppArmor, which is one helper on the pod template:
import flyte
from union import sandbox as sb
env = flyte.TaskEnvironment(
name="sandboxed-task",
image=sb.base_image,
# CAP_SYS_ADMIN + AppArmor unconfined, nothing else (allowPrivilegeEscalation=false).
# Omit for backend="userns" — a vanilla pod is enough.
pod_template=flyte.PodTemplate.allow_nested_sandboxing(),
)
@env.task
async def main() -> str:
async with sb.on_device.session(backend="bubblewrap") as sbx:
p = await sbx.run("uname -a", stdout=True)
out, _ = await p.communicate_text()
return out
See Pod security for the exact Kubernetes spec that helper emits and why it's needed.
Per-session config: resources, network, filesystem allow-list
Every session takes the same knobs on either transport:
import flyte
async with sb.on_device.session(
backend="bubblewrap",
resources=flyte.Resources(cpu="2", memory="2Gi"),
network_mode="allowlist", # "blocked" (default) | "open" | "allowlist"
network_allowlist=["pypi.org", "*.pythonhosted.org"],
read_only_paths=["/opt/models"], # add to the FS allow-list (read)
) as sbx:
...
Process output helpers on the returned proc:
await proc.communicate()→(stdout_bytes, stderr_bytes)await proc.communicate_text()→ decoded(stdout, stderr)async for line in proc.iter_stdout_lines(): ...streams decoded lines
Runnable versions of everything above are in
examples/on_device/ and
examples/remote/ — hello, network allow-list, put/get
bytes, and PyTorch (CPU + GPU).
Environment
Environment is a declarative spec for the sandbox-server pod
the session runs in. The default is registered for you; build a custom
one if you need extra packages, a tighter resource budget, secrets, or
a different isolation mode.
import flyte
from union import sandbox as sb
my_sandbox = sb.Environment(
name="ml-sandbox",
image=sb.base_image.with_pip_packages("torch", "transformers"),
resources=flyte.Resources(cpu="8", memory="32Gi", gpu="L4:1"),
secrets=[flyte.Secret(group="hf", key="HF_TOKEN")],
env_vars={"HF_HOME": "/tmp/hf"},
sandbox_mode="userns", # "userns" | "bwrap"
runtime="container", # "container" | "gvisor"
)
# Deploy: `flyte deploy --all path/to/your_module.py` finds
# `my_sandbox.task_env` and registers the task.
async with await sb.session(environment=my_sandbox) as sbx:
...
| Param | Type | Notes |
|---|---|---|
name |
str |
Task-env identifier; session resolves {name}.sandbox_server. |
image |
flyte.Image |
Defaults to base_image. Extend via base_image.with_*(). |
resources |
flyte.Resources |
Default per-session resources. Override per launch with sb.session(resources=...) — the platform task spec is rewritten via TaskDetails.override(resources=...), and the in-pod sandbox cgroup ceiling is derived from the same value (minus a small supervisor reserve). |
secrets |
list[flyte.Secret] |
Forwarded to the TaskEnvironment. |
env_vars |
dict[str, str] |
Forwarded. |
include |
tuple[str, ...] |
Globs of source files to include. |
interruptible |
bool |
Allow preemptible nodes. |
description |
str |
Free text shown in the UI. |
sandbox_mode |
"userns" | "bwrap" |
"bwrap" adds SYS_ADMIN + AppArmor unconfined (see below). |
sys_cap_admin |
bool | None |
Explicit override of the CAP_SYS_ADMIN grant. None (default) = granted iff sandbox_mode="bwrap"; True = always; False = never (even for bwrap). |
runtime |
"container" | "gvisor" |
"gvisor" sets runtimeClassName: gvisor on the pod. |
The
pod_templateis not exposed — it's derived fromsandbox_mode,sys_cap_admin, andruntime.
Pod security: CAP_SYS_ADMIN for the bubblewrap backend
The bubblewrap backend runs as a non-root user via unprivileged user
namespaces (no privileged: true, no setuid). But the containerd default
seccomp profile only permits the mount / pivot_root / setns / unshare
syscalls bwrap needs when the container's capability set includes
CAP_SYS_ADMIN, and the default AppArmor profile must be unconfined so those
calls aren't blocked. sandbox_mode="bwrap" (or sys_cap_admin=True) makes the
deployed sandbox-server pod carry exactly this — and nothing more
(allowPrivilegeEscalation: false, no other caps).
The Environment emits it for you; if you build the pod yourself, this is
the equivalent Kubernetes spec (the primary container is named primary in
Flyte 2):
apiVersion: v1
kind: Pod
metadata:
annotations:
# AppArmor must be unconfined or the default profile blocks bwrap's
# mount/pivot_root/unshare. (K8s 1.30+ alternative: the
# `securityContext.appArmorProfile` field below.)
container.apparmor.security.beta.kubernetes.io/primary: unconfined
spec:
containers:
- name: primary
securityContext:
capabilities:
add: ["SYS_ADMIN"] # only cap added — needed for the seccomp profile
allowPrivilegeEscalation: false
# K8s >= 1.30 equivalent of the annotation above:
# appArmorProfile:
# type: Unconfined
The userns backend needs none of this — it runs in a vanilla unprivileged
pod. Use sys_cap_admin=False to deploy a bubblewrap env on a cluster that
already permits unprivileged user namespaces without the cap (keeps the pod's
host attack surface minimal), or sys_cap_admin=True to grant it for a userns
env on a cluster whose seccomp profile blocks the userns-lite syscalls.
The repo also exports two ready-built defaults:
sb.DEFAULT_ENV— userns isolation, container runtime, nameunion-sandbox(used when you callsession()with noenvironment=).sb.DEFAULT_ENV_BWRAP— bubblewrap isolation, container runtime, nameunion-sandbox-bwrap.
SandboxSession API
The SandboxSession returned by sb.session() is a dataclass
(serialisable across Flyte task boundaries via mashumaro).
Fields (mashumaro-serialised)
| Field | Type | Meaning |
|---|---|---|
name |
str |
Session name. Equals the Flyte run name. |
endpoint |
str |
HTTP(S) URL the transport opens against. Empty until the owner has waited for the pod to surface. |
ip |
str |
Pod IP, when surfaced by the runtime; otherwise empty. |
created_at |
datetime |
UTC timestamp of construction. |
network_mode |
"blocked" | "open" | "allowlist" |
Default network posture applied to every run(). |
network_allowlist |
list[str] |
Default CIDR / DNS-pattern allow-list, used only with network_mode="allowlist". |
Lifecycle
Bringup is split into two phases so user setup work can overlap with pod startup:
sbx = await sb.session(...) # submit run; returns instantly.
# A background task watches for
# phase=RUNNING + a pod IP.
# ... do other work here while the pod comes up ...
async with sbx: # awaits the endpoint task. After
# this, sbx.endpoint / sbx.ip are
# populated. NO Health-check yet.
proc = await sbx.run("uname -a") # first send: Health-checks the
# transport (exponential backoff,
# usually first try), then sends.
# Subsequent sends just send.
# context exit closes the local transport and aborts the run (owner side).
await sbx is equivalent to async with sbx: for phase 1: both wait
for the pod to be addressable. The Health-check is intentionally
deferred to the first run / put_bytes / get_bytes call, so
the time between "session created" and "ready to send" overlaps with
whatever the caller does next.
Methods
# Run a command in the sandbox.
proc = await sbx.run(
"python3 -c 'import os; print(os.uname())'",
stdout=True, # PIPE | INHERIT | DEVNULL | False
stderr=True,
env={"FOO": "bar"},
cwd="/tmp", # under the sandbox work dir
script_type="shell", # "shell" | "python"
network_mode="allowlist",
network_allowlist=["pypi.org", "*.pythonhosted.org"],
)
out, err = await proc.communicate()
# proc.returncode, proc.runtime_ms, proc.backend, proc.termination_reason
#
# communicate() / wait() raise ExecutionError if the process never
# ran to a real exit — a server-side spawn failure or a stream that died
# before termination (the cases that used to surface as a silent rc=-1).
# A non-zero exit of *your* code (any code, incl. 128+signo for a signal)
# is not an error: it returns normally and you branch on proc.returncode.
#
# from union.sandbox.errors import ExecutionError
# try:
# out, err = await proc.communicate()
# except ExecutionError as e:
# print("could not run:", e.reason) # + e.returncode / e.backend
# Push raw bytes into a file under the sandbox work dir.
n = await sbx.put_bytes("/tmp/sandbox-work/input.json", b'{"x": 1}')
# Pull raw bytes back out.
data = await sbx.get_bytes("/tmp/sandbox-work/result.json", max_bytes=10 * 1024 * 1024)
# Inspect.
sbx.name # str
sbx.endpoint # str
sbx.ip # str ("" if not yet known)
sbx.created_at # datetime (UTC)
sbx.is_owner # bool — True iff this side created the run (can abort it)
sbx.url # Optional[str] — Flyte console URL when owner
Owner vs reference mode
Only the owner side (the task that called sb.session()) can abort
the run. When you pass a SandboxSession as an input to another task,
mashumaro carries the dataclass fields (name, endpoint, ip,
created_at, network_mode, network_allowlist) across the boundary; the in-memory
_run / _client / _endpoint_task are not serialised. The receiver
lands in reference mode:
@env.task
async def child(sbx: sb.SandboxSession):
# No `async with` needed — endpoint + ip already round-tripped via
# mashumaro, and the first .run() lazily opens the local transport.
proc = await sbx.run("uname -a", stdout=True)
out, _ = await proc.communicate()
close() on the receiver only shuts the receiver's transport; the run
keeps going until the owner aborts.
Network policy
A sandboxed call gets one of three network postures:
# 1. Blocked (default): fresh netns, only `lo`.
await sbx.run("curl -fsS https://example.com/") # fails
# 2. Open: full host network.
await sbx.run("curl -fsS https://example.com/", network_mode="open")
# 3. Allow-list: shares host net, but a per-call HTTP CONNECT proxy
# 403s anything outside the list.
await sbx.run(
"uv pip install requests",
network_mode="allowlist",
network_allowlist=["pypi.org", "*.pythonhosted.org", "10.0.0.0/8"],
)
Read the two knobs as:
network_mode— the posture selector:"blocked","open", or"allowlist".network_allowlist— the allow-list entries, used only withnetwork_mode="allowlist".
network_allowlist constrains clients that honour HTTPS_PROXY (pip, curl,
requests, boto3, huggingface_hub), not adversarial code. See
docs/network-allow-list.md for the
threat model.
Without a context manager
The SandboxSession returned by sb.session(...) doesn't require
async with — you can keep the handle and own its lifetime. await it
once to wait for the pod to surface (optional; the transport also opens
lazily on the first run), then call await sbx.close() yourself
(which closes the transport and aborts the run).
from union import sandbox as sb
sbx = await sb.session(timeout=timedelta(minutes=30))
await sbx # wait for the pod to surface — fail fast on a bad launch
try:
proc = await sbx.run("uname -a", stdout=True)
out, _ = await proc.communicate_text()
finally:
await sbx.close()
To attach to a sandbox-server you started yourself, call
sb.remote.session(endpoint=...); for in-process, sb.on_device.session(...).
Installing
The Python wheel bundles the native extension. From a checkout:
make develop # creates .venv-build/, builds the Rust ext into it
.venv-build/bin/pip install -e ".[remote]" # if you want the remote transport
For a release build of the server binary:
make server # produces target/release/sandbox-server
To cross-build the linux/amd64 binary that base_image ships
into Flyte:
make deploy-binary # docker-runs rust:1-bookworm
What's in the repo
.
├── crates/
│ ├── sandbox-core/ Isolation backends (bubblewrap, userns, sandbox-exec, none)
│ ├── sandbox-server/ Connect-RPC server
│ └── sandbox-py/ PyO3 bindings → union.sandbox._native
├── python/
│ └── union/
│ └── sandbox/
│ ├── on_device/ OnDeviceSession (Rust-backed)
│ ├── remote/ RemoteSession + generated stubs
│ └── task/ Sandbox-server packaged as a Flyte task
├── proto/sandbox/v2/sandbox.proto
├── examples/
│ ├── on_device/{apps,tasks}/ on-device-transport examples (sb.on_device)
│ └── remote/{apps,tasks}/ SandboxSession examples + Streamlit app
└── docs/
├── changes.md Original proposal that drove v2
├── design.md Architecture + threat model
├── sandbox-as-task.md `sb.session(...)` lifecycle, dataproxy hop
├── network-allow-list.md Per-host/CIDR allow-list design
├── filesystem-allow-list.md Default FS allow-list + how to extend it
├── vs-sandbox-rs.md Comparison to ErickJ3/sandbox-rs (Rust lib)
└── vs-openshell.md Comparison to NVIDIA OpenShell (agent sandbox)
Isolation backends
| Backend | How it works | Unprivileged on K8s | Default? |
|---|---|---|---|
bubblewrap |
bwrap(1) with --unshare-all --die-with-parent --cap-drop ALL, plus a Landlock ruleset applied in pre_exec as a kernel-side backstop |
Yes | Yes |
userns |
Direct unshare(2) + prctl(NO_NEW_PRIVS) + capset + setrlimit, plus Landlock and a seccomp BPF deny-list (mount/eBPF/keyring/kexec/raw I/O) |
Yes | Fallback |
sandbox-exec |
macOS-only wrapper around Apple's deprecated sandbox-exec; restricts writes to the sandbox work dir and can deny outbound sockets |
n/a | macOS auto |
none |
setpgid + best-effort setrlimit; logs a warning |
n/a | macOS/Windows dev only |
Backend selection is always explicit — sb.on_device.session(backend="…")
(default "bubblewrap"), or the sandbox-server --backend flag. There is no
env var and no auto-detection: a requested backend that isn't available here
fails loudly rather than downgrading to weaker isolation.
Across the Linux backends, the sandbox layers four kernel-enforced
control families. Both bubblewrap and userns use the first three;
the current seccomp deny-list is userns-only:
- Namespaces (user / mount / UTS / IPC, plus net unless the caller opted in to host networking).
- Capabilities dropped from every set (bounding, ambient, effective/permitted/inheritable).
- Landlock (5.13+) — inode-level read-only access to
/usr,/lib,/etc,/proc,/sys,/dev/null, …; read-write to/tmp,/dev/shm, the per-session work dir (and the NVIDIA device nodes when a GPU is requested). Built once in the parent (landlock_create_ruleset+landlock_add_ruleper allowed path) and applied by the child via a single async-signal-safelandlock_restrict_selfsyscall inpre_exec. Older kernels gracefully degrade. Callers can add paths to this allow-list (never replacing the defaults) viaread_only_paths/read_write_pathsonsb.on_device.session(...)— seedocs/filesystem-allow-list.md. - Seccomp BPF deny-list (
usernsbackend only —bubblewrap's own setup needsmount/unshare, so it'd need bwrap's--seccomp FDpath; that's a follow-up). Pre-compiled withseccompiler, cached in aOnceLock, installed via oneprctl(PR_SET_SECCOMP)inpre_exec. Denies syscalls with no legitimate purpose in a sandbox: mount / namespace control, eBPF, kernel keyring, kexec, raw port I/O, file-handle reopen, perf counters, etc. ReturnsEPERM, notSIGSYS.
Building and testing
make test # Rust workspace + Python suites
cargo test # just the Rust crates
make py-test # just the Python tests
make examples-local # run every local-task example end-to-end
make deploy-sandbox # flyte deploy --all python/union/sandbox/task/_server.py
License
Business Source License 1.1 (BSL). Each released version converts to the
Apache License 2.0 on its Change Date (four years after release). See
LICENSE and the licenses/ directory for the full
BSL and Apache-2.0 texts.
Releasing
See RELEASING.md. Releases are cut from the GitHub UI
(Actions → Release → Run workflow); the PyPI upload uses tokenless Trusted
Publishing from the release environment.
Metadata
Release files for unionai-sandbox 0.0.1b15
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| unionai_sandbox-0.0.1b15.tar.gz | 182.1 kB | Details |
Built distributions (wheels)
| File | Reset | |||
|---|---|---|---|---|
| unionai_sandbox-0.0.1b15-cp39-abi3-manylinux_2_28_x86_64.whl | CPython 3.9 | abi3 | Linux glibc 2.28+ x86-64 | Details |
| unionai_sandbox-0.0.1b15-cp39-abi3-manylinux_2_28_aarch64.whl | CPython 3.9 | abi3 | Linux glibc 2.28+ ARM64 | Details |
| unionai_sandbox-0.0.1b15-cp39-abi3-macosx_11_0_arm64.whl | CPython 3.9 | abi3 | macOS 11.0+ ARM64 | Details |
| unionai_sandbox-0.0.1b15-cp39-abi3-macosx_10_12_x86_64.whl | CPython 3.9 | abi3 | macOS 10.12+ x86-64 | Details |
Total release size: 11.9 MB
Release files / unionai_sandbox-0.0.1b15.tar.gz
| Download URL | unionai_sandbox-0.0.1b15.tar.gz |
|---|---|
| Size | 182.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
034d7d441697a4a43eabeafe5ad9bdb372db6c9ea42c0145f384489da930a386
|
|
BLAKE2b-256 checksum How to use checksums |
d22568ab8eb540d8ab2b0acfdffd69ee48706a3b3899aa852456a9d65db3b91b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Release files / unionai_sandbox-0.0.1b15-cp39-abi3-manylinux_2_28_x86_64.whl
| Download URL | unionai_sandbox-0.0.1b15-cp39-abi3-manylinux_2_28_x86_64.whl |
|---|---|
| Size | 3.0 MB |
| Tags | CPython 3.9 Linux glibc 2.28+ x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
e192c4df1989da32db045fffbaa4e0137b1b7c2d3f25fdd0ceef67dc53a91a76
|
|
BLAKE2b-256 checksum How to use checksums |
0f29bae162f6886e2311a2fd472874d2d8d0516aba595660f279b53046c5b609
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Release files / unionai_sandbox-0.0.1b15-cp39-abi3-manylinux_2_28_aarch64.whl
| Download URL | unionai_sandbox-0.0.1b15-cp39-abi3-manylinux_2_28_aarch64.whl |
|---|---|
| Size | 3.0 MB |
| Tags | CPython 3.9 Linux glibc 2.28+ ARM64 abi3 |
|
SHA-256 checksum How to use checksums |
13b6827dab67104d29d0984e680987cb04781bdbf794c3a1abbb24eba80d42ce
|
|
BLAKE2b-256 checksum How to use checksums |
dbcc6df7836d0e8a2b49a9aa4a6b3f0c98b499cf41c5e81afbeb2bed96bd5de2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Release files / unionai_sandbox-0.0.1b15-cp39-abi3-macosx_11_0_arm64.whl
| Download URL | unionai_sandbox-0.0.1b15-cp39-abi3-macosx_11_0_arm64.whl |
|---|---|
| Size | 2.9 MB |
| Tags | CPython 3.9 abi3 macOS 11.0+ ARM64 |
|
SHA-256 checksum How to use checksums |
5c332513f7da22544c55b16cf71f13de5ab8447998e9b97acdff9cf5d2830a1d
|
|
BLAKE2b-256 checksum How to use checksums |
2d0a2376aaad1aa0f374cff51ffe80a9492a59a10927f6a1f6e7db445f8b0ec3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Release files / unionai_sandbox-0.0.1b15-cp39-abi3-macosx_10_12_x86_64.whl
| Download URL | unionai_sandbox-0.0.1b15-cp39-abi3-macosx_10_12_x86_64.whl |
|---|---|
| Size | 2.9 MB |
| Tags | CPython 3.9 abi3 macOS 10.12+ x86-64 |
|
SHA-256 checksum How to use checksums |
d076f4c67f4f511a0b8e2c24671fb64d0f7eb5fda09a05550097c249a20a1260
|
|
BLAKE2b-256 checksum How to use checksums |
26804a513c9df0226f74af361e2632066651b8a357f2d4755cde8a52190ad7bd
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|