Skip to content

Use a sandbox

This guide shows you how to route an agent's file, shell, and browser side effects into a container, so an untrusted agent never touches the host directly. You need to be able to drive an agent with Client (Your first agent) and a container runtime.

Noeta ships the seam (ExecEnv, SandboxProvider) and the container-wire adapters. Provisioning the container is the host's job — the library runs no docker for you.

Pick a mode

Two entry points on HostConfig, depending on whether you want one shared container or a fresh one per session:

ModeHostConfig fieldWhen to use
Attach (one shared container)exec_env: SandboxExecEnvConfigA single long-lived container every session attaches to — simplest, good for development.
Provision (per-session container)sandbox_provider: SandboxProvider + sandbox_spec: SandboxSpecA fresh container allocated when a session opens and torn down when it ends — production isolation.

SandboxProvider takes precedence when both are set.

Mode A — attach to a running container

The fastest path: start a container yourself (e.g. an AIO Sandbox image), then tell Noeta its address.

python
from noeta.sdk import Client, HostConfig, Options, SandboxExecEnvConfig

host_config = HostConfig(
    exec_env=SandboxExecEnvConfig(
        base_url="http://localhost:8080",
        api_key_env="SANDBOX_API_KEY",   # read from env at connect time
        workdir="/workspace",
    ),
)

client = Client(
    Options(system_prompt="You are a coding agent."),
    provider=my_provider,
    workspace_dir="./workspace",
    model="claude-sonnet-4-5-20250929",
    host_config=host_config,
)

The API key is read from the environment variable named by api_key_envonly when a request is made — it never enters the config, the log, or an event. Set it before the first turn:

bash
export SANDBOX_API_KEY=your-container-key

Verify the routing by asking the agent to run something that reveals where it is:

Bash(command="hostname")  →  a1b2c3d4e5f6   # the container, not your host

In attach mode every session shares the same container. release is a no-op because the SDK does not own the container's lifecycle.

Mode B — provision one per session

For per-session isolation, implement the three-method SandboxProvider protocol and pass it as HostConfig.sandbox_provider. The SDK calls allocate when a root task opens, attach on resume / stale-reclaim, and release when the root task reaches a terminal state.

python
from noeta.sdk import (
    SandboxSpec, SandboxHandle, StaticApiKeyAuth, decode_exec_env_ref,
)

class DockerSandboxProvider:
    """Provision a fresh container per root-task tree via the Docker CLI."""

    def allocate(self, root_task_id: str, spec: SandboxSpec) -> SandboxHandle:
        # 1. Run the container. Mount the workspace; spec.mounts carries the
        #    assembled list (workspace + skills + host extensions).
        mounts = " ".join(
            f"-v {m.source}:{m.target}:{m.mode}" for m in spec.mounts
        )
        import subprocess
        result = subprocess.run(
            ["docker", "run", "-d", "--rm",
             *mounts.split(),
             spec.image,
             "sleep", "infinity"],
            check=True, capture_output=True, text=True,
        )
        container_id = result.stdout.strip()

        # 2. Probe readiness, then return the handle. base_url must be the
        #    container's API root; workdir is the container-side workspace.
        return SandboxHandle(
            base_url="http://localhost:8080",
            sandbox_id=container_id,
            auth=StaticApiKeyAuth(env_name="SANDBOX_API_KEY"),
            workdir="/workspace",
        )

    def attach(self, exec_env_ref: str) -> SandboxHandle:
        # Reconnect to a container recorded on TaskHostBound.exec_env_ref.
        base_url, sandbox_id = decode_exec_env_ref(exec_env_ref)
        return SandboxHandle(
            base_url=base_url,
            sandbox_id=sandbox_id,
            auth=StaticApiKeyAuth(env_name="SANDBOX_API_KEY"),
            workdir="/workspace",
        )

    def release(self, root_task_id: str) -> None:
        # Idempotent: look up the container for this root task and stop it.
        # A real provider keeps a root_task_id -> container_id map.
        ...

Wire it:

python
from noeta.sdk import HostConfig, SandboxSpec, MountSpec

host_config = HostConfig(
    sandbox_provider=DockerSandboxProvider(),
    sandbox_spec=SandboxSpec(
        image="aio-sandbox:latest",
        mounts=(
            MountSpec(source="./workspace", target="/workspace", mode="rw"),
        ),
        resources={"memory": "2g", "cpus": "2"},
    ),
)

What SandboxSpec carries

FieldShapePurpose
imagestrThe container image to run.
mountstuple[MountSpec, ...]Workspace + skills + host mounts. MountSpec.kind is "local-path" / "volume" / "nas" / "pvc" so a distributed provider maps the same shape to its storage backend.
resourcesMapping[str, str]Memory / CPU caps — passed straight to your provisioner.
envMapping[str, str]Extra container environment.

The durable binding: exec_env_ref

A session's container address is recorded on TaskHostBound.exec_env_ref as the flat string "{base_url}#{sandbox_id}". On resume or stale-reclaim the SDK calls provider.attach(exec_env_ref) — never allocate — so a resumed task reconnects to the same container. Credentials are not in the ref; the reconnecting host rebuilds SandboxAuth from its own environment.

What runs in the container

Under a sandbox, these side effects route through the session's ExecEnv:

  • fs toolsRead, Write, Edit, Glob, Grep
  • foreground shellBash (background shell is host-side and refused under a container)
  • web egressWebFetch / WebSearch go out via curl inside the container
  • skill indexingtree_snapshot batches the walk into one round-trip
  • browser toolsbrowser_navigate, browser_click, … via the BrowserBackend wire

Deliberately host-side: memory_* (global cross-session store), MCP, background shell, and the app preview gateway.

Browser tools in a sandbox

The browser built-in contributes the browser_* tool pack, but it mounts only when both hold:

  1. the agent activates browser ("browser" in Options.plugins), and
  2. the session is bound to a live sandbox container.

Among the official presets only the web subagent opens browser; main stays browser-free and delegates page work to it. Opt in with sandbox_browser_options():

python
from noeta.sdk import presets

options = presets.sandbox_browser_options()   # main + the web subagent, browser on

Knobs

HostConfig fieldPurpose
sandbox_exec_preamble(exec_env_ref, argv) -> str — prepend a per-command shell prefix (e.g. mint a fresh credential). Re-invoked every command.
sandbox_backend_factory / sandbox_browser_factorySwap the container wire without touching the seam.
sandbox_policy(root_task_id, workspace_dir) -> bool — per-session opt-out of the sandbox; False falls back to LocalExecEnv.

Known boundaries

  • The library ships no provisioner — running docker, a K8s API, or a remote session service is the host's job. The in-box provider only attaches.
  • Sandbox side effects are not fenced across worker generations: a worker whose lease expired can still POST to the container. Bounded by the same step-attempt re-drive as crashed-step side effects.
  • Bash's timeout is enforced client-side under a sandbox — the command keeps running in the container after the call returns.

Next steps

Released under the Apache License 2.0.