Bubblewrap Sandbox
Run your agent’s commands in a Linux bubblewrap (bwrap) sandbox on the host they already run on, over SSH or locally.
While Pydantic AI Harness is on 0.x releases, the API may change between minor releases; when it does, deprecation warnings and release-note migration guidance tell you (or your agent) exactly how to upgrade. See the version policy.
pip install "pydantic-ai-harness[coder,anthropic]"
uv add "pydantic-ai-harness[coder,anthropic]"
The sandbox’s host must run Linux with bwrap installed (the bubblewrap package) and user namespaces allowed; otherwise commands raise WorkspaceUnavailableError. The coder extra is there because the examples use Coder; the sandbox itself doesn’t need it. The anthropic extra is there because the examples use an Anthropic model; swap it for your model provider’s extra.
BubblewrapSandbox wraps another workspace capability and runs its commands in a sandbox on that workspace’s host. Wrap SSHWorkspace to sandbox commands on a remote host, or LocalWorkspace to sandbox them on this one:
from pydantic_ai import Agent
from pydantic_ai_harness import BubblewrapSandbox, Coder, SSHWorkspace
agent = Agent(
'anthropic:claude-opus-5-5',
capabilities=[BubblewrapSandbox(SSHWorkspace('dev@build-box', working_dir='/srv/app')), Coder()],
)
result = agent.run_sync('Run the test suite and fix the first failure.')
Inside the sandbox, commands see the host’s files read-only, a private /tmp and no network, and can write only to the working directory. /run is empty, because host daemons such as Docker listen on sockets there, and a read-only mount doesn’t stop a connection. bwrap must be able to give the sandbox its own user namespace, and the sandbox has no capabilities.
When the working directory contains the host account’s home directory, as an SSHWorkspace without a working_dir does, the files the host runs or reads when the next SSH connection logs in, before the command starts, are mounted read-only: ~/.ssh, ~/.pam_environment, and the startup files a login shell runs for a command (~/.bashrc, ~/.zshenv, ~/.cshrc, ~/.tcshrc and ~/.config/fish). Missing ones are created first, empty. If one of them is a symlink, the sandbox doesn’t start, because a command could replace the link. Neither finding the home directory (cd -P and pwd -P on an absolute $HOME) nor creating a missing ~/.ssh (/bin/mkdir) searches PATH; without an absolute home the sandbox does not start. bwrap_args come after these mounts and can still replace them.
Without the network, a seccomp filter blocks the same system calls as the Codex CLI’s Linux sandbox does without network access: commands can’t connect to, serve or accept on any socket, including Unix sockets the host has elsewhere on disk and the sandbox’s own loopback, and can’t use ptrace or io_uring. Commands can still create stream socket pairs, which language runtimes use between their own processes. Unlike Codex, Unix datagram sockets are blocked too, because a datagram can be addressed to any socket file without connecting first. So a test suite that starts a local server needs network=True, and so does Python’s forkserver start method for multiprocessing (the default on Linux since Python 3.14), which listens on a socket: multiprocessing.get_context('spawn') or 'fork' work without it. The filter covers x86_64 and aarch64 hosts; a 32-bit program in the sandbox is stopped when it makes a system call.
Pass network=True to share the host’s network: commands can reach it, including services on the host’s loopback and anything the host can reach, and can listen on the host’s ports. The DNS configuration under /run comes back with it, and there is no seccomp filter.
Add bwrap arguments with bwrap_args=: they come after the defaults, so ['--bind', path, path] makes another directory writable and ['--tmpfs', path] hides one, such as ~/.ssh. The program that starts bwrap, and base64 when the filter loads, is taken from a PATH directory outside the working directory.
Commands share the host’s process list rather than getting their own, so a command started in the background, such as a Shell background job or a dev server, keeps running after the call that started it, and after the agent run, until something stops it. Later calls can check on it or stop it. On timeout or cancellation, what stops is up to the wrapped backend. LocalWorkspaceBackend and SSHWorkspaceBackend stop the command and its foreground descendants; SSH cleanup is best effort if the host stalls or becomes unreachable. The E2B and Modal backends do the same only when the image has setsid, and otherwise stop just the command itself, so its children may keep running. Jobs deliberately detached into their own session keep running on every backend. The cost is that sandboxed commands can see the host’s processes and their command lines, and signal the host user’s own. A call’s env values are bwrap --setenv arguments, so they show up in that process list while the command runs.
File methods such as write_text, and so the FileSystem and Coder file tools, run in the sandbox too, as shell commands, so they see what commands see: they can’t write outside the working directory, even through a symlink a command swapped in after a path check. Only when the wrapped workspace is read-only, and so runs no commands, are its files read from the host directly. The run’s ref is the wrapped workspace’s, and its backend is the wrapped backend.
The wrapped workspace must run commands without a controlling terminal. BubblewrapWorkspace omits bwrap --new-session so the backend can stop foreground descendants on timeout or cancellation; with a controlling terminal, kernels permitting TIOCSTI could let sandboxed commands inject input into the host terminal. LocalWorkspaceBackend starts a new session and SSHWorkspaceBackend uses ssh -T. E2BSandboxBackend and ModalSandboxBackend run commands through their providers’ non-PTY command APIs, so no terminal is attached, with or without setsid in the image. All four satisfy this requirement; custom backends must do so too.
The sandbox keeps an agent from changing the host outside its working directory. It is not a boundary against an agent trying to harm the host user:
- Commands can read every file the host user can, such as SSH keys and cloud credentials, and show them to the model. Hide those directories with
bwrap_args=['--tmpfs', path]. - Commands can signal, and so stop, the host user’s other processes.
- Anything a command writes in the working directory, such as Git hooks, a
Makefileor an.envrc, runs unsandboxed if you later run it outside the sandbox. - With the home directory writable, so are the files only an interactive login runs, such as
~/.profileand~/.bash_profile, and other per-user configuration. Give the sandbox a project directory as itsworking_dirinstead. - The wrapped workspace’s own settings,
bwrap_args, and the host’sbwrapoutside the working directory are trusted.
For an agent you don’t trust, give it its own user on the host, or use a cloud sandbox such as E2B.
The capability builds a BubblewrapWorkspace, a WrapperWorkspace you can also use directly, around any workspace:
from pydantic_ai.workspaces import CommandResult, Workspace
from pydantic_ai_harness import BubblewrapWorkspace, SSHWorkspaceBackend
async def run_tests() -> CommandResult:
workspace = BubblewrapWorkspace(Workspace(SSHWorkspaceBackend('dev@build-box')))
# `bwrap` runs `make test` on build-box
return await workspace.run(['make', 'test'])
BubblewrapSandbox emits no spans of its own. The run’s workspace is the wrapped one’s, so core’s instrumentation records its pydantic_ai.workspace.provider and pydantic_ai.workspace.id on the agent run span, and each sandboxed command runs inside its tool call’s span.
Bases: WrapperCapability[AgentDepsT]
Runs the commands of the wrapped capability’s workspace in a bubblewrap sandbox, on that workspace’s host.
See BubblewrapWorkspace for what the sandbox allows.
from pydantic_ai import Agent
from pydantic_ai_harness import BubblewrapSandbox, SSHWorkspace
agent = Agent(
'anthropic:claude-opus-5-5',
capabilities=[BubblewrapSandbox(SSHWorkspace('dev@build-box', working_dir='/srv/app'))],
)
Whether commands share the host’s network; without it, a seccomp filter also blocks every socket connection.
Type: bool Default: False
Extra bwrap arguments, placed after the defaults so they can override them.
Type: Sequence[str] Default: ()
Bases: WrapperWorkspace
A Workspace that runs commands in a bubblewrap (bwrap) sandbox.
The wrapped workspace runs bwrap, so the sandbox is on its host: wrap an
SSHWorkspaceBackend to sandbox commands on the
remote host. bwrap must be installed there (Linux only).
The wrapped workspace must run commands without a controlling terminal. This wrapper omits
bwrap --new-session so the backend can stop foreground descendants on timeout or cancellation;
with a controlling terminal, kernels permitting TIOCSTI could let sandboxed commands inject input
into the host terminal. LocalWorkspaceBackend starts a new session and SSHWorkspaceBackend uses
ssh -T. E2BSandboxBackend and ModalSandboxBackend run commands through their providers’ non-PTY
command APIs, so no terminal is attached, with or without setsid in the image. All four satisfy this
requirement; custom backends must do so too.
Commands see the host read-only, with an empty /run, a private /tmp, and no network, and can only
write to the working directory. Without the network, a seccomp filter also stops them from connecting to
or serving any socket, including the host’s Unix sockets. They share the host’s processes, so a detached
command keeps running after the call that started it, and they can signal the host user’s other processes.
bwrap and, without the network, base64 are taken from a PATH directory outside the working
directory, so a command cannot replace the next launch by writing there. When that directory contains
files the next SSH login runs on the host, such as ~/.ssh/rc or ~/.bashrc, they are bind-mounted
read-only (and created if missing), so a command cannot run code outside the sandbox through them.
File methods run in the sandbox too, as shell commands, so they see what commands see and can’t be tricked into writing outside it through a symlink. Only when the wrapped workspace is read-only (and so runs no commands) do its file methods read the host directly.
The workspace whose host runs the sandbox.
network : bool Default: False
Whether commands share the host’s network; without it, a seccomp filter also blocks every socket connection.
Extra bwrap arguments, placed after the defaults so they can override them,
such as ['--bind', path, path] for another writable directory or ['--tmpfs', secrets_dir]
to hide one.
def durable_policy() -> tuple[object, ...]
(network, bwrap_args), so a durable unit cannot rebuild this sandbox with another.