Skip to content

Bubblewrap Sandbox

Run your agent’s commands in a Linux bubblewrap (bwrap) sandbox on the host they already run on, over SSH or locally.

Source

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.

Install

Terminal
pip install "pydantic-ai-harness[anthropic]"

The sandbox’s host must run Linux with bwrap installed (the bubblewrap package) and user namespaces allowed; otherwise commands raise WorkspaceUnavailableError. The anthropic extra is there because the examples use an Anthropic model; swap it for your model provider’s extra.

Quick start

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.')

What the sandbox allows

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.

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.

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. The cost is that sandboxed commands can see the host’s processes and their command lines, and signal the host user’s own.

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.

What it doesn’t protect

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 Makefile or an .envrc, runs unsandboxed if you later run it outside the sandbox.
  • The wrapped workspace’s own settings, bwrap_args and the host’s bwrap 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.

Use the workspace directly

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'])

Telemetry

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.

API reference

BubblewrapSandbox

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'))],
)

Attributes

network

Whether commands share the host’s network; without it, a seccomp filter also blocks every socket connection.

Type: bool Default: False

bwrap_args

Extra bwrap arguments, placed after the defaults so they can override them.

Type: Sequence[str] Default: ()

BubblewrapWorkspace

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).

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.

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.

Constructor Parameters

wrapped : Workspace

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.

bwrap_args : Sequence[str] Default: ()

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.