Augments LabsAugments ADK

๐Ÿชฃ Sandbox

A sandbox wraps every tool execution inside an isolated environment so that an agent's shell commands, file writes, and network calls cannot affect the host process or other tenants. This guide explains why sandboxing matters, what backends are available, how the framework picks one, and how you wire everything together.


Why sandboxes

When an agent calls a shell tool, the framework has no way to know in advance what that tool will do. A single command can delete files, open network connections, install packages, or fork new processes. Without isolation, a mistake or a prompt injection that reaches the shell tool has unbounded blast radius โ€” it can touch anything the host process can touch.

A sandbox draws a hard boundary. All execution inside it is subject to:

  • Network policy โ€” allowlist which hosts and ports are reachable.
  • Resource limits โ€” CPU cores, memory, wall-clock time, process count, disk.
  • Workspace isolation โ€” the agent sees only what the manifest declares.
  • Audit trail โ€” every command is emitted to an AuditSink before and after it runs.

The sandbox layer is the last line of containment. Guardrails decide whether a command is permitted; the sandbox decides where it runs and what it can reach while it runs. Those are orthogonal concerns โ€” see Tool permissions vs sandboxing below.


Two backend families

The framework ships two families of sandbox backend.

Local backends run on the same machine as the framework process. The framework speaks directly to the Docker daemon or the Kubernetes API server. Network round-trips are sub-millisecond; cold-start for a cached image is roughly one second. Local backends are ideal for CI pipelines, on-premises deployments, and any environment where you own the infrastructure.

Hosted bridges forward execution to a remote sandbox service over HTTPS. The framework speaks a thin REST dialect; each vendor translates it to their own substrate. You pay compute costs per minute but avoid managing infrastructure entirely. Hosted bridges are the right default for cloud deployments and for agents that need more resources than a single machine can provide.

PropertyLocal (Docker / K8s)Hosted bridges
Cold-start latency< 2 s (cached image)2โ€“10 s
Infrastructure opsYou manage the daemon / clusterVendor manages
Persistent workspaceYes (volumes)Provider-dependent
Network isolationLinux namespacesProvider-dependent
Cost modelInfrastructure cost onlyPer-minute metered
Offline capableYesNo

SandboxSelector ABC

When you supply more than one candidate backend, the framework delegates the choice to a SandboxSelector. The selector receives the list of SandboxCandidate objects (each pairing a BaseSandboxClient with its options) and a SandboxRequirements struct that describes what the run needs (network, persistence, custom capabilities).

import abc
 
class SandboxSelector(abc.ABC):
    @abc.abstractmethod
    def select(
        self,
        candidates: list[SandboxCandidate],
        requirements: SandboxRequirements,
    ) -> SandboxCandidate: ...

The built-in CheapestFirstSelector filters the list to candidates whose capabilities satisfy requirements, then returns the one with the lowest cost.usd_per_minute. Unpriced backends (local Docker, K8s) sort after all priced ones so hosted bridges are preferred only when you explicitly price them lower.

from augments.adk.sandbox.selector import CheapestFirstSelector, SandboxCandidate
from augments.adk.sandbox.clients.hosted.e2b import E2bSandboxClient, E2bSandboxClientOptions
from augments.adk.sandbox.clients.docker import DockerSandboxClient, DockerSandboxClientOptions
 
selector = CheapestFirstSelector()
candidates = [
    SandboxCandidate(
        client=DockerSandboxClient(),
        options=DockerSandboxClientOptions(image="python:3.12-slim"),
    ),
    SandboxCandidate(
        client=E2bSandboxClient(),
        options=E2bSandboxClientOptions(api_key="..."),
    ),
]

Wire the selector into SandboxRunConfig:

from augments.adk.sandbox.config import SandboxRunConfig
from augments.adk.types.sandbox.cost import SandboxRequirements
 
config = SandboxRunConfig(
    selector=selector,
    candidates=candidates,
    requirements=SandboxRequirements(network=True),
)

See Cost-Aware Sandbox Selection for the full cost-aware selection documentation.


Local backends

DockerSandboxClient

Spawns one long-lived container per session using sleep infinity as PID 1, then drives workloads with docker exec. The container is removed when the session closes.

from augments.adk.sandbox.clients.docker import DockerSandboxClient, DockerSandboxClientOptions
from augments.adk.sandbox.config import SandboxRunConfig
 
client = DockerSandboxClient()
config = SandboxRunConfig(
    client=client,
    options=DockerSandboxClientOptions(
        image="python:3.12-slim",
        memory_mb=512,
        cpu_count=1.0,
        working_directory="/workspace",
    ),
)

Key options:

  • image โ€” Docker image (required).
  • memory_mb / cpu_count / pid_limit โ€” resource caps translated to Docker's mem_limit / nano_cpus / pids_limit.
  • network_policy โ€” NetworkPolicy translated to Docker network arguments.
  • environment โ€” environment variables injected at container start.

Install the optional extra: pip install 'augments-adk[sandbox-docker]'.

When to use: local development, CI pipelines, single-machine deployments. DockerSandboxClient is the recommended local backend for production workloads where you control the Docker daemon.

K8sPodSandboxClient

Spawns an ephemeral Pod per session. NetworkPolicy objects are translated to Kubernetes NetworkPolicy custom resources; resource limits become ResourceQuota constraints.

from augments.adk.sandbox.clients.k8s import K8sPodSandboxClient, K8sSandboxClientOptions
from augments.adk.sandbox.config import SandboxRunConfig
 
client = K8sPodSandboxClient()
config = SandboxRunConfig(
    client=client,
    options=K8sSandboxClientOptions(
        image="python:3.12-slim",
        namespace="agents",
        service_account="sandbox-runner",
    ),
)

Key options:

  • image โ€” container image (required).
  • namespace โ€” Kubernetes namespace (default "default").
  • service_account โ€” serviceAccountName for the pod; None uses the namespace default.

Install the optional extra: pip install 'augments-adk[sandbox-k8s]'.

When to use: multi-tenant workloads where you need Kubernetes scheduling, autoscaling, and namespace-scoped RBAC. K8s PodSecurity admission labels are applied at the namespace level; the pod itself is created with the restricted profile by default.

LocalSubprocessSandboxClient

Runs commands as child processes of the host Python process inside a temporary working directory.

[!WARNING] LocalSubprocessSandboxClient provides no isolation. Commands run as the same user and with the same filesystem access as the framework process. Use only for local development and examples. Production deployments must use DockerSandboxClient, K8sPodSandboxClient, or a hosted bridge.

from augments.adk.sandbox.clients.local import (
    LocalSubprocessSandboxClient,
    LocalSandboxClientOptions,
)
from augments.adk.sandbox.config import SandboxRunConfig
 
client = LocalSubprocessSandboxClient()
config = SandboxRunConfig(
    client=client,
    options=LocalSandboxClientOptions(working_directory="/tmp/agent-workspace"),
)

The client logs a WARNING banner on construction so the lack of isolation is visible in every trace and audit log.


Hosted bridges

Each hosted bridge is a thin REST client that wraps one SaaS sandbox provider. All seven inherit from RemoteVMSandboxClient, which provides shared HTTP construction, retry logic, and error mapping. Every bridge uses httpx under the hood; the [sandbox-hosted] extra installs it.

The table below summarises the bridges, their backend_id, the rate the CheapestFirstSelector uses for ranking, and whether the backend declares a persistent workspace:

Bridge classbackend_idRate (USD/min)PersistentKey auth field
E2bSandboxCliente2b0.06Yesapi_key
ModalSandboxClientmodal0.10Yesapi_key
DaytonaSandboxClientdaytona0.08Yesapi_key
VercelSandboxClientvercel0.12Noapi_key
CloudflareSandboxClientcloudflare0.05Noapi_key
BlaxelSandboxClientblaxel0.09Yesapi_key
RunloopSandboxClientrunloop0.10Yesapi_key

Rates shown are the static defaults in the source; override the class-level cost attribute when actual provider pricing differs.

Configuration per backend

Every options class inherits the shared RemoteVMSandboxClientOptions base, which provides base_url, max_retries, and request_timeout. Each bridge then adds provider-specific fields:

from augments.adk.sandbox.clients.hosted.e2b import E2bSandboxClient, E2bSandboxClientOptions
 
# E2B โ€” template selects the sandbox environment image
options = E2bSandboxClientOptions(
    api_key="e2b_...",
    template_id="python-data-science",
    region="us-east-1",      # optional
)
 
from augments.adk.sandbox.clients.hosted.modal import ModalSandboxClient, ModalSandboxClientOptions
 
# Modal โ€” app_name + environment_name scope the sandbox
options = ModalSandboxClientOptions(
    api_key="ak-...",
    app_name="my-agent-app",
    environment_name="main",
    image="python:3.12-slim",
)
 
from augments.adk.sandbox.clients.hosted.cloudflare import (
    CloudflareSandboxClient,
    CloudflareSandboxClientOptions,
)
 
# Cloudflare โ€” account_id scopes billing
options = CloudflareSandboxClientOptions(
    api_key="cf_...",
    account_id="abc123",
)
 
from augments.adk.sandbox.clients.hosted.runloop import RunloopSandboxClient, RunloopSandboxClientOptions
 
# Runloop โ€” blueprint_id selects the sandbox image
options = RunloopSandboxClientOptions(
    api_key="rl_...",
    blueprint_id="bp_python312",
)

The remaining bridges (DaytonaSandboxClientOptions, VercelSandboxClientOptions, BlaxelSandboxClientOptions) follow the same pattern: api_key plus one or two provider-specific identifiers (workspace_id, project_id / team_id, workspace).


SnapshotStore

A SnapshotStore persists and restores sandbox workspace state between runs. The framework calls BaseSandboxSession.persist_workspace() at the end of a run to produce a tar stream, then hands it to the store's save() method. On the next run, BaseSandboxClient.create() receives the SnapshotSpec and asks the store for the bytes, then restores the workspace before the agent loop starts.

class SnapshotStore(abc.ABC):
    async def save(
        self, *, snapshot_id: str, data: IOBase, manifest_hash: str | None = None
    ) -> SnapshotMetadata: ...
    async def load(self, ref: SnapshotRef) -> IOBase: ...
    async def delete(self, ref: SnapshotRef) -> None: ...
    async def list(self, prefix: str | None = None) -> list[SnapshotMetadata]: ...
    async def exists(self, ref: SnapshotRef) -> bool: ...

Three concrete implementations ship out of the box:

  • LocalSnapshotStore โ€” writes tar files under a local directory. Suitable for single-machine development or CI.
  • S3SnapshotStore โ€” reads and writes snapshots from an S3-compatible bucket. Requires the [sandbox-s3] extra.
  • GCSSnapshotStore โ€” reads and writes snapshots from Google Cloud Storage. Requires the [sandbox-gcs] extra.

[!NOTE] Not all backends support snapshot_store. Docker, K8s, and the hosted bridges raise UnsupportedSnapshotFeatureError if you pass a non-None store. The session-level persist_workspace / restore path works independently of the store; the store is the persistence layer for cross-run restore.

Wire the store via SandboxRunConfig:

from augments.adk.sandbox.snapshot import LocalSnapshotStore
from augments.adk.sandbox.config import SandboxRunConfig
from augments.adk.types.sandbox.snapshot import SnapshotSpec
 
store = LocalSnapshotStore(base_dir="/var/augments/snapshots")
config = SandboxRunConfig(
    client=client,
    options=options,
    snapshot_store=store,
    snapshot=SnapshotSpec(snapshot_id="run-42"),
)

See Sandbox Snapshots for the full snapshot documentation.


RunHooks โ€” sandbox lifecycle callbacks

RunHooks exposes four sandbox-specific callbacks that fire around every sandboxed run. Subclass RunHooks and override only the callbacks you need:

from augments.adk.hooks.hooks import RunHooks
from augments.adk.run.context import RunContext
from augments.adk.types.sandbox.usage import SandboxUsage
from augments.adk.types.sandbox.exec_result import ExecResult
 
class MySandboxHooks(RunHooks):
    async def on_sandbox_start(self, context, agent, session) -> None:
        """Fires once when the sandbox session is acquired."""
        ...
 
    async def on_sandbox_stop(self, context, agent, session, usage: SandboxUsage) -> None:
        """Fires once when the session is released. usage carries wall-clock
        duration and, if capture_live_cost=True, the provider-reported cost."""
        ...
 
    async def on_sandbox_exec_start(self, context, agent, command: str) -> None:
        """Fires before each non-PTY command."""
        ...
 
    async def on_sandbox_exec_end(self, context, agent, command: str, result: ExecResult) -> None:
        """Fires after each non-PTY command. Non-zero exit codes surface here,
        not as exceptions โ€” the hook is observation only."""
        ...

Pass the hooks instance to Runner:

from augments.adk.run.runner import Runner
 
runner = Runner(agent=agent, hooks=MySandboxHooks())

The on_sandbox_snapshot callback (also on RunHooks) fires after a successful snapshot save and is the right place to log snapshot metadata or trigger an external notification.


Tool permissions vs sandboxing

Tool permissions and sandboxing are orthogonal โ€” they answer different questions.

Tool permissions (the allowed_tools allowlist on an agent or runner) answer: "Is this tool allowed to run at all?" A tool not in the allowlist is never invoked regardless of the sandbox backend.

Sandboxing answers: "In which environment does an allowed tool execute, and what can it reach?" Even if a tool is permitted, the sandbox can constrain its network access, CPU, memory, and filesystem visibility.

Both checks happen before the tool executes:

LLM tool call
    โ”‚
    โ–ผ
  Allowed-tools check  โ”€โ”€ rejected? โ†’ ToolNotAllowedError
    โ”‚
    โ–ผ
  Sandbox environment  โ”€โ”€ command policy? โ†’ SandboxCommandGuardrail
    โ”‚
    โ–ผ
  Execution inside sandbox

Because the concerns are separate you can, for example, allow all shell tools on a development agent but confine them to an unnetworked Docker container, while a production agent has a tighter tool allowlist and a networked hosted bridge for the subset of commands that need it.

See Permissions Usage for the tool-permission model.


Common patterns

Sandbox-by-default with per-tool opt-out

Attach a sandbox to every run via RunConfig.sandbox and let agents that do not need isolation opt out by omitting the sandbox field:

from augments.adk.run.config import RunConfig
from augments.adk.sandbox.config import SandboxRunConfig
from augments.adk.sandbox.clients.docker import DockerSandboxClient, DockerSandboxClientOptions
 
sandboxed_config = RunConfig(
    sandbox=SandboxRunConfig(
        client=DockerSandboxClient(),
        options=DockerSandboxClientOptions(image="python:3.12-slim"),
    )
)
 
# Unsafe agent that should never touch the host filesystem
await runner.arun(input="...", run_config=sandboxed_config)
 
# Coordination agent with no shell tools โ€” no sandbox needed
await coordinator_runner.arun(input="...")

Tenant-aware backend selection

In multi-tenant deployments you may want each tenant to run in a dedicated namespace or project. Build the SandboxRunConfig dynamically:

def make_sandbox_config(tenant_id: str) -> SandboxRunConfig:
    return SandboxRunConfig(
        client=K8sPodSandboxClient(),
        options=K8sSandboxClientOptions(
            image="python:3.12-slim",
            namespace=f"tenant-{tenant_id}",
        ),
    )

Pair this with the per-tenant task-queue routing documented in ๐Ÿ›ก๏ธ Governance when you need Temporal-level tenant isolation alongside sandbox isolation.

Cost-aware backend selection

Cheap local backends handle the bulk of triage work; expensive hosted bridges are reserved for heavy lifting. CheapestFirstSelector implements this automatically when you supply a mixed candidate list and a SandboxRequirements that matches only what each job needs:

from augments.adk.types.sandbox.cost import SandboxRequirements
 
# Triage run โ€” no network, any backend
triage_config = SandboxRunConfig(
    selector=CheapestFirstSelector(),
    candidates=candidates,           # Docker first, then hosted
    requirements=SandboxRequirements(network=False),
)
 
# Heavy lifting โ€” network required, pick cheapest qualifying hosted bridge
heavy_config = SandboxRunConfig(
    selector=CheapestFirstSelector(),
    candidates=candidates,
    requirements=SandboxRequirements(network=True, persistent=True),
)

CheapestFirstSelector will route triage jobs to the unpriced local Docker backend and heavy jobs to whichever hosted bridge has the lowest usd_per_minute rate while satisfying the requirements.


See also