<!-- Generated by scripts/gen_llms_full.py from the pages listed in llms.txt. -->

# sandboxio

> One secure Python API for running AI-agent code in any sandbox (Docker, E2B, an in-process fake). Async-first on anyio with a sync facade; deny-by-default egress; mandatory timeouts; isolation tiers reported and enforceable; stable error codes; offline testing with FakeBackend.

Canonical usage is `import sandboxio`, unaliased. `SBX` is the short code (error codes `SBX_E1002`, env vars `SBX_DEBUG`, fixture `sbx_fake`). Copy-paste commands use `sandboxio`, never the `sbx` alias. `.native` is outside the semver contract. The specification in `docs/spec/` is normative and wins over every other document.

## Start here

- [README](../README.md): what it is and runnable examples
- [Examples](../examples/README.md): complete runnable programs, one concept each, all executed by CI
- [Quickstart](quickstart.md): install to a sandboxed run in five minutes
- [AGENTS.md snippet](reference/agents-snippet.md): paste-ready rules for coding assistants in a project that uses sandboxio

## How-to

- [Docker](how-to/docker.md): images with dependencies, offline wheelhouses, reaper, allowlists refused
- [E2B](how-to/e2b.md): API key, allowlists, stateful contexts, rich outputs
- [Offline testing](how-to/offline-testing.md): sbx_fake fixture, scripting, register() so DSNs resolve to the fake
- [Audit and tracing](how-to/observability.md): LoggingSink, FileSink, QueueSink, OTel execute_tool spans, redaction
- [CI](how-to/ci.md): copy-paste GitHub Actions workflow, fake on PRs and Docker on main
- [Integrations](how-to/integrations.md): LangGraph tool, OpenAI Agents tool, MCP server and its container image
- [Operations](how-to/operations.md): sandboxio doctor, sandboxio reap, SBX_* variables, exit codes
- [Troubleshooting](how-to/troubleshooting.md): diagnosis by symptom — no network inside the sandbox, three timeouts, refusals, leaked containers

## Explanation

- [The security model](explanation/security-model.md): threat model in and out of scope, the five defaults, what is not claimed
- [Isolation tiers](explanation/isolation-tiers.md): CONTAINER is not a boundary; MICROVM is the floor for untrusted code
- [Deny by default](explanation/deny-by-default.md): why egress is off and what it costs
- [Why errors have codes](explanation/error-codes.md): stable codes, hints that are fixes, no builtin inheritance
- [Version policy](explanation/version-policy.md): what breaks, what a deprecation window is, why `.native` is exempt
- [Why sandboxio and not something else](explanation/comparisons.md): provider SDKs, framework sandbox layers, plain Docker, and when not to use this

- [FAQ](faq.md): short answers with a link to the long one

## Reference

- [Error codes](errors/README.md): every SBX_E code, generated from the source
- [Public API spec](spec/03-public-api.md): create(), sync facade, stability contract
- [Ports spec](spec/02-ports.md): Backend, AsyncSandbox, Process, AsyncFileSystem, ReapableBackend
- [Domain model spec](spec/01-domain-model.md): value objects, Capability, IsolationTier
- [Errors spec](spec/04-errors.md): tree and rendering
- [Security policy spec](spec/05-security-policy.md): defaults, network, secrets, tenancy
- [Observability spec](spec/06-observability.md): the operation record, sinks, spans
- [Configuration spec](spec/07-configuration.md): DSN grammar, typed config, routing file
- [Adapter contract spec](spec/08-adapter-contract.md): what every adapter must do
- [Integrations spec](spec/09-integrations.md): LangGraph, OpenAI Agents, MCP server
- [CLI spec](spec/10-cli.md): doctor, reap, demo

## Optional

- [Architecture decision records](adr/README.md): why each decision was made
- [Hazards](hazards.md): what can go wrong and the tripwires
- [Build order](build-order.md): what is built, in what order, with exit criteria
- [Full text](llms-full.txt): every page above concatenated, for one-shot context loading


==============================================================================
# FILE: README.md
==============================================================================

# sandboxio

**One secure Python API for running AI-agent code in any sandbox.** Swap Docker ↔ E2B with
one line; no network by default; test your agent tools offline with the built-in fake.

[![PyPI](https://img.shields.io/pypi/v/sandboxio)](https://pypi.org/project/sandboxio/)
[![CI](https://github.com/bit-agents/sandboxio/actions/workflows/ci.yml/badge.svg)](https://github.com/bit-agents/sandboxio/actions/workflows/ci.yml)
[![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue)](docs/adr/0015-python-version-floor.md)
[![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/bit-agents/sandboxio/badge)](https://scorecard.dev/viewer/?uri=github.com/bit-agents/sandboxio)
[![Coverage](https://img.shields.io/badge/coverage-%E2%89%A580%25-green)](https://github.com/bit-agents/sandboxio/actions/workflows/ci.yml)

> [!IMPORTANT]
> **v0.1 — the API is not stable until 1.0.** A patch release never breaks the surface
> [`docs/spec/03-public-api.md`](docs/spec/03-public-api.md#stability-contract) covers; a
> minor release may, and every break arrives with a changelog entry and a migration note.
> Pin a minor if that matters to you.
>
> What counts as a break, how long a deprecation lives, and why the answer differs for
> callers and for adapter authors: [version policy](docs/explanation/version-policy.md).

## Why this exists

Every sandbox provider ships its own SDK with its own shape, so agent code that runs on one
is rewritten for the next. The framework layers each solve it captively — LangChain's
backends only help inside LangChain. sandboxio is the framework-agnostic substrate
underneath, with the security posture that agent execution actually needs.

- **Security is the headline, not an add-on.** Deny-by-default egress, mandatory timeouts,
  isolation-tier reporting and audit hooks are all in v0.1 — not a later hardening pass.
- **No lowest common denominator.** One-backend features stay reachable through `Capability`
  flags and `.native` instead of being sanded off.
- **Tiny, auditable core.** `anyio` and `typing-extensions` only; every backend SDK sits
  behind an extra and is imported lazily.
- **Offline-testable.** `FakeBackend` and a pytest fixture, so your agent's tools have tests
  that need no Docker, no network and no provider account.
- **Usable without writing Python.** The same sandbox and the same defaults are served over
  MCP, so a client like Claude Desktop or Cursor gets code execution it cannot reconfigure
  at runtime.
- **Provider churn is absorbed publicly.** Upstream breaking changes are tracked, absorbed
  and written down.

## Quickstart

Every Python block below is executed by CI exactly as written
([`tests/test_readme_examples.py`](tests/test_readme_examples.py)); a copied example that
does not run is a P0 bug. The Docker-backed lines run against the built-in fake in CI, so
they are the same code you would run — only the backend differs.

The commands below are what v0.1 will install. Today the name on PyPI holds a `0.0.0`
placeholder that carries no code, so `uv add sandboxio` does not yet get you a library.
`uvx sandboxio demo` is the documented entry point — never the `sbx` alias, which resolves
to an unrelated PyPI package ([ADR-0014](docs/adr/0014-project-name.md)).

```bash
uv add "sandboxio[docker]"     # from v0.1
uvx sandboxio demo             # create, run, stream, prove egress is denied, tear down
sandboxio doctor               # what is installed, reachable and missing — no secrets printed
```

Run code in a sandbox. `create()` with no arguments is local Docker; nothing leaves the
sandbox unless you say so.

```python
import sandboxio

async with await sandboxio.create() as sb:              # zero-config, local Docker
    res = await sb.run_code("print('hello')")
    print(res.stdout)                                    # hello
    res = await sb.run(["python", "--version"], timeout=30)
    print(res.exit_code, res.stdout.strip())
```

Swap the backend with one line. The code that uses the sandbox does not change.

```python
import sandboxio

async def summarise(sb: sandboxio.protocols.AsyncSandbox) -> str:
    await sb.files.write("/work/input.txt", "3 4\n")
    res = await sb.run_code("a, b = open('/work/input.txt').read().split(); print(int(a) * int(b))")
    res.raise_for_status()                               # ExecutionError on a non-zero exit
    return res.stdout.strip()

async with await sandboxio.create("docker://python:3.12-slim") as sb:
    print(await summarise(sb))
async with await sandboxio.create("e2b://code-interpreter-v1") as sb:   # needs E2B_API_KEY
    print(await summarise(sb))
```

Stream output while a long command runs, and rely on the timeout: a sandbox timeout is
`sandboxio.SandboxTimeout`, never the builtin `TimeoutError`.

```python
import sandboxio

async with await sandboxio.create() as sb:
    async with sb.stream(["sh", "-c", "echo start; sleep 1; echo done"], timeout=30) as proc:
        async for chunk in proc:
            print(chunk.stream, chunk.data.decode(), end="")
        res = await proc.wait()
    try:
        await sb.run(["sleep", "999"], timeout=1)
    except sandboxio.SandboxTimeout as exc:
        print(exc.code)                                  # SBX_E1302 — stable, documented
```

Sync code gets the same surface through `create_sync()`. One sandbox is one portal thread;
use the async API for heavy concurrency.

```python
import sandboxio

with sandboxio.create_sync("docker://python:3.12-slim", timeout=60) as sb:
    print(sb.run_code("print(6 * 7)").stdout)
```

Test your agent's tools offline. `sbx_fake` is a pytest fixture installed with the package;
it executes nothing, records everything, and passes the same contract suite as the real
backends.

```python
import pytest
from sandboxio import ExecResult


@pytest.mark.anyio
async def test_my_tool_reads_the_pandas_version(sbx_fake):
    sbx_fake.on_run_code(match="import pandas", returns=ExecResult(0, "2.2.1\n", ""))
    res = await sbx_fake.sandbox.run_code("import pandas; print(pandas.__version__)")
    assert "2.2.1" in res.stdout
    assert sbx_fake.calls[0].network.egress == "deny"     # the default, recorded
```

Full surface, including capability discovery, `require_isolation`, audit sinks and spans:
[`docs/quickstart.md`](docs/quickstart.md) and the normative
[`docs/spec/03-public-api.md`](docs/spec/03-public-api.md).

## Backends

| Backend | Status | Isolation tier |
|---------|--------|----------------|
| Docker | shipped in v0.1 | `CONTAINER` — [containers share the host kernel](https://docs.docker.com/get-started/docker-concepts/the-basics/what-is-a-container/) |
| E2B | shipped in v0.1 | `MICROVM` — [Firecracker microVM, its own kernel](https://e2b.dev/security) |
| `FakeBackend` | shipped in v0.1 | n/a — in-process, for tests |
| Modal | planned for v0.1.1 | `GVISOR` — [containerised and virtualised using gVisor](https://modal.com/docs/guide/security) |

Every tier above is the mechanism the provider documents for itself, read on **2026-09-20**.
None of those three pages carries its own revision date, so that is the date it was read, not
a date the provider published; re-verification is a [quarterly item](docs/runbook.md).
What the tiers mean — and why `GVISOR` is defence in depth rather than VM equivalence — is in
[`docs/explanation/isolation-tiers.md`](docs/explanation/isolation-tiers.md).

Third-party adapters are first-class: the adapter contract is
[specified](docs/spec/08-adapter-contract.md) and enforced by a shared contract suite.

## Integrations

- **LangGraph / LangChain** — `sandboxio.integrations.langgraph.make_code_tool()` returns a
  native `BaseTool` (`sandboxio[langgraph]`).
- **OpenAI Agents SDK** — `sandboxio.integrations.openai_agents.make_code_tool()` returns a
  native `FunctionTool` (`sandboxio[openai-agents]`).
- **MCP** — `python -m sandboxio.mcp --backend docker://python:3.12-slim` serves
  `run_python`, `run_command`, file tools and `sandbox_info` (`sandboxio[mcp]`), also as a
  container image.

## Documentation

**[docs.sandboxio.dev](https://docs.sandboxio.dev)** — the Markdown under
[`docs/`](docs/) is its source and stays the source, so nothing below moves. Read it here
or there:

- [`examples/`](examples/) — complete programs, one concept each: hello world, swapping
  backends, streaming, proving egress is denied, agent tools. Each one is executed by CI.
- [`docs/quickstart.md`](docs/quickstart.md) — five minutes from install to a sandboxed run.
- [`docs/how-to/`](docs/how-to/) — Docker images and offline wheelhouses, E2B keys and
  allowlists, offline testing with the fake, audit sinks and tracing, CI, and
  [troubleshooting by symptom](docs/how-to/troubleshooting.md).
- [`docs/explanation/`](docs/explanation/) — [the security model](docs/explanation/security-model.md),
  isolation tiers, deny-by-default, why errors have codes, the
  [version policy](docs/explanation/version-policy.md) and
  [why sandboxio and not something else](docs/explanation/comparisons.md).
- [`docs/faq.md`](docs/faq.md) — the questions a newcomer asks first, answered short.
- [`docs/errors/`](docs/errors/README.md) — every error code, generated from the source.
- [`docs/spec/`](docs/spec/) — the normative specification. This is the contract.
- [`docs/adr/`](docs/adr/) — why each decision was made, including the ones that look arbitrary.
- [`docs/llms.txt`](docs/llms.txt) — for coding assistants; `llms-full.txt` beside it.

## Security

Isolation tiers are reported, not assumed: `CONTAINER` is not a security boundary against
hostile code, and sandboxio says so rather than implying otherwise.

Report vulnerabilities privately — see [`SECURITY.md`](SECURITY.md). A report that a
*declared control did not apply* is the highest-severity class this project has.

The project's own supply-chain posture is scored weekly by
[OpenSSF Scorecard](https://scorecard.dev/viewer/?uri=github.com/bit-agents/sandboxio) and
published — the badge above links to the run that produced it.

## Contributing

Read [`CONTRIBUTING.md`](CONTRIBUTING.md). Contributions need a DCO `Signed-off-by` line;
there is no CLA.

## License

Code is [MIT](LICENSE). Prose in `docs/` is [CC BY 4.0](LICENSE-DOCS); code samples inside
those documents are MIT ([ADR-0026](docs/adr/0026-docs-license-cc-by.md)).


==============================================================================
# FILE: examples/README.md
==============================================================================

# Examples

Complete programs, one concept each. Every one of them is executed by CI
([`tests/test_examples.py`](../tests/test_examples.py)) with the Docker and E2B backends
swapped for the in-process fake, so an example that stopped working fails our build rather
than your first attempt.

| Example | Shows | Needs |
|---------|-------|-------|
| [`01_hello_sandbox.py`](01_hello_sandbox.py) | create → `run_code` → `run` → teardown | Docker |
| [`02_swap_backends.py`](02_swap_backends.py) | one function, two providers, one line changed | Docker, `E2B_API_KEY` |
| [`03_stream_and_timeout.py`](03_stream_and_timeout.py) | `stream()`, and `SandboxTimeout` (`SBX_E1302`) stopping a runaway | Docker |
| [`04_egress_denied.py`](04_egress_denied.py) | deny-by-default egress, proven, then the explicit opt-out | Docker |
| [`05_langgraph_agent.py`](05_langgraph_agent.py) | a LangGraph agent whose tool is a sandbox | Docker, `langgraph`, `ANTHROPIC_API_KEY` |
| [`06_openai_agents.py`](06_openai_agents.py) | the same through the OpenAI Agents SDK | Docker, `OPENAI_API_KEY` |
| [`test_my_tool.py`](test_my_tool.py) | testing your agent's tools with `sbx_fake` | nothing |

## Running them

From a clone, with the repository's own environment:

```bash
uv run --extra docker examples/01_hello_sandbox.py
uv run pytest examples/test_my_tool.py
```

The two agent examples run their sandboxio half with no API key and print why they stopped,
so you can see the tool work before you spend a token:

```bash
uv sync --all-extras
uv run examples/05_langgraph_agent.py
```

<!-- TODO(placeholder): once the package is published, each script gets a PEP 723
     `# /// script` header so `uv run https://.../01_hello_sandbox.py` works with no clone
     and no install. It cannot be written until there is a name on PyPI to depend on. -->

Every example assumes a running Docker daemon and the `python:3.12-slim` image. If something
is off, `sandboxio doctor` says what is installed, reachable and missing — without printing
a single secret.

## What they deliberately do not show

- **Per-host allowlists on Docker.** `NetworkPolicy(allow=...)` is refused there with
  `SBX_E1101`, because Docker cannot enforce it and silently ignoring it would be a lie
  about a security control. E2B can; see
  [`docs/spec/05-security-policy.md`](../docs/spec/05-security-policy.md).
- **Tasks rather than concepts.** Image preparation, offline wheelhouses, E2B keys, audit
  sinks, tracing and CI each have a page under [`docs/how-to/`](../docs/how-to/).


==============================================================================
# FILE: docs/quickstart.md
==============================================================================

# Quickstart

Five minutes from a clean machine to code running in a sandbox that cannot reach the
network. You need Python 3.11+ and, for the Docker backend, a running Docker daemon.
Everything here also runs against the in-process fake, which needs neither.

## 1. Prove the install works

```bash
uvx sandboxio demo
```

The demo creates a sandbox, runs code, streams output, tries to reach the network from
inside — and reports that the attempt was denied — then tears the sandbox down. The first
run pulls the `python:3.12-slim` image (about 130 MB, on the host, outside the sandbox's
network policy). A warm run finishes in about two seconds. If Docker is not installed, the
demo prints the exact install command and exits `1`.

When something is off, ask the doctor. It prints credential variable *names*, never values,
and makes no provider API call:

```bash
sandboxio doctor
sandboxio doctor --json     # paste-friendly, for bug reports
```

## 2. Add it to a project

```bash
uv add "sandboxio[docker]"   # or "sandboxio[e2b]" — the core has no backend built in
```

The base package depends on `anyio` and `typing-extensions` only. Every backend SDK lives
behind an extra and is imported the first time that backend is used.

## 3. Run code

```python
import sandboxio

async with await sandboxio.create() as sb:
    res = await sb.run_code("print('hello')")
    print(res.stdout)
```

`create()` with no arguments is local Docker with the defaults every backend applies:

| Default | Value | Change it with |
|---------|-------|----------------|
| Network | egress denied | `network=NetworkPolicy(egress="allow")` or an allowlist on E2B |
| Timeout | 300 s for the sandbox and every call | `timeout=` on `create()`, `run()`, `run_code()`, `stream()` |
| Resources | modest CPU and memory caps | `resources=Resources(memory_mb=2048)` |
| Labels | none | `metadata={"tenant_id": ..., "session_id": ...}` |

`timeout=None` on a call means "inherit the sandbox timeout", never "unbounded";
`timeout=None` on `create()` is refused.

## 4. Swap the backend

```python
import sandboxio

sb = await sandboxio.create("docker://python:3.12-slim")
sb = await sandboxio.create("e2b://code-interpreter-v1")      # needs E2B_API_KEY
sb = await sandboxio.create("fake://")                        # tests; executes nothing
```

The DSN is `<backend>://[<template>][?timeout=<seconds>]` and nothing else — no secrets,
no policy. Production code SHOULD prefer typed configuration
([spec/07](spec/07-configuration.md#typed-configuration)).

Make a deployment fail closed rather than silently downgrade:

```python
import sandboxio
from sandboxio import IsolationTier

sb = await sandboxio.create("e2b://", require_isolation=IsolationTier.MICROVM)
```

If the resolved backend is weaker, `create()` raises `ConfigurationError` before anything is
provisioned. `CONTAINER` (Docker) is not a boundary against hostile code; read
[isolation tiers](explanation/isolation-tiers.md) before choosing.

## 5. Use the whole surface

```python
import sandboxio
from sandboxio import Capability

async with await sandboxio.create() as sb:
    await sb.files.write("/work/data.csv", "a,b\n1,2\n")
    await sb.files.upload("local.txt", "/work/local.txt")
    print(await sb.files.ls("/work"))

    async with sb.stream(["pytest", "-q"], timeout=300) as proc:   # `async with` is required
        async for chunk in proc:
            print(chunk.stream, chunk.data.decode(), end="")
        res = await proc.wait()

    if Capability.STATEFUL_CODE in sb.capabilities:              # E2B and the fake, not Docker
        await sb.run_code("x = 41", context_id="session-1")
        print((await sb.run_code("print(x + 1)", context_id="session-1")).stdout)

    sb.native   # the provider's own object — outside the semver contract
```

Capabilities are discovered per sandbox, never inferred from the backend name. Calling
something the backend does not declare raises `CapabilityNotSupported` naming the backends
that do.

## 6. Handle errors by code

```python
import sandboxio

try:
    async with await sandboxio.create("e2b://") as sb:
        await sb.run("sleep 999", timeout=5)
except sandboxio.SandboxTimeout as exc:      # not the builtin TimeoutError
    print(exc.code, exc.hint)                 # SBX_E1302, and the fix
except sandboxio.SandboxError as exc:
    print(exc)                                # [code] message / Fix: ... / Docs: ...
```

Every exception carries a stable code, a hint holding the exact fix, and a docs URL
([error reference](errors/README.md), [why codes](explanation/error-codes.md)).

## 7. Test without a sandbox

```python
import pytest
from sandboxio import ExecResult


@pytest.mark.anyio
async def test_tool(sbx_fake):
    sbx_fake.on_run_code(match="import pandas", returns=ExecResult(0, "2.2.1\n", ""))
    res = await sbx_fake.sandbox.run_code("import pandas; print(pandas.__version__)")
    assert "2.2.1" in res.stdout
```

The fixture is installed with the package. More in
[offline testing](how-to/offline-testing.md).

## Where next

- [`examples/`](https://github.com/bit-agents/sandboxio/blob/main/examples/README.md): the same ground as complete programs you can run —
  one concept each, every one executed by CI.
- [Docker how-to](how-to/docker.md): images with dependencies, offline wheelhouses, the reaper.
- [E2B how-to](how-to/e2b.md): API key, allowlists, stateful contexts, rich outputs.
- [Audit and tracing](how-to/observability.md): sinks, OTel spans, redaction.
- [CI](how-to/ci.md): a copy-paste GitHub Actions workflow.
- [Integrations](how-to/integrations.md): LangGraph, OpenAI Agents SDK, MCP.
- [Operations](how-to/operations.md): `doctor`, `reap`, the `SBX_*` switches.
- Coding assistant in your repo? Paste [the AGENTS.md snippet](reference/agents-snippet.md).


==============================================================================
# FILE: docs/reference/agents-snippet.md
==============================================================================

# AGENTS.md snippet for projects that use sandboxio

Paste the block below into your repository's `AGENTS.md` (or `CLAUDE.md`, `.cursorrules`,
…). It gives a coding assistant the handful of facts it needs to write correct sandboxio
code without reading this repository.

````markdown
## sandboxio

Sandboxed code execution goes through `sandboxio` (`import sandboxio`, never aliased).

- Create: `async with await sandboxio.create("docker://python:3.12-slim") as sb:`. Sync code
  uses `sandboxio.create_sync(...)` with `with`. `create()` never guesses; `None` means local
  Docker. Other DSNs: `e2b://<template>`, `fake://` (tests).
- Run: `await sb.run(["cmd", "arg"], timeout=60)` → `ExecResult(exit_code, stdout, stderr)`;
  `await sb.run_code("print(1)")`; `async with sb.stream([...]) as proc: async for chunk in proc`.
  Files: `sb.files.read/write/upload/download/ls/mkdir/remove` (paths are sandbox-internal).
- Defaults are security controls: egress denied, 300 s timeout, modest CPU/memory caps.
  Do not loosen them to make something work; bake dependencies into the image or upload a
  wheelhouse. `timeout=None` means "inherit", never "unbounded".
- Secrets go in `secrets={...}`, never `env=` and never in a DSN. They are redacted everywhere.
- Check `Capability.X in sb.capabilities` before a backend-specific call; Docker has no
  `STATEFUL_CODE` and refuses network allowlists; E2B refuses `Resources(cpu/memory_mb)`.
- Errors: catch `sandboxio.SandboxError` (base) or a subclass; every one has `.code`
  (`SBX_E1302`), `.hint` (the fix) and `.url`. `except TimeoutError` does NOT catch sandbox
  timeouts — use `sandboxio.SandboxTimeout`.
- Tests use the `sbx_fake` pytest fixture (installed with the package) or
  `sandboxio.register("docker", lambda: FakeBackend())` so `docker://` resolves to the fake.
  Never require Docker or a provider account in unit tests.
- `sb.native` and `sandboxio.experimental.*` are outside semver; say so when you use them.
- Diagnose the environment with `sandboxio doctor --json`; clean up with `sandboxio reap`.
````

The snippet is kept current with the public API: the doc-sample and README gates in this
repository run against the same surface it describes.


==============================================================================
# FILE: docs/how-to/docker.md
==============================================================================

# How to run on Docker

Install the extra, have a Docker daemon running, and `create()` with no arguments — or
`docker://<image>` — gives you a container per sandbox with `network: none`.

```bash
uv add "sandboxio[docker]"
sandboxio doctor          # confirms the daemon is reachable and prints the engine version
```

```python
import sandboxio
from sandboxio_docker import DockerConfig

sb = await sandboxio.create("docker://python:3.12-slim")
sb = await sandboxio.create(DockerConfig(template="ghcr.io/acme/agent-runtime:1.4"))
```

The isolation tier is `CONTAINER`: a shared kernel. Use it for trusted, dev and CI code;
for untrusted multi-tenant code pick a `MICROVM` backend
([isolation tiers](../explanation/isolation-tiers.md)).

## What the adapter does

- One container per sandbox, `docker exec` per operation, working directory `/work`.
  A relative path means the same file to `files.read`/`write` and to `run` — both resolve
  under `/work`.
- `network_mode: none` by default. `NetworkPolicy(egress="allow")` gives the bridge network.
  **Allowlists are refused** with `CapabilityNotSupported`: Docker has no per-host egress
  filtering, and granting bridge access because an allowlist was requested would be a
  security control that appears to apply and does not
  ([ADR-0023](../adr/0023-docker-network-and-dependencies.md)). Allowlists are an E2B capability.
- CPU and memory caps apply on every sandbox (1 CPU, 512 MB unless you raise them), and
  swap is capped with memory so the limit is the limit.
  `Resources(disk_mb=...)` is refused rather than ignored — Docker cannot enforce it.
- Every container drops all capabilities, runs with `no-new-privileges` and a 512-process
  ceiling ([ADR-0028](../adr/0028-docker-container-hardening.md)). It still runs **as root
  on a writable rootfs** ([H16](../hazards.md#h16--docker-sandboxes-run-as-root-on-a-writable-rootfs)):
  `CONTAINER` is for trusted, dev and CI code, not for untrusted multi-tenant code — use
  E2B for that. An image needing a dropped capability back is a
  `.native` case.
- The container's PID 1 is `sleep <timeout>`: the provider-side lifetime backstop. When it
  fires, Docker leaves the container **stopped**; `sandboxio reap` removes it.
- A call timeout or a cancellation kills **every** process in the container (Docker cannot
  signal one `exec`); the filesystem survives.
- `run_code(context_id=...)` raises: Docker declares `STATEFUL_CODE` off in v0.1
  ([ADR-0024](../adr/0024-stateful-code-on-docker.md)).
- The filesystem port relies on `find`, `mkdir` and `rm` in the image, as in `python:*-slim`.

## Getting dependencies into a deny-egress sandbox

The sandbox cannot `pip install` from the internet, by design. Two patterns work under the
default policy; neither weakens it.

### Pattern 1 — bake them into the image (primary)

```dockerfile
FROM python:3.12-slim
RUN pip install --no-cache-dir pandas==2.2.3 numpy==2.1.3
WORKDIR /work
```

```bash
docker build -t agent-runtime:1 .
```

```python
import sandboxio

async with await sandboxio.create("docker://agent-runtime:1") as sb:
    print((await sb.run_code("import pandas; print(pandas.__version__)")).stdout)
```

### Pattern 2 — ship an offline wheelhouse

Download the wheels on the host, upload them, install with no index. This needs no network
inside the sandbox at all.

```bash
pip download --dest wheels --only-binary=:all: --platform manylinux2014_x86_64 \
  --python-version 3.12 pandas==2.2.3
```

```python
from pathlib import Path

import sandboxio

async with await sandboxio.create("docker://python:3.12-slim", timeout=600) as sb:
    await sb.files.mkdir("/work/wheels", parents=True)
    for wheel in Path("wheels").glob("*.whl"):
        await sb.files.upload(wheel, f"/work/wheels/{wheel.name}")
    res = await sb.run(
        ["python", "-m", "pip", "install", "--no-index", "--find-links", "/work/wheels", "pandas"],
        timeout=300,
    )
    res.raise_for_status()
```

Match the wheel platform to the image (`manylinux2014_x86_64` for `python:3.12-slim` on
x86-64; `manylinux2014_aarch64` on Apple Silicon and Graviton). If the image has `uv`,
`uv pip install --offline --find-links /work/wheels pandas` does the same.

A mutable "install with egress, then lock down" window is deliberately not offered: the
policy in effect would then vary over the sandbox's life, and the audit record could not
say which policy an operation ran under.

## Leaked containers and the reaper

Every container carries `io.sandboxio.managed=true`, `io.sandboxio.session=<process id>`,
`io.sandboxio.timeout=<seconds>` and one `io.sandboxio.meta.<key>` label per `metadata`
entry. A Ryuk-style sidecar (one per process, image `testcontainers/ryuk`) removes the
session's containers if the process dies without teardown. `SBX_DOCKER_REAPER=0` disables
it, for hosts that forbid privileged containers.

Whatever survives — a crashed process with the reaper disabled, a lifetime-expired
container left stopped — is visible to:

```bash
sandboxio reap                          # lists, running and stopped
sandboxio reap --label tenant_id=acme   # narrows by your metadata
sandboxio reap --kill                   # removes
```

## Configuring the backend object

`AuditConfig`, the default image and the interpreter name live on the backend instance.
Construct it yourself and either use it directly or register it under the `docker` name so
DSNs pick it up:

```python
import sandboxio
from sandboxio.audit import AuditConfig, LoggingSink
from sandboxio_docker import DockerBackend

backend = DockerBackend(image="agent-runtime:1", audit=AuditConfig(sinks=(LoggingSink(),)))
sandboxio.register("docker", lambda: backend)

sb = await sandboxio.create()   # now uses `backend`
```


==============================================================================
# FILE: docs/how-to/e2b.md
==============================================================================

# How to run on E2B

E2B runs each sandbox in a Firecracker microVM with a live code interpreter. It is the
`MICROVM` tier and the backend to pick for untrusted, multi-tenant code
([isolation tiers](../explanation/isolation-tiers.md)).

## API key

```bash
uv add "sandboxio[e2b]"
export E2B_API_KEY=e2b_...        # from the E2B dashboard; use a key scoped to one team
sandboxio doctor                  # shows "E2B_API_KEY set" — never the value
```

The key is read from the environment when a sandbox is created, never from a DSN and never
from a dotfile. A missing key raises `AuthError` (`SBX_E1501`) naming `E2B_API_KEY`. Keep
keys short-lived and least-privilege; sandboxio never persists them and redacts them from
every log, exception, repr, audit event and span.

```python
import sandboxio
from sandboxio_e2b import E2BConfig

sb = await sandboxio.create("e2b://")                          # default template
sb = await sandboxio.create("e2b://code-interpreter-v1?timeout=600")
sb = await sandboxio.create(E2BConfig(template="my-team-template"))
```

## Network allowlists

E2B is the backend where `NetworkPolicy(allow=...)` is a real control: it maps to the
provider's native deny-all-then-allow rules at create time.

```python
import sandboxio
from sandboxio import NetworkPolicy

async with await sandboxio.create(
    "e2b://", network=NetworkPolicy(allow=("api.openai.com", "pypi.org", "files.pythonhosted.org"))
) as sb:
    res = await sb.run(["pip", "install", "-q", "httpx"], timeout=120)
```

`egress="deny"` (the default) blocks everything; `egress="allow"` opens everything. Hosts
and CIDRs are both accepted. The policy is fixed for the sandbox's life.

## Stateful code contexts and rich outputs

E2B declares `STATEFUL_CODE`: pass any `context_id` string and the adapter keeps one live
interpreter per id, so variables survive between calls. A timeout or cancellation restarts
that context — the only way to stop a running cell — and the next call starts clean.

```python
import sandboxio

async with await sandboxio.create("e2b://") as sb:
    await sb.run_code("import pandas as pd; df = pd.DataFrame({'a': [1, 2, 3]})", context_id="s1")
    res = await sb.run_code("df.describe()", context_id="s1")
    for output in res.results or ():
        print(output.mime_type, output.data[:80])      # text/plain, text/html, image/png (base64)
```

`ExecResult.results` carries the interpreter's display outputs as `RichOutput(mime_type,
data)` in Jupyter's format; binary payloads are base64 text.

## Resources come from the template

CPU, memory and disk are properties of an E2B template, so `Resources(cpu=..., memory_mb=...)`
is **refused** with `ConfigurationError` instead of being silently ignored. Build a template
with the resources you need and name it in the DSN or `E2BConfig`.

## Labels and reaping

`metadata` becomes sandbox metadata on E2B, plus `sandboxio_managed=true` and
`sandboxio_session=<process id>`. `sandboxio reap --backend e2b` lists every running or
paused sandbox carrying the managed flag; `--kill` ends them. `connect()` refuses a sandbox
that does not carry the flag.

## Behind `.native`

PTY, pause/resume, snapshots and the SDK's own methods are reachable as `sb.native`, the
SDK's `AsyncSandbox`. Everything reached through `.native` is outside the semver contract:
it changes when E2B changes it.


==============================================================================
# FILE: docs/how-to/offline-testing.md
==============================================================================

# How to test agent tools offline

`sandboxio.testing.FakeBackend` is a supported product surface, not a test helper: it passes
the same contract suite as Docker and E2B, executes nothing, and needs no daemon, network or
account. Your tool's tests run in milliseconds anywhere pytest runs.

This page as a runnable file, executed by CI like every other example:
[`examples/test_my_tool.py`](https://github.com/bit-agents/sandboxio/blob/main/examples/test_my_tool.py).

## The fixture

The package registers a pytest plugin, so `sbx_fake` is available with no `conftest.py`.

```python
import pytest
from sandboxio import ExecResult, NetworkPolicy


@pytest.mark.anyio
async def test_my_tool(sbx_fake):
    sbx_fake.on_run_code(match="import pandas", returns=ExecResult(0, "2.2.1\n", ""))
    res = await sbx_fake.sandbox.run_code("import pandas; print(pandas.__version__)")
    assert "2.2.1" in res.stdout
    assert sbx_fake.calls[0].op == "create"
    assert sbx_fake.calls[0].network == NetworkPolicy(egress="deny")   # == not is
```

- `sbx_fake.sandbox` is a ready sandbox; `sbx_fake.calls` records every call with its
  arguments, including the policy in effect at create.
- `on_run(match=..., returns=...)` and `on_run_code(...)` script responses by substring.
  `hangs=True` makes the operation run until its timeout — the way to test your timeout
  handling in three seconds of wall time (virtual time, `time_scale=0.01`).
- Unscripted commands go through a tiny virtual shell (`echo`, `sh -c`, `printenv`, `seq`,
  `sleep`, `curl`, `cat`); anything else exits `127`. Unscripted `run_code` recognises
  assignments, literals and `print()` without executing; a recognised network call such as
  `urllib.request.urlopen(...)` obeys the sandbox's `NetworkPolicy`, like `curl` does.

## Through the DSN, for code that creates its own sandbox

```python
import sandboxio
from sandboxio import ExecResult
from sandboxio.testing import FakeBackend


async def my_tool(query: str) -> str:
    async with await sandboxio.create("docker://python:3.12-slim") as sb:
        return (await sb.run_code(f"print({query!r}.upper())")).stdout


async def test_tool_without_docker() -> None:
    fake = FakeBackend()
    fake.on_run_code(match="upper", returns=ExecResult(0, "HELLO\n", ""))
    sandboxio.register("docker", lambda: fake)          # the DSN now resolves to the fake
    assert await my_tool("hello") == "HELLO\n"
    assert fake.calls[0].timeout == 300
```

`register()` beats entry points, so production code keeps its `docker://` DSN and the test
decides what that means. This is how the README's Docker examples run in this repository's
own CI.

## Simulating the provider misbehaving

```python
from sandboxio.testing import FakeBackend

fake = FakeBackend()
fake.simulate(create_takes=3600)            # create() hits its timeout → CreateTimeout
fake.simulate(create_fails=RuntimeError())  # → CreationError with __cause__
fake.simulate(kill_hangs=True)              # teardown exceeds the grace → OrphanedSandboxWarning
fake.simulate(auth_missing=True)            # → AuthError naming SBX_FAKE_TOKEN
fake.simulate()                             # back to normal
fake.expire("fake-0001")                    # the provider reclaimed it → SandboxGone
```

Declare fewer capabilities to test your capability branches:

```python
from sandboxio import Capability
from sandboxio.testing import FakeBackend

minimal = FakeBackend(capabilities=Capability.RUN_COMMAND | Capability.NETWORK_POLICY)
```

## What the fake will not do

It will not emulate a feature a real backend lacks, and if it ever diverges from a real
backend that is a suite gap fixed with a shared test, not a special case in the fake
([spec/08](../spec/08-adapter-contract.md#fakebackend)). It reports `IsolationTier.CONTAINER`
while isolating nothing — it is for tests, and `require_isolation` behaves accordingly.


==============================================================================
# FILE: docs/how-to/observability.md
==============================================================================

# How to record and trace sandbox operations

Every `run`, `run_code`, stream and file operation produces **one** record. It is redacted
once, at close, and then rendered to whichever sinks you configured and to an OpenTelemetry
span. There is no third code path, so "no secret reaches any sink" is one testable claim
([spec/06](../spec/06-observability.md), [ADR-0021](../adr/0021-observability-record.md)).

## Audit sinks

Sinks are configured per backend instance, never globally:

```python
import logging

import sandboxio
from sandboxio.audit import AuditConfig, FileSink, LoggingSink, QueueSink
from sandboxio_docker import DockerBackend

audit = AuditConfig(
    sinks=(
        LoggingSink(logging.getLogger("myapp.audit")),   # one JSON line per event
        FileSink("audit.jsonl"),                          # appended, one JSON line per event
    ),
    on_sink_failure="warn",      # "fail" raises AuditSinkError and fails the operation
    capture_code=False,          # True adds the (redacted) code; the hash is always there
)
sandboxio.register("docker", lambda: DockerBackend(audit=audit))
```

| Sink | Behaviour |
|------|-----------|
| `NoopSink` | the default; records nothing |
| `LoggingSink(logger, level=INFO)` | `logger.log(level, <json line>, extra={"audit": {...}})`; configures no handler — your logging config decides where it goes |
| `FileSink(path)` | opens, appends one JSON line, closes, in a worker thread; a local write, like `logging.FileHandler` |
| `QueueSink(target, maxlen=1000)` | `emit` enqueues instantly; **you** run `await sink.drain()` in your own task group; overflow drops the oldest and warns |

`emit` is awaited inline and bounded by `SBX_AUDIT_TIMEOUT` (default 5 s). A sink that does
network I/O belongs behind `QueueSink`. Under `on_sink_failure="fail"`, an unrecorded
operation is a failed operation — the setting for a regulated buyer.

One event, as a JSON line:

```json
{"ts": "2026-09-19T10:00:00.000000+00:00", "event": "run_code", "sandbox_id": "8c2af94cd1bc",
 "backend": "docker", "isolation": "container", "tenant_id": "acme", "session_id": "s-1",
 "code_sha256": "…", "argv": null, "exit_code": 0, "duration_ms": 115, "bytes_in": 42,
 "bytes_out": 6, "network_denials": 0}
```

`argv` and `code` are redacted: every value passed as `secrets=` and every credential-shaped
`env` value is replaced by `***` wherever it appears. Code is hashed; the text itself is only
present with `capture_code=True`, redacted the same way.

## OpenTelemetry spans

Nothing to configure. If your application has set a tracer provider, each operation is an
`execute_tool` span nesting under whatever span is current — your agent framework's
`invoke_agent` span, typically. With no provider, nothing happens; sandboxio never installs
an exporter or starts a provider.

```bash
uv add "sandboxio[otel]"         # the OTel API only; you already have it if you trace anything
```

```python
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor

provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter()))
trace.set_tracer_provider(provider)   # from here on, every sandbox operation is a span
```

Span attributes, from GenAI semantic conventions **1.37.0** plus a `sandboxio.*` namespace
because the convention has no sandbox vocabulary:

| Attribute | Value |
|-----------|-------|
| `gen_ai.operation.name` | `execute_tool` |
| `gen_ai.tool.name` | `exec`, `run_code`, `file_read`, … |
| `sandboxio.backend`, `sandboxio.isolation`, `sandboxio.sandbox.id` | as reported |
| `sandboxio.tenant_id`, `sandboxio.session_id` | from `metadata` |
| `sandboxio.exit_code`, `sandboxio.duration_ms`, `sandboxio.bytes_in`, `sandboxio.bytes_out`, `sandboxio.network_denials` | measured |
| `sandboxio.code_sha256`, `sandboxio.argv` | redacted |
| `sandboxio.code` | only with `capture_code=True`, redacted |
| `error.type` | the exception class, when the operation raised |

Every attribute string lives in `sandboxio/otel.py` and nowhere else, so a convention rename
is a one-file change and a changelog entry ([ADR-0011](../adr/0011-otel-mapping-layer.md)).

## Debugging

`SBX_DEBUG=1` is the only verbosity switch: the CLI shows full tracebacks (with `rich` when
it is importable). The library never prints and configures no logging. Every object has a
`repr` that tells you what you need — `<DockerSandbox docker:8c2af94cd1bc container running
caps=RUN_COMMAND|RUN_CODE|…>` — and never a secret.


==============================================================================
# FILE: docs/how-to/ci.md
==============================================================================

# How to run sandboxio in CI

Two jobs: your tool tests against the fake on every pull request (no Docker, no network,
seconds), and the same tests against real Docker on `main`. Copy this into
`.github/workflows/sandboxio.yml` and change the two `run` lines that are yours.

```yaml
name: agent tools

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  fake:
    name: tests against the fake (every PR)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: astral-sh/setup-uv@v7
        with:
          enable-cache: true
      - run: uv sync
      - run: uv run pytest -q            # your tests, using the sbx_fake fixture

  docker:
    name: tests against Docker (main only)
    if: github.ref == 'refs/heads/main'
    needs: fake
    runs-on: ubuntu-latest              # ships a Docker daemon
    steps:
      - uses: actions/checkout@v5
      - uses: astral-sh/setup-uv@v7
        with:
          enable-cache: true
      - run: uv sync --extra docker
      - run: docker pull python:3.12-slim
      - run: uv run pytest -q -m docker  # your tests that create real sandboxes
      - name: zero leaked sandboxes
        run: test "$(docker ps -aq --filter label=io.sandboxio.managed | wc -l)" -eq 0
```

Mark the tests that need a daemon so the PR job skips them:

```python
import pytest

import sandboxio


@pytest.mark.docker
@pytest.mark.anyio
async def test_tool_on_real_docker() -> None:
    async with await sandboxio.create("docker://python:3.12-slim", timeout=60) as sb:
        assert (await sb.run(["echo", "ok"])).stdout == "ok\n"
```

```toml
[tool.pytest.ini_options]
markers = ["docker: needs a Docker daemon"]
addopts = "-m 'not docker'"      # PRs run the fake; `-m docker` opts in
```

The leak check at the end is the same one this repository runs: a sandbox left behind by a
test is a bug, and the label makes it visible. `sandboxio reap --kill` cleans up a runner
that is reused between jobs.

For E2B, add a job gated on the secret and run it nightly rather than on every PR — the suite
creates real sandboxes and costs real money:

```yaml
  e2b:
    if: github.event_name == 'schedule'
    runs-on: ubuntu-latest
    env:
      E2B_API_KEY: ${{ secrets.E2B_API_KEY }}
    steps:
      - uses: actions/checkout@v5
      - uses: astral-sh/setup-uv@v7
      - run: uv sync --extra e2b
      - run: uv run pytest -q -m e2b
```


==============================================================================
# FILE: docs/how-to/integrations.md
==============================================================================

# How to use sandboxio from LangGraph, the OpenAI Agents SDK and MCP

Both agent integrations have a complete, CI-executed program:
[`examples/05_langgraph_agent.py`](https://github.com/bit-agents/sandboxio/blob/main/examples/05_langgraph_agent.py) and
[`examples/06_openai_agents.py`](https://github.com/bit-agents/sandboxio/blob/main/examples/06_openai_agents.py). Each runs its sandboxio
half with no API key set, so you can watch the tool work before spending a token.

Each integration returns the framework's **native** tool object wrapping `run_code` or
`run` on a sandbox you provide. The tool descriptions are written for the model: one obvious
way, the limits stated. No integration can weaken a default — deny egress, the timeout and
the caps come from the sandbox you hand it ([spec/09](../spec/09-integrations.md)).

Two ways to hand over a sandbox:

- a **ready sandbox** — reused across calls; you own its lifetime;
- a **factory** such as `backend.create` or `functools.partial(sandboxio.create, "e2b://")`
  — one fresh sandbox per tool call, torn down when the call ends.

## Supported framework versions

| Extra | Package | Tested from | Tested to |
|-------|---------|-------------|-----------|
| `sandboxio[langgraph]` | `langchain-core` | 0.3 | latest |
| `sandboxio[openai-agents]` | `openai-agents` | 0.19 | latest |
| `sandboxio[mcp]` | `mcp` | 2.0 | latest |

"Tested from" is not a guess: a weekly CI matrix installs each framework at exactly that
version, on its own, and runs the tests that cover it. There is no upper pin — a new release
is exercised the following Monday, and an incompatibility becomes our bug to absorb rather
than your migration ([the version policy](../explanation/version-policy.md)).

Older versions may happen to work. They are not tested, so they are not supported.

## LangGraph / LangChain

```bash
uv add "sandboxio[docker,langgraph]"
```

```python
import sandboxio
from sandboxio.integrations.langgraph import make_code_tool

async with await sandboxio.create("docker://python:3.12-slim") as sb:
    run_python = make_code_tool(sb)                 # a langchain_core BaseTool
    print(await run_python.ainvoke({"code": "print(2 ** 10)"}))
    # graph = create_react_agent(model, tools=[run_python])
```

`make_command_tool(sb)` wraps `run` the same way. Both take `name=`, `description=` and
`timeout=` overrides.

## OpenAI Agents SDK

```bash
uv add "sandboxio[e2b,openai-agents]"
```

```python
import functools

import sandboxio
from sandboxio.integrations.openai_agents import make_code_tool

run_python = make_code_tool(functools.partial(sandboxio.create, "e2b://"))   # FunctionTool
# agent = Agent(name="analyst", instructions="...", tools=[run_python])
```

sandboxio backends *as* an OpenAI `SandboxClient` is planned for v0.2
([ADR-0013](../adr/0013-complement-openai-sandboxclient.md)).

## MCP server

Use this when the consumer is an MCP client — Claude Desktop, Cursor, your own agent in
another language — rather than your own Python code. The client gets code execution against a
sandbox it cannot reconfigure: the backend, the egress policy and the timeout are fixed when
you start the process, and no tool runs anything on the host. If you are writing the agent in
Python, call `sandboxio.create()` directly; the protocol hop buys you nothing.

```bash
uv add "sandboxio[mcp,docker]"
python -m sandboxio.mcp --backend docker://python:3.12-slim
```

One sandbox per server process, created on the first tool call and killed at shutdown.
Tools: `run_python(code)`, `run_command(cmd)`, `read_file(path)`, `write_file(path, content)`,
`list_files(path)`, `sandbox_info()`. One `run_python` tool instead of many narrow schemas is
the point — it is the MCP code-execution pattern.

Claude Desktop, Cursor and most clients take this shape:

```json
{
  "mcpServers": {
    "sandboxio": {
      "command": "uvx",
      "args": ["--from", "sandboxio[mcp,docker]", "python", "-m", "sandboxio.mcp",
               "--backend", "docker://python:3.12-slim"]
    }
  }
}
```

Options are fixed for the process: `--backend`, `--timeout`, `--egress deny|allow`,
`--transport stdio|streamable-http`, `--host` (localhost unless you say otherwise), `--port`.
There is deliberately **no** tool that changes them at runtime, and no tool ever executes on
the host: every call routes through the sandbox.

### As a container

```bash
docker build -f docker/mcp/Dockerfile -t sandboxio-mcp .
docker run -i --rm -e E2B_API_KEY sandboxio-mcp --backend e2b://code-interpreter-v1
```

The image is rootless, mounts no Docker socket, and defaults to the E2B backend because a
sandbox provider inside the container must be a cloud one. Catalog metadata lives in
[`docker/mcp/`](https://github.com/bit-agents/sandboxio/blob/main/docker/mcp/README.md).


==============================================================================
# FILE: docs/how-to/operations.md
==============================================================================

# How to operate sandboxio

The `sandboxio` command ships with the base package (`sbx` is a local alias; copy-paste
examples always use the full name, because `uvx` resolves by distribution name).

## `sandboxio doctor`

The first thing to run when something is wrong, and what a bug report asks for.

```bash
sandboxio doctor
sandboxio doctor --json
```

Per backend: installed (with the install command if not), package versions, credential
variables by **name** with set/unset, reachability (Docker: a local daemon ping; E2B: not
probed, because that would be an API call), and the isolation tier it would provide. Every
failing line carries a fix. The same report is available in Python as
`sandboxio.doctor()`, returning a frozen `DoctorReport` with `as_dict()`.

Exit `0` when every installed backend is healthy, `1` otherwise.

## `sandboxio reap`

The operator-facing backstop for sandboxes that outlived their process: a crash with the
reaper disabled, a teardown that exceeded its grace and warned, a Docker container left
stopped when its lifetime ended.

```bash
sandboxio reap                              # dry run: docker and e2b, every managed sandbox
sandboxio reap --backend docker             # one backend
sandboxio reap --label tenant_id=acme       # narrowed by your metadata
sandboxio reap --kill                       # remove what is listed
sandboxio reap --json
```

Listing is the default because the tool operates on live infrastructure and a label filter
can be wrong. A backend whose extra is not installed is reported as *not installed*, not an
error. Exit `1` only when a kill failed.

## `sandboxio demo`

`uvx sandboxio demo` is the zero-config proof that the install works; `--backend fake://`
runs the same five steps in-process. See the [quickstart](../quickstart.md).

## Environment variables

All `SBX_`-prefixed. Provider credentials use the provider's own names (`E2B_API_KEY`).

| Variable | Effect | Default |
|----------|--------|---------|
| `SBX_DEBUG` | `1` shows full tracebacks in the CLI | off |
| `SBX_TEARDOWN_GRACE` | seconds a kill may take before `OrphanedSandboxWarning` | `5` |
| `SBX_AUDIT_TIMEOUT` | seconds an audit sink's `emit` may take | `5` |
| `SBX_DOCKER_REAPER` | `0` disables the Docker reaper sidecar | on |
| `SBX_DOCKER_THREADS` | worker threads the Docker adapter may hold; each live stream parks one ([ADR-0027](../adr/0027-adapter-thread-budget.md)) | `64` |
| `NO_COLOR` | disables colour in the CLI | — |

Nothing else changes behaviour from the environment, and nothing is read from a dotfile.

## Exit codes

`0` success · `1` a problem was found or the command failed · `2` usage error · `130`
interrupted. Stable across releases ([spec/10](../spec/10-cli.md)).


==============================================================================
# FILE: docs/how-to/troubleshooting.md
==============================================================================

# Troubleshooting

Diagnosis by **symptom**, for when you do not already have an `SBX_E` code to look up. If
you do have one, go straight to the [error reference](../errors/README.md) — every code has
a page, and every exception already carries the fix in its `hint`.

Before anything else:

```bash
sandboxio doctor          # what is installed, reachable and missing, with a fix per line
sandboxio doctor --json   # the same, paste-friendly; prints credential names, never values
```

`doctor` exits `0` when every installed backend is healthy. It makes no provider API call,
so it costs nothing to run twice.

## Symptom index

| What you saw | Go to |
|--------------|-------|
| `ModuleNotFoundError`, or an error naming an extra you have not installed | [Nothing is installed yet](#nothing-is-installed-yet) |
| `cannot reach the Docker daemon` | [Docker is not reachable](#docker-is-not-reachable) |
| Code inside the sandbox cannot resolve a hostname or connect | [The sandbox has no network — by design](#the-sandbox-has-no-network--by-design) |
| The command clearly failed, but no exception was raised | [A failed command is not an exception](#a-failed-command-is-not-an-exception) |
| A call raised after roughly 300 seconds | [Three different timeouts](#three-different-timeouts) |
| `except TimeoutError:` did not catch a sandbox timeout | [Three different timeouts](#three-different-timeouts) |
| `CapabilityNotSupported`, for a call that works on another backend | [The backend refuses instead of pretending](#the-backend-refuses-instead-of-pretending) |
| `ConfigurationError` about `Resources(...)` | [The backend refuses instead of pretending](#the-backend-refuses-instead-of-pretending) |
| Containers still on the host after the process exited | [Sandboxes that outlived the process](#sandboxes-that-outlived-the-process) |
| `OrphanedSandboxWarning` | [Sandboxes that outlived the process](#sandboxes-that-outlived-the-process) |
| `UnverifiedIsolationWarning`, or `create()` refusing a tier | [Isolation warnings and refusals](#isolation-warnings-and-refusals) |
| `AuthError`, `RateLimitError` | [Provider credentials](#provider-credentials) |
| A secret you passed is not visible inside the sandbox | [Secrets are not `env`](#secrets-are-not-env) |
| A test passes but nothing actually ran | [The fake executes nothing](#the-fake-executes-nothing) |

## Nothing is installed yet

The core package contains no backend. Two different errors say so:

| Code | Means | Fix |
|------|-------|-----|
| [`SBX_E1002`](../errors/SBX_E1002.md) `BackendNotInstalled` | a first-party backend whose extra is missing | `uv pip install "sandboxio[docker]"` |
| [`SBX_E1001`](../errors/SBX_E1001.md) `BackendNotFound` | no backend is registered under that name at all | check the spelling; the hint lists what *is* registered |

A third-party adapter is `BackendNotFound` rather than `BackendNotInstalled` — sandboxio
cannot know its install command, so the hint names what it does know.

## Docker is not reachable

```text
sandboxio.errors.CreationError: [SBX_E1201] cannot reach the Docker daemon
  Fix:  Start Docker, or set DOCKER_HOST; `sandboxio doctor` explains.
```

The adapter creates its client on first use, never at import, so this surfaces at
`create()` rather than at `import sandboxio`. Work through it in this order:

1. `docker info` — if that fails, this is a Docker problem, not a sandboxio one.
2. `sandboxio doctor` — it reports reachability and the engine version.
3. A non-default socket (Colima, Rancher Desktop, a remote engine) needs `DOCKER_HOST`
   exported in the same shell that runs your program.

## The sandbox has no network — by design

This is the single most common surprise, and it usually does **not** raise a sandboxio
error. `create()` denies egress by default, so the failure happens *inside* your code: a
DNS resolution error, a connection refused, a `pip install` that cannot reach PyPI. What
comes back is an `ExecResult` with a non-zero `exit_code` and the real reason in `stderr`.

```python
import sandboxio

async with await sandboxio.create() as sb:
    res = await sb.run(["python", "-c", "import urllib.request; urllib.request.urlopen('https://example.com')"])
    print(res.exit_code, res.stderr)   # non-zero, and a socket error — not an SBX code
```

That is the deny-by-default policy working ([why](../explanation/deny-by-default.md)). Your
options, in order of preference:

1. **Bake the dependencies into the image**, or upload an **offline wheelhouse** — both
   work under the default policy
   ([Docker how-to](docker.md#getting-dependencies-into-a-deny-egress-sandbox)).
2. **Allowlist the hosts you need**, on a backend that can enforce one
   ([E2B how-to](e2b.md#network-allowlists)). Docker cannot, and says so rather than
   granting full access.
3. **Open egress explicitly** with `NetworkPolicy(egress="allow")`, when the code is
   trusted and you have decided that is acceptable.

Two things this is *not*:

- **Not an image-pull problem.** `docker pull` runs on the host daemon, outside the
  container's network namespace, so a deny-egress sandbox still starts from a remote image.
- **Not** [`SBX_E1401`](../errors/SBX_E1401.md) `NetworkPolicyViolation` in most cases. That
  code is raised where the backend itself reports a blocked attempt; a container with
  `network: none` has nothing to report, because there is no network stack to block.

## A failed command is not an exception

`run()` and `run_code()` return an `ExecResult`. A non-zero exit is data, not a raise —
the same shape as `subprocess.run()` without `check=True`:

```python
import sandboxio

async with await sandboxio.create() as sb:
    res = await sb.run(["pytest", "-q"])
    if not res.ok:                 # exit_code != 0
        print(res.stderr)
    res.raise_for_status()         # ExecutionError (SBX_E1301) with the result attached
```

If a step in your pipeline "silently did nothing", check `res.ok` first. When it did raise,
[`SBX_E1301`](../errors/SBX_E1301.md) carries the whole result: `exc.result.stderr` and
`exc.result.exit_code`.

## Three different timeouts

They look alike and have different fixes:

| Raised | Code | What ran out | Fix |
|--------|------|--------------|-----|
| `CreateTimeout` | [`SBX_E1203`](../errors/SBX_E1203.md) | the sandbox did not become usable in time | raise `timeout=` on `create()`, or use a warmer image/template |
| `ExecutionTimeout` | [`SBX_E1302`](../errors/SBX_E1302.md) | one `run()`, `run_code()` or stream | raise `timeout=` on the call, or make the code finish sooner |
| `SandboxGone` | [`SBX_E1204`](../errors/SBX_E1204.md) | the sandbox's whole lifetime, while you were using it | raise `timeout=` on `create()` — this is the sandbox-level budget, not the call's |

`SandboxGone` is deliberately **not** a timeout subclass: the fix is a different parameter.

`timeout=None` on a call means *inherit the sandbox timeout*, never *unbounded*, and
`timeout=None` on `create()` is refused outright. There is no way to ask for forever.

**`except TimeoutError:` will not catch any of these.** On Python 3.11+ the builtin is what
`asyncio` raises, so inheriting from it would make "my deadline fired" and "the sandbox's
limit fired" indistinguishable ([ADR-0017](../adr/0017-timeout-error-naming.md)). Catch the
library's own base instead:

```python
import sandboxio

try:
    async with await sandboxio.create() as sb:
        await sb.run("sleep 999", timeout=5)
except sandboxio.SandboxTimeout as exc:     # CreateTimeout and ExecutionTimeout
    print(exc.code, exc.hint)
```

## The backend refuses instead of pretending

A backend that cannot honour a typed argument raises rather than quietly ignoring it. This
is the rule that makes the defaults worth trusting, and it is also why a call that works on
one backend can fail on another:

| You asked for | On | Result |
|---------------|-----|--------|
| `NetworkPolicy(allow=[...])` | Docker | `CapabilityNotSupported` — Docker has no per-host egress filtering ([ADR-0023](../adr/0023-docker-network-and-dependencies.md)). Allowlists are an E2B/Modal capability |
| `run_code(context_id=...)` | Docker | `CapabilityNotSupported` — `STATEFUL_CODE` is off for Docker in v0.1 ([ADR-0024](../adr/0024-stateful-code-on-docker.md)) |
| `Resources(disk_mb=...)` | Docker | `ConfigurationError` — Docker cannot cap disk; drop it or pick a backend that can |
| `Resources(...)` | E2B | `ConfigurationError` — caps belong to the template; pick a template with the resources you need |
| `egress="learn"` | anything | raises in v0.1 rather than degrading to `deny` |

Never write a `try/except` that swallows one of these into a fallback: the refusal is the
feature. Discover instead of guessing:

```python
import sandboxio
from sandboxio import Capability

async with await sandboxio.create() as sb:
    if Capability.STATEFUL_CODE in sb.capabilities:
        await sb.run_code("x = 41", context_id="session-1")
```

## Sandboxes that outlived the process

Normal teardown happens on context-manager exit, including on exception and on
cancellation. What survives that is visible and removable:

```bash
sandboxio reap                          # lists, running and stopped — dry run by default
sandboxio reap --label tenant_id=acme   # narrowed by your own metadata
sandboxio reap --kill                   # removes what was listed
```

Three ways a container is still there:

- **The process crashed with the reaper disabled** (`SBX_DOCKER_REAPER=0`). The Ryuk-style
  sidecar normally removes the session's containers when the process dies.
- **The lifetime expired.** The container's PID 1 is `sleep <timeout>`; when it fires,
  Docker leaves the container *stopped*, not removed.
- **Teardown exceeded its grace.** You will have seen `OrphanedSandboxWarning` with the id
  and labels — that warning exists precisely so `reap` has something to go on. Raise
  `SBX_TEARDOWN_GRACE` if a slow host makes 5 seconds too tight.

Always ran under `async with`, and still leaking? That is a bug worth an issue — CI asserts
zero `io.sandboxio.managed` containers remain after every Docker job, so a reproduction is
actionable ([H2](../hazards.md#h2--leaked-sandboxes)).

## Isolation warnings and refusals

`UnverifiedIsolationWarning` means the backend reports `UNKNOWN` — its adapter has not
verified what it isolates with. It is emitted once per backend, and an `UNKNOWN` backend
satisfies no `require_isolation=` requirement at all.

`create(require_isolation=...)` raising `ConfigurationError` is the feature working: the
check runs **before** provisioning, so nothing was created and nothing was billed. Either
point at a backend that meets the tier, or lower the requirement deliberately —
`require_isolation=UNKNOWN` is meaningless and raises.

Read [isolation tiers](../explanation/isolation-tiers.md) before deciding which way to go.
`CONTAINER` is not a boundary against hostile code.

## Provider credentials

[`SBX_E1501`](../errors/SBX_E1501.md) `AuthError` names the exact environment variable it
wanted — export that one, in the shell that runs your program, and re-run
`sandboxio doctor` to confirm it is seen. `doctor` reports variables by name with set/unset
and never prints a value.

[`SBX_E1502`](../errors/SBX_E1502.md) `RateLimitError` is the provider throttling you.
sandboxio does not retry on your behalf in v0.1 — back off in your own code.

## Secrets are not `env`

`secrets=` and `env=` are separate parameters on purpose. Values passed as `secrets=` are
redacted everywhere they could surface: messages, notes, reprs, audit events, spans and CLI
output. If you are grepping logs for a secret to confirm it arrived, you will not find it —
that is the redaction, not a delivery failure.

Credentials in a DSN are refused: the DSN carries a backend, a template and `timeout`, and
nothing else. Use typed configuration and the provider's environment variable
([spec/05](../spec/05-security-policy.md#secrets)).

## The fake executes nothing

`fake://` and the `sbx_fake` fixture run no code at all. A test that asserts on real output
without scripting a result first is asserting on a default, not on your logic:

```python
import pytest
from sandboxio import ExecResult


@pytest.mark.anyio
async def test_tool(sbx_fake):
    sbx_fake.on_run_code(match="import pandas", returns=ExecResult(0, "2.2.1\n", ""))
    res = await sbx_fake.sandbox.run_code("import pandas; print(pandas.__version__)")
    assert "2.2.1" in res.stdout
```

The fake reports `CONTAINER` while isolating nothing, because it is for tests — never use
it as a stand-in for a security boundary. More in
[offline testing](offline-testing.md).

## Still stuck

- Include `sandboxio doctor --json` in any report; it is built for pasting and prints no
  secrets.
- A reproducible bug goes to [issues](https://github.com/bit-agents/sandboxio/issues/new/choose);
  a question goes to [discussions](https://github.com/bit-agents/sandboxio/discussions)
  ([SUPPORT.md](https://github.com/bit-agents/sandboxio/blob/main/SUPPORT.md)).
- A security vulnerability goes privately through the Security tab, never an issue
  ([SECURITY.md](https://github.com/bit-agents/sandboxio/blob/main/SECURITY.md)).
- A default that appears to apply and does not is the highest-severity bug this project can
  have ([H1](../hazards.md#h1--a-security-default-silently-does-not-apply)). Report it as
  one.


==============================================================================
# FILE: docs/explanation/security-model.md
==============================================================================

# The security model

What sandboxio protects you from, what it does not, and what remains your job. Read this
before running code you did not write.

The normative version is [spec/05](../spec/05-security-policy.md), written as a contract in
RFC-2119 language. This page is the same model in the order a reader needs it, and where
the two disagree the spec wins.

## The one-sentence version

sandboxio bounds the blast radius of code an agent generates. It does not make that code
safe, and no library can — what it can do is make the bound explicit, apply it by default,
and fail loudly when a backend cannot honour it.

## What is in scope

1. **Malicious or buggy agent-generated code** — exfiltration, resource exhaustion, escape
   attempts.
2. **Prompt-injected agents** invoking tools with attacker-chosen code or arguments.
   sandboxio cannot prevent the injection; it bounds what the injected code can reach.
3. **Cross-tenant leakage** — tenant A's code, files or state reaching tenant B.
4. **Credential leakage** into sandboxes, logs, traces or audit events.
5. **Supply-chain compromise of sandboxio itself** — a tiny dependency surface, lazy
   adapter imports, and a release path with no long-lived token.

## What is explicitly out of scope

Stated plainly, because a security tool that is vague about its limits is worse than none:

- **Making untrusted code safe on the `CONTAINER` tier.** A shared kernel is not a boundary
  against hostile code. Docker is for trusted, dev and CI workloads.
- **Defending against prompt injection.** That is your agent's problem, upstream of the
  sandbox. sandboxio assumes the injection already succeeded.
- **Tenant identity and authorization.** sandboxio labels and separates sandboxes; deciding
  who may ask for one is your application's job.

## The five defaults that do the work

| Default | What it stops | Escape hatch |
|---------|---------------|--------------|
| **Egress denied** on `create()` with no arguments | exfiltration, second-stage fetches, calls to APIs with found credentials | `NetworkPolicy(egress="allow")`, or an allowlist where the backend enforces one |
| **Mandatory timeouts** on `create()`, `run()` and `run_code()` | runaway cost, hung executions, denial of service | raise the value; `None` means *inherit*, never *unbounded* |
| **Resource caps always applied** — CPU, memory bounded together with swap, and a process ceiling where the backend has one | fork bombs, memory exhaustion of the host | a caller may raise a cap, never unset one |
| **Guaranteed teardown**, shielded and bounded by a grace period, on exception and on cancellation | sandboxes and bills that outlive the process | `sandboxio reap` for whatever still survives, with a warning naming it |
| **Isolation tier reported and enforceable** | a silent downgrade between environments | `require_isolation=`, checked *before* provisioning |

## The rule that makes them trustworthy

**A backend that cannot enforce what you asked for raises.** It does not approximate, warn
and continue, or degrade to something weaker.

A `NetworkPolicy(allow=[...])` against Docker raises `CapabilityNotSupported` instead of
granting full bridge access, because Docker has no per-host egress filtering. `egress="learn"`
raises in v0.1 rather than falling back to `deny`. `Resources(disk_mb=...)` on Docker is
refused rather than ignored. A security control that appears to apply and does not is the
highest-severity bug this project can ship
([H1](../hazards.md#h1--a-security-default-silently-does-not-apply)), and the contract suite
proves deny-by-default against a canary host on every real backend.

## Choosing a tier

The tier measures one property: resistance to kernel escape by an adversarial tenant
([isolation tiers](isolation-tiers.md)).

| Your code is | Use | Why |
|--------------|-----|-----|
| yours, reviewed, or CI's own | `CONTAINER` (Docker) | a shared kernel is fine when nothing in the sandbox is trying to leave it |
| agent-generated, single-tenant, low-value data | `CONTAINER` with egress denied, or better | the network default is doing most of the work here |
| untrusted, multi-tenant, or touching other people's data | `MICROVM` (E2B) as the floor | a dedicated guest kernel; a kernel bug is not immediately a host compromise |

Make it enforceable rather than documented:

```python
import sandboxio
from sandboxio import IsolationTier

sb = await sandboxio.create("e2b://", require_isolation=IsolationTier.MICROVM)
```

A weaker backend fails before anything is provisioned or billed. An `UNKNOWN`-tier backend
satisfies no requirement at all, and creating on one warns.

## What the container tier does and does not harden

Everything that costs no compatibility is applied: all capabilities dropped,
`no-new-privileges`, a process ceiling, and swap bounded together with memory
([ADR-0028](../adr/0028-docker-container-hardening.md)).

Two things are **not** applied, because both break ordinary images: running as a non-root
user, and a read-only root filesystem. So a Docker sandbox runs as root on a writable
rootfs inside the container ([H16](../hazards.md#h16--docker-sandboxes-run-as-root-on-a-writable-rootfs)).
That gap is recorded rather than papered over, and it is one more reason `CONTAINER` is not
the tier for hostile code.

## Secrets

`secrets=` is a separate parameter from `env=` so that redaction has something to act on.
Values passed there are removed from messages, notes, reprs, audit events, spans and CLI
output before anything is rendered, which means an exception you log is safe to log.

sandboxio persists no credential anywhere. Secrets in a DSN are refused with a
`ConfigurationError` pointing at the environment-variable convention — a DSN carries a
backend, a template and `timeout`, and is safe to put in a config file. For your own
provider credentials, use short-lived, least-privilege keys.

## Tenancy

One sandbox per session or tenant-run, labelled with your own metadata:

```python
import sandboxio

sb = await sandboxio.create(metadata={"tenant_id": "acme", "session_id": "s-1"})
```

Labels propagate to provider-native labels where the backend supports them, which is what
makes an orphan findable later by `sandboxio reap --label tenant_id=acme`. **sandboxio will
not silently share a sandbox across tenants under any circumstance.** Warm pools and
fork-per-tenant are opt-in patterns you choose, never a default you get.

## Your side of the contract

Before running untrusted code:

- [ ] Pick the tier deliberately, and state it in code with `require_isolation=`.
- [ ] Leave egress denied, or allowlist specific hosts — not `egress="allow"` because a
      dependency was missing ([the dependency patterns are here](../how-to/docker.md#getting-dependencies-into-a-deny-egress-sandbox)).
- [ ] Pass credentials as `secrets=`, and only the ones that sandbox actually needs.
- [ ] Label sandboxes with tenant and session, so an orphan is attributable.
- [ ] Turn on an audit sink, so what ran is a record rather than a memory
      ([audit and tracing](../how-to/observability.md)).
- [ ] Put your own authorization in front of sandbox creation. sandboxio does not do it.

## What this project does not claim

- No third-party security audit has been performed.
- It is **pre-alpha**, with nothing released; the API is not stable yet.
- No v0.1 backend provides the `GVISOR` tier — the tier exists in the model, not in what
  shipped.
- Isolation tiers are claims about someone else's infrastructure, published only with a
  dated link to the provider's own documentation and re-verified quarterly
  ([ADR-0006](../adr/0006-isolation-tiers-first-class.md)).

Vulnerabilities go privately through the Security tab, never an issue
([SECURITY.md](https://github.com/bit-agents/sandboxio/blob/main/SECURITY.md)).


==============================================================================
# FILE: docs/explanation/isolation-tiers.md
==============================================================================

# Isolation tiers

"Sandbox" spans radically different guarantees, and every abstraction in this space presents
them behind one word. sandboxio does not. Every backend and every sandbox reports an
`IsolationTier`, it appears in reprs, audit events and spans, and you can require one.

## What the tier measures

One property: **resistance to kernel escape by an adversarial tenant**. Not performance,
not features, not cost. The rank orders that property only
([ADR-0018](../adr/0018-isolation-tier-ordering.md)).

| Tier | Mechanism | Honest position |
|------|-----------|-----------------|
| `CONTAINER` | a Linux container sharing the host kernel (runc) | **Not a security boundary against hostile code.** For trusted, dev and CI code only. A kernel exploit is a host compromise. |
| `GVISOR` | a user-space kernel interposed between the container and the host | Defence in depth, not hardware-VM equivalence. No v0.1 backend provides it; the tier is a property of the model, not of what shipped. |
| `MICROVM` | a dedicated guest kernel in a hardware-virtualised VM (Firecracker) | The recommended floor for untrusted, multi-tenant code. |
| `UNKNOWN` | the adapter has not verified its mechanism | Satisfies **no** requirement. Creating on it warns once per backend. |

Docker is `CONTAINER`. E2B is `MICROVM`. The fake reports `CONTAINER` while isolating nothing,
because it is for tests.

## Why `CONTAINER` is still in the product

Because most agent code is not hostile, and local Docker is where development happens. The
point is not to forbid it; the point is that code reviewed against local Docker and deployed
against a cloud backend — or the reverse — should never *silently* change its security
assumption. The tier makes the change visible.

## Making it enforceable

```python
import sandboxio
from sandboxio import IsolationTier

sb = await sandboxio.create("e2b://", require_isolation=IsolationTier.MICROVM)
```

`require_isolation` is checked **before** anything is provisioned. A weaker backend fails
with `ConfigurationError` and nothing was created or billed. `require_isolation=UNKNOWN` is
meaningless and raises. This one line is the most valuable security affordance in the API:
it turns a silent downgrade into a failed deployment.

## Why the README's tier cells are blank

A tier is a claim about someone else's infrastructure. This project publishes one only with
a dated link to the provider's own documentation, re-verified quarterly
([ADR-0006](../adr/0006-isolation-tiers-first-class.md),
[hazard H4](../hazards.md#h4--isolation-claims-we-cannot-defend)). A provider changing its
isolation mechanism is a breaking change in this project's public data, not a footnote.


==============================================================================
# FILE: docs/explanation/deny-by-default.md
==============================================================================

# Deny by default

`sandboxio.create()` with no arguments gives a sandbox that cannot open a single outbound
connection. Not "a sandbox with a firewall you can configure" — one that starts closed, and
opens only where you say so.

[`examples/04_egress_denied.py`](https://github.com/bit-agents/sandboxio/blob/main/examples/04_egress_denied.py) proves this on a real
sandbox in about twenty lines, then shows the explicit opt-out.

## The threat this answers

An agent's code is at best buggy and at worst attacker-chosen through prompt injection.
sandboxio cannot prevent the injection. It bounds the blast radius: whatever runs in the
sandbox cannot exfiltrate the data it was given, cannot fetch a second stage, and cannot
call an API with credentials it found lying around. Egress is the channel for all three, so
egress is off ([spec/05](../spec/05-security-policy.md#threat-model)).

Mandatory timeouts and resource caps sit beside it for the same reason: an unbounded
execution is a cost and a denial-of-service waiting to happen, so `timeout=None` is refused
rather than interpreted as "forever".

## What the default costs, honestly

Dependencies. `pip install` from inside the sandbox does not work, and this project's own
first draft carried a streaming example that did exactly that. The answer is not a setup
window with the network open — a policy that varies over a sandbox's life makes "the policy
in effect" a time-varying fact the audit record cannot state. The answers are images with
dependencies baked in and offline wheelhouses uploaded through `files`
([Docker how-to](../how-to/docker.md#getting-dependencies-into-a-deny-egress-sandbox)), or an
allowlist on a backend that can enforce one ([E2B how-to](../how-to/e2b.md#network-allowlists)).

Image pulls are unaffected: `docker pull` runs on the host daemon, outside the container's
network namespace. That confusion generated an open question in this project's history, and
is worth stating plainly.

## The rule that makes it trustworthy

**A backend that cannot enforce a requested policy raises `CapabilityNotSupported`.** It
does not approximate, it does not warn and continue. Docker has no per-host egress filtering,
so a `NetworkPolicy(allow=...)` against Docker raises instead of quietly granting full bridge
access ([ADR-0023](../adr/0023-docker-network-and-dependencies.md)). Accepting and ignoring a
network policy is the single most dangerous bug this library could have
([hazard H1](../hazards.md#h1--a-security-default-silently-does-not-apply)), and the contract
suite proves deny against a canary host on every real backend.

The same rule covers every typed argument: `Resources(disk_mb=...)` on Docker and
`Resources(cpu=...)` on E2B are refused, not ignored. Never silently no-op.

## Seeing it

`uvx sandboxio demo` runs an outbound request from inside a fresh sandbox and reports that it
was denied — and would fail the demo if it were not. Where a backend reports blocked
attempts, the count is in every audit event as `network_denials`.


==============================================================================
# FILE: docs/explanation/error-codes.md
==============================================================================

# Why errors have codes

```text
sandboxio.errors.BackendNotInstalled: [SBX_E1002] The 'e2b' backend is not installed.
  Fix:  uv pip install "sandboxio[e2b]"
  Docs: https://<docs>/errors/SBX_E1002
```

Every exception this library raises carries three things beyond its message: a stable code,
a hint that is the exact fix, and a URL. Here is why each exists
([spec/04](../spec/04-errors.md), [ADR-0010](../adr/0010-stable-error-codes.md)).

## The code is for machines, and for search

Two audiences read errors. Humans want the fix. Coding assistants — increasingly the ones
writing sandboxio calls — want a token that means the same thing this year and next. Messages
get reworded; codes do not. `SBX_E1302` is `ExecutionTimeout` today and will be until v1
ends, which means a search, a runbook entry or an `except` clause written against the code
keeps working. Renaming or repurposing a code is a breaking change under semver, like
removing a method.

## The hint is the fix, not the problem restated

A hint that says "the backend is not installed" adds nothing. A hint that says
`uv pip install "sandboxio[e2b]"` ends the incident. So errors are constructed with context
rather than raised bare: `AuthError` names the exact environment variable, `BackendNotFound`
lists what *is* installed, `CapabilityNotSupported` names the backends that do support the
call. The error catalog is generated from the source so the pages and the hints cannot
drift.

## No inheritance from builtins, on purpose

`except TimeoutError` does **not** catch a sandbox timeout. On Python 3.11+ the builtin
`TimeoutError` is what `asyncio.timeout()` raises, so with an outer deadline around a sandbox
call the two mean different things: the caller's deadline fired, or the sandbox's own limit
fired. Inheriting would erase that distinction. Instead `SandboxTimeout` is a codeless base
that catches every sandbox deadline, with `CreateTimeout` and `ExecutionTimeout` beneath it
([ADR-0017](../adr/0017-timeout-error-naming.md)). And `SandboxGone` is deliberately **not**
a timeout: the sandbox's lifetime ended while you were using it, which is a different fix.

## Messages never carry secrets

Redaction runs before anything is rendered: every value passed as `secrets=` and every
credential-shaped environment value is replaced wherever it appears — in messages, notes,
reprs, audit events, spans and CLI output. An exception you log is safe to log.

## The catalog

[`docs/errors/`](../errors/README.md) has one page per code and is regenerated by
`scripts/gen_error_catalog.py`; a CI gate fails when the pages and the source disagree.


==============================================================================
# FILE: docs/explanation/version-policy.md
==============================================================================

# Version policy

[spec/03](../spec/03-public-api.md#stability-contract) names the surface semver covers. This
page says what that means in practice: what counts as a break, how long a deprecation lives,
and why the answer sometimes differs depending on whether you *call* sandboxio or *implement*
a backend for it.

## What the number promises today

sandboxio is `0.x`. Until `1.0`:

- **A patch release never breaks the covered surface.**
- **A minor release may**, and every such break gets a changelog entry and a migration note.

That is the ordinary `0.x` bargain, and it is the honest one while adapters are still being
written against the contract. After `1.0` the usual rule applies: breaks land in a major.

## Two audiences, one version number

Most libraries have callers. This one also has **adapter authors**, and the same change can
be additive for one and breaking for the other. The verdict below is always the stricter of
the two, because the version number cannot say "breaking, but only for some of you".

| Change | Callers | Adapter authors |
|--------|---------|-----------------|
| New error code | additive | additive |
| Renaming or repurposing an error code ([ADR-0010](../adr/0010-stable-error-codes.md)) | **breaking** | **breaking** |
| New DSN scheme or parameter ([spec/07](../spec/07-configuration.md)) | additive | additive |
| Changing what an existing DSN parameter means | **breaking** | **breaking** |
| New `Capability` or `IsolationTier` member | additive | additive |
| Removing or renaming one | **breaking** | **breaking** |
| New method on a port protocol | additive | **breaking** — every adapter must grow it |
| Changing a port method's signature | **breaking** | **breaking** |
| New required test in the contract suite ([ADR-0007](../adr/0007-contract-suite-as-spec.md)) | additive | **breaking** — a passing adapter can start failing |
| A backend's reported `IsolationTier` changes ([ADR-0006](../adr/0006-isolation-tiers-first-class.md)) | **breaking** | **breaking** |
| A default becoming stricter | **breaking** | **breaking** |

The last two are the ones people are surprised by.

A **reported isolation tier is public data**, not an implementation detail: code branches on
it, and `require_isolation=` refuses to provision below it. Correcting a tier downward can
stop a program that used to run — which is the point, but it is still a break.

A **stricter default breaks behaviour without breaking any signature**. Everything still
type-checks and the call still compiles; it just refuses work it used to accept. Tightening
security defaults is not exempt: it ships in a minor before `1.0`, in a major after, always
with the changelog entry saying what now gets refused.

## How the spec versions

[`docs/spec/`](../spec/README.md) ships with the package and versions with it; there is no
separate spec version to track. A spec edit is breaking when it turns something an adapter
was allowed to do into something it MUST NOT, or adds a MUST it did not carry. Clarifying
wording that changes no requirement is not a break, and says so in its changelog entry.

Where the spec is silent or marked `OPEN (Qn)`, nothing is promised yet. Building on an open
question is building on sand — ask, and the answer becomes normative.

## Deprecations

A symbol on its way out carries all four of these, not whichever is convenient:

1. `DeprecationWarning` with a correct `stacklevel`, so the warning points at *your* line.
2. PEP 702 `@typing_extensions.deprecated`, so the type-checker says it before runtime does.
3. A changelog entry naming the replacement.
4. A window of **at least one minor release and at least 90 days**, whichever ends later.

Removal happens only in a release that is allowed to break. Warnings alone do not reach
people — most CI hides them — which is why the type-checker annotation is the half that
actually works.

## `.native` is outside the contract

`.native` hands you the provider's own object, and the provider's surface is not ours to
promise. Concretely: **anything reached through `.native` can change in a patch release**,
because it changes when the provider ships, not when we do.

What is still promised is that `.native` exists and returns the underlying object. Reaching
through it is opting out of semver deliberately, and
[ADR-0003](../adr/0003-no-lowest-common-denominator.md) is why that escape hatch exists
rather than sanding the feature off.

## An upstream break is not our breaking change

When a provider ships an incompatible SDK, absorbing it is the job — see the
[churn-absorption log](../churn-log.md). The default outcome is a patch release in which your
code does not change.

If a provider break genuinely cannot be absorbed, it becomes a minor with a migration note
and a churn-log entry saying so plainly. "The provider changed it" is an explanation, never
an excuse for a silent break.

## Security releases and yanking

A security fix lands in a patch on the current minor, with an advisory
([SECURITY.md](https://github.com/bit-agents/sandboxio/blob/main/SECURITY.md)). A release is
yanked only when installing it is actively harmful — a broken build, a leaked credential, a
declared control that does not apply. A yank hides a version from resolution; it never
deletes it, and PyPI filenames are never reused.


==============================================================================
# FILE: docs/explanation/comparisons.md
==============================================================================

# Why sandboxio and not something else

Four things already solve part of this problem. Each is the right answer sometimes, and this
page says when — including the cases where the answer is "not sandboxio".

## A single provider's SDK

**Use theirs when** you have picked one provider, you are using what makes it distinctive,
and you are not planning to move.

The provider's own SDK is always the shortest path to that provider's best features, and
sandboxio does not try to beat it there. What it does is stop that choice from being
load-bearing:

- **Local and cloud are the same code.** Docker on a laptop, a microVM in production, one
  call site.
- **Distinctive features stay reachable.** `Capability` flags and `.native` keep E2B's rich
  interpreter output, Modal's GPUs or Daytona's LSP available instead of sanded off. This is
  the whole of [ADR-0003](../adr/0003-no-lowest-common-denominator.md), written against
  `apache-libcloud` — a portable API narrowed to the intersection of every provider, whose
  users dropped to the raw SDK the moment they needed anything real.
- **Your tests stop needing an account.** `FakeBackend` records and asserts without a
  network, and passes the same contract suite the real backends do.

The cost is one more dependency between you and the provider, and you can always reach
through it.

## A framework's sandbox layer

LangChain ships sandbox backends; the OpenAI Agents SDK ships `SandboxConfig` with clients
for several providers. Both are good, and **neither is a competitor here** — that is a
decision, not a slogan ([ADR-0013](../adr/0013-complement-openai-sandboxclient.md)).

**Use theirs when** you are inside that framework, intend to stay, and its provider list
covers you. Fewer moving parts wins.

**Use sandboxio when** the sandbox has to outlive the framework choice: a library used from
more than one framework, a service with no framework at all, or a migration where the agent
layer changes and the execution layer should not.

You are not choosing between them. `sandboxio.integrations` returns each framework's **native**
tool object, so sandboxio runs *inside* those ecosystems rather than beside them.

## A sandbox provider's own MCP server

Sandbox providers ship MCP servers for their own products, and a coding assistant may already
have code execution built in. This is the alternative to
[`python -m sandboxio.mcp`](../how-to/integrations.md#mcp-server), not to the library.

**Use theirs when** the assistant is the only consumer and you are already that provider's
customer. One less layer.

**Use sandboxio's when** the same policy has to hold in more than one place. The server is this
library with a protocol in front, so the sandbox your assistant talks to is the sandbox your
tests and your production agent get — same defaults, same error codes, same
[contract suite](../spec/08-adapter-contract.md). Moving from Docker to a microVM is the
`--backend` flag, not a different server with a different tool surface.

The rest of the difference is what the server refuses to do. Configuration is fixed at process
start with deliberately no tool to change it, no tool executes on the host, and the image is
rootless with no Docker socket reachable from sandboxed code
([spec/09](../spec/09-integrations.md)). That shape is a response to the 2026 LiteLLM CVE
chain, where the worst unauthenticated RCE sat in an MCP endpoint ([hazards](../hazards.md)).

## Docker and `subprocess`, yourself

This is the real alternative for most people, and for a script it is the right one.

It stops being right at the point where the list below becomes yours to maintain. Every item
is something this project already had to get wrong once:

- A timeout that actually **bounds** the work, including a hung stream, and that raises
  something other than the builtin `TimeoutError` so your retry loop can tell them apart.
- **Teardown that survives cancellation.** A `Ctrl-C` during cleanup must not strand a
  container; shielded teardown with a bounded grace is a surprising amount of care.
- **No network unless asked**, and a way to prove it is off rather than assume it.
- **Honest capability reporting**, so a control you set is never quietly ignored — the one
  failure mode that turns a security setting into a lie.
- A **leak check** you trust, because the container you forgot is billed either way.

## When not to use sandboxio

- **You need a real security boundary against hostile code, and you are on Docker.**
  Containers share the host kernel. sandboxio reports `CONTAINER` rather than implying
  otherwise, but reporting a risk does not remove it — read
  [isolation tiers](isolation-tiers.md) and pick `MICROVM`, or do not run that code.
- **One provider, one feature, one script.** The abstraction earns nothing and costs a
  dependency.
- **You are all-in on one agent framework** whose sandbox layer already covers your
  providers.
- **You need it today.** Nothing is released yet.

## What is actually different

Not features — anyone can ship features. These are the things that are *checkable*:

| | How you can check it |
|---|---|
| Adapters cannot lie about capabilities | Every declared flag has a passing [contract-suite](../spec/08-adapter-contract.md) test; every undeclared one must raise |
| Isolation is reported, never assumed | Tiers are dated and sourced to the provider's own documentation, re-verified quarterly ([runbook](../runbook.md)) |
| Deny-by-default is the default | An example in CI proves egress is denied, rather than a sentence saying so |
| Provider churn is absorbed in public | The [churn-absorption log](../churn-log.md), plus a nightly canary and a weekly framework matrix that produced the bounds in `pyproject.toml` |
| Errors are an API | Stable `SBX_E` codes under semver ([ADR-0010](../adr/0010-stable-error-codes.md)) |

## A note on dates

The descriptions of other projects above are deliberately general, because specifics go stale
fastest. The snapshot behind them is from **2026-09-19**
([ADR-0013](../adr/0013-complement-openai-sandboxclient.md)), and re-reading it is a quarterly
item in the [runbook](../runbook.md). If something here is out of date, that is a bug worth an
issue.


==============================================================================
# FILE: docs/faq.md
==============================================================================

# FAQ

Short answers with a link to the long one. If your question is "why does X fail", start at
[troubleshooting](how-to/troubleshooting.md) instead.

## What it is

**Is this a framework?**
No. It is a library with no runtime of its own, no server and no opinion about how you
build your agent — one async API over sandbox providers, plus a sync facade
([ADR-0009](adr/0009-library-first-server-later.md)).

**Does it replace the provider SDKs?**
It sits in front of them and keeps them reachable. Provider-specific features stay
available through `Capability` flags and `sb.native`, so using sandboxio is not a decision
to give up the E2B or Docker API ([ADR-0003](adr/0003-no-lowest-common-denominator.md)).

**Why not just Docker plus `subprocess`?**
For one provider and one script, that is the right answer. The case for this library starts
when you need a second backend, a security default you can prove, or tests that run without
Docker — laid out with the counter-cases in
[why sandboxio and not something else](explanation/comparisons.md).

**How does it relate to OpenAI's `SandboxClient`?**
Complement, not competitor: framework sandbox layers are a distribution channel for this
one ([ADR-0013](adr/0013-complement-openai-sandboxclient.md)).

## Using it

**Which backends exist?**
Docker, E2B and an in-process fake in v0.1; Modal in v0.1.1
([ADR-0025](adr/0025-v01-scope-cut.md)). Third-party adapters install as
`sandboxio-<name>` and register under the same entry-point group as the first-party ones.

**Do I have to write async code?**
No. `create_sync()` gives the same API, blocking — it is a thin facade over the async core,
not a second implementation. Each sync sandbox holds one portal thread for its lifetime, so
heavy concurrency is a reason to use the async API ([ADR-0022](adr/0022-sync-facade.md)).

**How do I install dependencies if the sandbox has no network?**
Bake them into the image, or upload an offline wheelhouse — both work under the default
policy ([Docker how-to](how-to/docker.md#getting-dependencies-into-a-deny-egress-sandbox)).
On E2B you can also allowlist the hosts you need.

**Can I turn the network on?**
Yes, explicitly: `NetworkPolicy(egress="allow")`. What you cannot do is get it by accident
([deny by default](explanation/deny-by-default.md)).

**Can I run without a timeout?**
No. `timeout=None` on a call means *inherit the sandbox timeout*; on `create()` it is
refused. Unbounded execution is a cost and an availability problem, not only a security one
([spec/05](spec/05-security-policy.md#mandatory-timeouts)).

**How do I test code that uses sandboxio?**
`fake://` and the `sbx_fake` fixture, which ship with the base package: no Docker, no
network, no provider account ([offline testing](how-to/offline-testing.md)).

**Does it work with LangGraph, the OpenAI Agents SDK or MCP?**
Yes, with the supported version ranges published and proven by a weekly CI matrix
([integrations](how-to/integrations.md#supported-framework-versions)).

## Security

**Is a Docker sandbox safe for untrusted code?**
No. `CONTAINER` shares the host kernel and is not a boundary against hostile code — it is
for trusted, dev and CI code. `MICROVM` is the recommended floor for untrusted or
multi-tenant work ([the security model](explanation/security-model.md),
[isolation tiers](explanation/isolation-tiers.md)).

**Then what does sandboxio actually give me?**
A blast radius you chose on purpose, and defaults that fail loudly rather than silently:
egress denied, timeouts mandatory, caps always applied, teardown guaranteed, and the
isolation tier reported and enforceable. The full in-scope and out-of-scope list is on
[the security model page](explanation/security-model.md).

**What happens if a backend cannot honour a security argument?**
It raises. Accepting and ignoring a network policy is the single most dangerous bug this
library could ship, so an unsupported allowlist or cap is refused, never approximated
([H1](hazards.md#h1--a-security-default-silently-does-not-apply)).

**Are my secrets safe in logs?**
Values passed as `secrets=` are redacted from messages, reprs, audit events, spans and CLI
output before rendering, and sandboxio persists no credential anywhere. Secrets in a DSN are
refused ([spec/05](spec/05-security-policy.md#secrets)).

**How do I report a vulnerability?**
Privately, through the Security tab — never an issue
([SECURITY.md](https://github.com/bit-agents/sandboxio/blob/main/SECURITY.md)).

## The project

**Is it production-ready?**
v0.1 is released, the contract suite gates every backend, and the behaviour you build
against is the normative, CI-enforced [specification](spec/README.md). What v0.1 does not
give you is a frozen API: a minor release may break the covered surface until 1.0
([version policy](explanation/version-policy.md)), so pin a minor. It is also young — judge
the code, not the adjective.

**What does it depend on?**
`anyio` and `typing-extensions`, and nothing else. Every provider SDK sits behind an extra
and is imported the first time that backend is used; `import sandboxio` has a 150 ms budget
that CI enforces ([ADR-0004](adr/0004-thin-core-lazy-adapters.md)).

**Does it phone home?**
No telemetry, ever, not even opt-out — and no import side effects at all: no sockets, no
logging configuration, no global state ([ADR-0012](adr/0012-no-telemetry-no-import-side-effects.md)).

**Which Python versions?**
3.11 and newer ([ADR-0015](adr/0015-python-version-floor.md)).

**What counts as a breaking change?**
More than you would expect, because there are two audiences: callers and adapter authors. A
new required contract-suite test breaks adapter authors even though callers see nothing, and
the version number takes the stricter verdict. `sb.native` is outside the contract by design
([version policy](explanation/version-policy.md)).

**What happens when a provider ships a breaking change?**
A nightly canary installs every provider SDK unpinned and opens an issue when it breaks;
what was absorbed is written down in the [churn log](churn-log.md). Absorbing that churn
publicly is the point of the project, not a chore beside it.

**What is the licence?**
MIT for code, CC BY 4.0 for the prose in `docs/`. Contributions are under the DCO, with no
CLA ([ADR-0016](adr/0016-license-mit.md), [ADR-0026](adr/0026-docs-license-cc-by.md)).

**Why `sandboxio` and not `sbx`?**
`sbx` on PyPI is an unrelated package. `SBX` survives as the short code — error codes,
`SBX_` environment variables, the `sbx_fake` fixture — and copy-paste commands always use
the full name ([ADR-0014](adr/0014-project-name.md)).

**Where do I ask something that is not here?**
[Discussions](https://github.com/bit-agents/sandboxio/discussions) for questions and ideas,
[issues](https://github.com/bit-agents/sandboxio/issues/new/choose) for a bug you can
reproduce ([SUPPORT.md](https://github.com/bit-agents/sandboxio/blob/main/SUPPORT.md)).


==============================================================================
# FILE: docs/errors/README.md
==============================================================================

# Error codes

Every sandboxio exception carries one of these codes, a hint with the exact fix, and
a link to its page here. Codes are stable across releases
([spec/04](../spec/04-errors.md), [ADR-0010](../adr/0010-stable-error-codes.md)).

| Code | Class | Meaning |
|------|-------|---------|
| [SBX_E1000](SBX_E1000.md) | `ConfigurationError` | The request could not be understood: bad DSN parameter, unknown key, bad option. |
| [SBX_E1001](SBX_E1001.md) | `BackendNotFound` | No backend is registered under that name. |
| [SBX_E1002](SBX_E1002.md) | `BackendNotInstalled` | A first-party backend whose extra is not installed. |
| [SBX_E1101](SBX_E1101.md) | `CapabilityNotSupported` | The backend does not declare the capability this call needs (spec/01 rule 2). |
| [SBX_E1201](SBX_E1201.md) | `CreationError` | The backend failed to produce a usable sandbox. |
| [SBX_E1202](SBX_E1202.md) | `ConnectError` | The backend would not service a sandbox-management call. |
| [SBX_E1203](SBX_E1203.md) | `CreateTimeout` | ``create()`` exceeded its timeout before the sandbox became usable. |
| [SBX_E1204](SBX_E1204.md) | `SandboxGone` | The sandbox no longer exists — its lifetime ended or the backend reclaimed it. |
| [SBX_E1301](SBX_E1301.md) | `ExecutionError` | A command exited non-zero and the caller asked for that to raise. |
| [SBX_E1302](SBX_E1302.md) | `ExecutionTimeout` | ``run()``, ``run_code()`` or a stream exceeded its timeout; the process was killed. |
| [SBX_E1401](SBX_E1401.md) | `NetworkPolicyViolation` | Sandboxed code attempted egress the ``NetworkPolicy`` denies. |
| [SBX_E1402](SBX_E1402.md) | `ResourceLimitExceeded` | A ``Resources`` cap was hit. |
| [SBX_E1501](SBX_E1501.md) | `AuthError` | A provider credential is missing or rejected. |
| [SBX_E1502](SBX_E1502.md) | `RateLimitError` | The provider throttled the request. sandboxio does not retry on your behalf in v0.1. |
| [SBX_E1601](SBX_E1601.md) | `AuditSinkError` | An audit sink failed and ``on_sink_failure="fail"`` was configured. |
| [SBX_E1700](SBX_E1700.md) | `FileSystemError` | A sandbox filesystem operation failed for a reason other than a missing path. |
| [SBX_E1701](SBX_E1701.md) | `PathNotFound` | The sandbox-internal path does not exist. |

<!-- Generated from src/sandboxio/errors.py by scripts/gen_error_catalog.py. Do not edit by hand. -->


==============================================================================
# FILE: docs/spec/03-public-api.md
==============================================================================

# 03 — Public API

Design target: autocomplete-driven and AI-legible. A coding assistant reading only the type
signatures should produce correct sandboxio code.

## Module surface

<!-- doc-sample: skip -->
```python
sandboxio.create(...)          # async, returns AsyncSandbox
sandboxio.create_sync(...)     # sync facade
sandboxio.connect(...)         # async, by sandbox id
sandboxio.register(name, path) # runtime backend registration
sandboxio.doctor()             # programmatic environment diagnosis -> DoctorReport

sandboxio.Capability, sandboxio.IsolationTier, sandboxio.Resources, sandboxio.NetworkPolicy, sandboxio.ExecResult
sandboxio.errors.*             # full error tree, also re-exported at top level
sandboxio.testing.*            # FakeBackend, fixtures, BackendContractSuite
```

Importing `sandboxio` MUST NOT import any adapter or provider SDK
([ADR-0004](../adr/0004-thin-core-lazy-adapters.md)).

## `create()`

```python
async def create(
    target: str | BackendConfig | None = None,   # DSN, typed config, or None → local Docker
    *,
    template: str | None = None,
    resources: Resources = Resources(),
    network: NetworkPolicy = NetworkPolicy(),    # egress="deny" by default
    env: dict[str, str] | None = None,
    secrets: dict[str, str] | None = None,       # separate from env; redacted everywhere
    timeout: float = 300,                        # required cap; unbounded is refused
    metadata: dict[str, str] | None = None,      # tenant_id, session_id, run_id, …
    require_isolation: IsolationTier | None = None,
) -> AsyncSandbox: ...
```

- `target=None` MUST resolve to local Docker and MUST work with no API key, no account and
  no config file.
- Backend names appearing as plain identifiers MUST be `Literal["docker","e2b","modal","fake"]`.
- `require_isolation` MUST be checked **before** provisioning, raising `ConfigurationError`
  if the resolved backend is weaker ([05](05-security-policy.md#isolation-enforcement)).
- `timeout` MUST NOT accept `None` or a non-positive value.

## Canonical usage

```python
import sandboxio

async with await sandboxio.create() as sb:                      # zero-config, local Docker
    res = await sb.run_code("print('hello')")
    print(res.stdout)

sb = await sandboxio.create("docker://python:3.12-slim")        # one-line backend swap
sb = await sandboxio.create("e2b://code-interpreter")
sb = await sandboxio.create("modal://base?gpu=T4")           # v0.1.1 — not installable yet

res = await sb.run(["pytest", "-q"], timeout=120)
res = await sb.run_code("import pandas; print(pandas.__version__)")

await sb.files.upload("model.pkl", "/work/model.pkl")
data = await sb.files.read("/work/out.json")

async with sb.stream(["pytest", "-q"], timeout=300) as proc:   # async with is required
    async for chunk in proc:
        log.write(chunk.data)                   # OutputChunk(stream=..., data=bytes)
    res = await proc.wait()                     # res.streamed is True; stdout/stderr empty

try:
    await sb.run("sleep 999", timeout=5)
except sandboxio.SandboxTimeout:          # NOT builtin TimeoutError — see spec/04
    await sb.kill()

if sandboxio.Capability.GPU in sb.capabilities: ...
if sb.isolation is not sandboxio.IsolationTier.MICROVM:
    log.warning("weaker than microVM isolation for untrusted multi-tenant code")

sb.native.tunnels()        # Modal-specific (v0.1.1) — outside the semver contract
```

> The streaming `pip install` example from the input docs is **removed**: it cannot run
> under the default deny-egress policy, and Docker cannot express an allowlist to make it
> work ([ADR-0023](../adr/0023-docker-network-and-dependencies.md)). Dependency patterns are
> in [05](05-security-policy.md#getting-dependencies-into-a-deny-egress-sandbox); streaming
> itself is specified in [02](02-ports.md#process-streaming).

## Sync facade

Resolved in [ADR-0022](../adr/0022-sync-facade.md).

```python
with sandboxio.create_sync("docker://python:3.12-slim") as sb:
    res = sb.run_code("print('hello')")
```

- Mirrors the async surface 1:1. Entry is **explicit**; `create()` MUST NOT auto-detect sync
  context and return a different type ([ADR-0002](../adr/0002-async-first-anyio.md)).
- Implemented as **hand-written delegation** across an anyio `BlockingPortal`. It MUST
  contain no adapter logic — only delegation.
- **A parity test MUST assert** that every public async member has a sync counterpart with a
  matching `inspect.signature` once coroutine-ness is discounted. Adding an async method
  without its sync counterpart MUST fail CI.
- **The portal is per-sandbox**: `create_sync()` starts it, `__exit__` / `close()` stops it.
  A shared module-level portal is forbidden as global state
  ([ADR-0012](../adr/0012-no-telemetry-no-import-side-effects.md)).
- **N sync sandboxes means N threads.** Docs MUST state this, and MUST name the async API as
  the answer for heavy concurrency.
- Sync streaming is supported. Each `OutputChunk` crosses the portal, so each chunk costs a
  thread round-trip; the docs MUST state the cost rather than hide it.
- Errors raised through the facade MUST be the same classes with `__cause__` intact. Portal
  frames in the traceback are acceptable.

## Typing requirements

- `py.typed` MUST be present in the wheel, asserted in CI. Without it, downstream mypy
  treats the whole package as `Any`.
- Public API MUST type-check under pyright strict and mypy strict.
- `@overload` where return type depends on arguments. Dataclass returns, never raw dicts.
- Every public symbol MUST carry a docstring with a runnable example.

## Stability contract

**Covered by semver:** port protocols, error codes ([04](04-errors.md)), DSN grammar
([07](07-configuration.md)), `ExecResult` shape, `Capability` and `IsolationTier` members,
the contract suite.

**Not covered:** `.native` and everything reached through it; anything under
`sandboxio.experimental.*`; anything emitting `ExperimentalWarning`.

**Deprecation:** `DeprecationWarning` with correct `stacklevel`, plus PEP 702
`@typing_extensions.deprecated`, plus a changelog entry, plus a generous window. Warnings
alone do not reach users; the type-checker annotation is what does.

What each of these means in practice — including the deprecation window, and where a change
is additive for callers but breaking for adapter authors — is
[the version policy](../explanation/version-policy.md).


==============================================================================
# FILE: docs/spec/02-ports.md
==============================================================================

# 02 — Port Interfaces

Protocols adapters implement. Structural (`typing.Protocol`), so third parties need not
import a base class. Type-checking conformance is necessary but **not sufficient** — the
contract suite is what proves correctness ([08](08-adapter-contract.md)).

## `Backend`

```python
class Backend(Protocol):
    name: str
    capabilities: Capability
    isolation: IsolationTier

    async def create(
        self, *,
        template: str | None = None,
        resources: Resources = Resources(),
        network: NetworkPolicy = NetworkPolicy(),
        env: dict[str, str] | None = None,
        secrets: dict[str, str] | None = None,
        timeout: float = 300,
        metadata: dict[str, str] | None = None,
    ) -> AsyncSandbox: ...

    async def connect(self, sandbox_id: str) -> AsyncSandbox: ...
```

- `create()` MUST NOT return until the sandbox is usable, or MUST raise.
- `create()` MUST apply `network` and `resources` **before** any user code can run. A
  backend that can only apply policy post-creation MUST raise `CapabilityNotSupported`
  rather than create an unpoliced sandbox.
- `connect()` MUST raise `ConnectError` for an unknown or dead id; it MUST NOT create one.
- `metadata` MUST be propagated to provider-native labels where the provider supports them,
  so external reconciliation and reaping can find orphans.

## `ReapableBackend` (optional)

```python
class ReapableBackend(Backend, Protocol):
    async def list_managed(self, *, labels: Mapping[str, str] | None = None) -> list[ManagedSandbox]: ...
    async def kill_managed(self, sandbox_id: str) -> bool: ...
```

What `sandboxio reap` ([10](10-cli.md#reap-contract)) talks to. Optional for third parties,
implemented by every first-party backend including the fake. `list_managed()` MUST return
sandboxes the provider still holds under sandboxio's label, **including ones whose lifetime
already ended** where the provider keeps them (Docker's stopped containers); `labels` narrows
by `metadata`. `kill_managed()` returns `False` when the id is already gone and MUST NOT
raise for that case.

## `AsyncSandbox`

```python
class AsyncSandbox(Protocol):
    id: str
    capabilities: Capability
    isolation: IsolationTier

    async def run(self, cmd: str | list[str], *,
                  timeout: float | None = None,
                  env: dict[str, str] | None = None) -> ExecResult: ...

    async def run_code(self, code: str, *,
                       language: str = "python",
                       context_id: str | None = None,
                       timeout: float | None = None) -> ExecResult: ...

    def stream(self, cmd: str | list[str], *,
               timeout: float | None = None,
               env: dict[str, str] | None = None) -> Process: ...   # NOT a coroutine

    async def kill(self) -> None: ...

    @property
    def files(self) -> AsyncFileSystem: ...
    @property
    def native(self) -> object: ...

    async def __aenter__(self) -> AsyncSandbox: ...
    async def __aexit__(self, *exc) -> None: ...
```

Requirements:

- **No `**kwargs` anywhere in the public surface.** Every argument is named and typed.
- `run` with a `list[str]` MUST NOT go through a shell. With a `str` it MAY, and the
  behaviour MUST be documented per adapter.
- `timeout=None` means "inherit the sandbox timeout", never "unbounded"
  ([05](05-security-policy.md#mandatory-timeouts)).
- A timeout MUST raise `ExecutionTimeout`, and MUST NOT hang or return a partial result as
  success. A sandbox that has ceased to exist MUST raise `SandboxGone`, not a timeout
  ([04](04-errors.md#timeouts)).
- `run_code` with `context_id` requires `Capability.STATEFUL_CODE`; without it, MUST raise
  `CapabilityNotSupported`. Docker declares it **off** in v0.1
  ([ADR-0024](../adr/0024-stateful-code-on-docker.md)).
- `kill()` MUST be idempotent. A second call on a dead sandbox MUST succeed silently.
- `native` MUST return the live provider object with nothing wrapped or hidden. It is
  **outside the semver contract** and every reference to it in docs MUST say so.

## `Process` (streaming)

Resolved in [ADR-0019](../adr/0019-streaming-process-handle.md).

```python
@dataclass(frozen=True)
class OutputChunk:
    stream: Literal["stdout", "stderr"]
    data: bytes

class Process(Protocol):
    async def __aenter__(self) -> Process: ...
    async def __aexit__(self, *exc) -> None: ...
    def __aiter__(self) -> AsyncIterator[OutputChunk]: ...
    async def wait(self) -> ExecResult: ...
    async def kill(self) -> None: ...
    @property
    def returncode(self) -> int | None: ...     # None while running
```

```python
async with sb.stream(["pytest", "-q"], timeout=300) as proc:
    async for chunk in proc:
        log.write(chunk.data)
    res = await proc.wait()
```

Requirements:

- `stream()` MUST be a plain function and its return value MUST NOT be awaitable, so
  omitting `async with` fails immediately rather than leaking. The process starts on
  `__aenter__`.
- `__aexit__` MUST terminate the process if still running — after normal completion, an
  early `break`, a propagating exception, or cancellation. Shielding and grace period per
  [Q7](../open-questions.md#q7--cancellation-semantics).
- Ordering MUST be preserved **within** each stream. Ordering **between** stdout and stderr
  is explicitly NOT guaranteed.
- `wait()` MUST return the terminal `ExecResult` with `streamed=True` and empty
  `stdout`/`stderr`. It MUST be idempotent. Called with output unconsumed, it drains and
  discards the remainder.
- Missing `Capability.STREAMING` MUST raise `CapabilityNotSupported` from `stream()`
  itself, before the context is entered.
- A timeout MUST raise `ExecutionTimeout` from iteration or from `wait()`.
- Typed keyword arguments only; no `**kwargs`.

**`stream_code` is deferred.** `Process` is shaped to carry interpreter rich outputs later;
streaming code execution lands when a second backend supports it, per the two-backend
promotion rule ([ADR-0003](../adr/0003-no-lowest-common-denominator.md)). Until then it is
reachable through `.native`.

## `AsyncFileSystem`

```python
class AsyncFileSystem(Protocol):
    async def read(self, path: str) -> bytes: ...
    async def write(self, path: str, data: bytes | str) -> None: ...
    async def upload(self, local: str | Path, remote: str) -> None: ...
    async def download(self, remote: str, local: str | Path) -> None: ...
    async def ls(self, path: str = ".") -> list[FileInfo]: ...
    async def mkdir(self, path: str, *, parents: bool = False) -> None: ...
    async def remove(self, path: str) -> None: ...
```

- All paths are **sandbox-internal**. An adapter MUST NOT resolve a path against the host
  filesystem, except for the explicit `local` arguments of `upload`/`download`.
- A relative path MUST resolve against the **same sandbox root** the adapter gives `run()`,
  so writing `"out.txt"` and then `cat out.txt` name one file. An adapter whose provider
  APIs disagree resolves the path itself rather than passing it through.
- `read` returns `bytes`; text decoding is the caller's. Round-trips MUST be binary-safe.
- A missing path MUST raise a mapped sandboxio error, never return empty.
- Large transfers SHOULD stream rather than buffer whole files in memory.

## Cancellation and teardown

Resolved in [ADR-0020](../adr/0020-cancellation-semantics.md). Under structured concurrency
a plain `finally: await self.kill()` is decorative — it is cancelled at its first
checkpoint — so teardown MUST be shielded.

```python
with anyio.move_on_after(TEARDOWN_GRACE, shield=True):
    await self._kill()
```

- Teardown MUST run in a **shielded, bounded** scope. `TEARDOWN_GRACE` defaults to **5 s**,
  overridable by `SBX_TEARDOWN_GRACE`. It is NOT a `create()` parameter.
- Cancelling a task awaiting `run()`, `run_code()` or a stream MUST NOT leave an orphaned
  remote process. Cancellation **kills** the remote process; detach-on-cancel is deferred.
- **Partial creation MUST be shielded narrowly**: the window between the provider returning
  an id and the handle owning it, and no wider. `create()` as a whole MUST NOT be shielded.
- If the grace expires, `OrphanedSandboxWarning` MUST be emitted naming the sandbox id,
  backend and `metadata` labels.
- **Cancellation MUST propagate as cancellation.** `CancelledError` /
  `anyio.get_cancelled_exc_class()` MUST NOT be wrapped in a `SandboxError`;
  `except BaseException` in an adapter is a bug.
- `kill()` MUST be safe to call from inside a shielded scope, MUST be idempotent, and MUST
  NOT block indefinitely.
- The provider-side timeout from `create(timeout=...)` is the guaranteed backstop: it bounds
  the worst-case orphan even when every other mechanism fails
  ([05](05-security-policy.md#mandatory-timeouts)).
- Each of the above is a contract-suite test, not adapter discretion.


==============================================================================
# FILE: docs/spec/01-domain-model.md
==============================================================================

# 01 — Domain Model

## Entities

### Sandbox

A live, isolated execution environment with an identity and a lifecycle.

- MUST expose a stable `id` unique within its backend for the sandbox's lifetime.
- MUST expose `capabilities: Capability` and `isolation: IsolationTier` reflecting **this
  sandbox instance**, not a static backend default — a backend MAY produce sandboxes with
  differing capabilities depending on template or options.
- MUST be an async context manager whose exit tears the sandbox down, including on exception
  and on cancellation ([05](05-security-policy.md#guaranteed-teardown)).
- MUST expose `.files` and `.native`.

Lifecycle states: `creating → running → (killed | failed)`. `paused` is deferred to
`.native` until at least two backends support it stably ([ADR-0003](../adr/0003-no-lowest-common-denominator.md)).

### Process

A single execution within a sandbox, returned by `stream()`. An async context manager that
iterates tagged output chunks and terminates in an `ExecResult`
([ADR-0019](../adr/0019-streaming-process-handle.md)).

- MUST terminate the remote process on context exit if it is still running — on normal
  completion, early `break`, exception, or cancellation.
- MUST distinguish stdout from stderr, and MUST preserve ordering **within** each stream.
  Ordering **between** the two streams is explicitly not guaranteed.
- MUST expose the terminal `ExecResult`, including `exit_code`, via `wait()`.

### FileSystem

Read/write access scoped to one sandbox. Never host filesystem access.

### Template

A base environment specification: a container image reference, or a provider template id.
sandboxio does not build images in v0.1.

### Session

**Post-MVP.** A logical, resumable binding of tenant or agent-run to sandbox. Not in v0.1;
the v0.1 equivalent is `metadata` labelling ([05](05-security-policy.md#tenancy)).

## Value objects

All value objects MUST be frozen dataclasses with value equality. Callers compare with `==`,
never `is` ([Q13](../open-questions.md#q13--doc-bug-is-on-a-dataclass)).

```python
@dataclass(frozen=True)
class Resources:
    cpu: float | None = None          # cores; None = backend default, never unbounded
    memory_mb: int | None = None
    disk_mb: int | None = None
    gpu: str | None = None            # e.g. "T4"; CapabilityNotSupported if unsupported

@dataclass(frozen=True)
class NetworkPolicy:
    egress: Literal["deny", "allow", "learn"] = "deny"
    allow: tuple[str, ...] = ()       # hostnames and/or CIDRs
    ingress_ports: tuple[int, ...] = ()

@dataclass(frozen=True)
class RichOutput:
    mime_type: str                    # e.g. "text/html", "image/png"
    data: str                         # binary payloads are base64 text

@dataclass(frozen=True)
class FileInfo:
    path: str                         # sandbox-internal
    size: int
    is_dir: bool
```

- `Resources` defaults MUST resolve to modest concrete caps, never "unlimited"
  ([05](05-security-policy.md#resource-caps)). Where the caps belong to the template rather
  than the sandbox, a non-default `Resources(...)` is refused, never ignored
  ([05](05-security-policy.md#where-caps-belong-to-the-template)).
- `egress="learn"` is v0.2 and MUST raise `CapabilityNotSupported` in v0.1 rather than
  silently behaving as `deny`.

### `ManagedSandbox`

```python
@dataclass(frozen=True)
class ManagedSandbox:
    sandbox_id: str
    backend: str
    state: Literal["running", "stopped", "paused"]
    created_at: datetime | None = None
    labels: dict[str, str] = field(default_factory=dict)   # the caller's metadata, as stored
```

What [`ReapableBackend.list_managed()`](02-ports.md#reapablebackend-optional) returns and
`sandboxio reap` prints. Not a `Sandbox`: nothing can be executed through it.

## Capability

A `Flag` enum. Additions are backwards compatible; removals are breaking.

```python
class Capability(Flag):
    RUN_COMMAND = auto(); RUN_CODE = auto(); STATEFUL_CODE = auto()
    STREAMING = auto(); FILESYSTEM = auto(); UPLOAD_DOWNLOAD = auto()
    PTY = auto(); PAUSE_RESUME = auto(); SNAPSHOT_FORK = auto()
    GPU = auto(); LSP = auto(); GIT = auto(); NETWORK_POLICY = auto(); TUNNELS = auto()
    COST_REPORTING = auto()          # post-hoc cost attribution by label (v0.2)
```

Rules:

1. A declared capability MUST have a passing contract test for that backend.
2. An undeclared capability, if invoked, MUST raise `CapabilityNotSupported` — never a
   silent no-op, never a degraded emulation.
3. Capabilities are discovered, not inferred from backend name. Callers branch on flags.

Backend notes for v0.1:

- **Docker declares `STATEFUL_CODE` off.** `run_code(context_id=...)` raises
  `CapabilityNotSupported`; `run_code` without a context is a one-shot exec
  ([ADR-0024](../adr/0024-stateful-code-on-docker.md)). `results` stays `None` and MUST NOT
  be synthesised from stdout.
- **Docker declares `NETWORK_POLICY` for deny/allow-all only.** A non-empty allowlist raises
  ([ADR-0023](../adr/0023-docker-network-and-dependencies.md)).
- **E2B takes its resource caps from the template.** `Resources(cpu=…)`, `memory_mb` and
  `disk_mb` are refused with `ConfigurationError` rather than ignored
  ([05](05-security-policy.md#where-caps-belong-to-the-template)). `Resources` is not a
  `Capability`, so this is a refusal, not an undeclared flag.

## IsolationTier

```python
class IsolationTier(Enum):
    UNKNOWN   = "unknown"     # undeclared or unverified — satisfies NO requirement
    CONTAINER = "container"   # runc, shared kernel — trusted/dev/CI code only
    GVISOR    = "gvisor"      # user-space kernel — defence in depth, not VM-equivalent
    MICROVM   = "microvm"     # dedicated guest kernel — floor for untrusted multi-tenant
```

Ordering is an **explicit internal rank map** (`UNKNOWN 0, CONTAINER 10, GVISOR 20,
MICROVM 30`), not a property of the member values ([ADR-0018](../adr/0018-isolation-tier-ordering.md)).

- The rank orders **one thing only: resistance to kernel escape by an adversarial tenant**,
  under the threat model in [05](05-security-policy.md#threat-model). It MUST NOT be
  presented as a general "more secure" scale.
- Rich comparisons (`<`, `<=`, `>`, `>=`) derive from the rank; arithmetic is not available.
  `tier.satisfies(minimum)` is the form the docs teach.
- Ranks are spaced by 10 so a tier can be inserted without renumbering. Adding a stronger
  tier means an existing `require_isolation` accepts it automatically.
- Every member MUST have a rank, asserted by a test that enumerates the class.
- Every backend and every sandbox MUST report a tier.
- A tier above `CONTAINER` MUST be justified by provider documentation, recorded with a date
  ([ADR-0006](../adr/0006-isolation-tiers-first-class.md)).

### `UNKNOWN`

- `UNKNOWN` is the **default for any adapter that does not declare a tier**. An adapter
  author who says nothing has claimed nothing.
- It satisfies **no** requirement, including `require_isolation=CONTAINER`.
- `require_isolation=UNKNOWN` is meaningless and MUST raise `ConfigurationError`.
- Creating on an `UNKNOWN`-tier backend MUST emit `UnverifiedIsolationWarning` once per
  backend per process.

| Backend | Tier | Notes |
|---------|------|-------|
| Docker (local) | `CONTAINER` | shared kernel; trusted code only |
| E2B | `MICROVM` | Firecracker |
| Modal | `GVISOR` | not hardware-VM equivalent |
| Fake | `CONTAINER` | executes nothing; MUST NOT be used outside tests |
| Daytona | `UNKNOWN` | unverified — not claimed at any tier until documented with a date |

## ExecResult

```python
@dataclass(frozen=True)
class ExecResult:
    exit_code: int
    stdout: str
    stderr: str
    results: tuple[RichOutput, ...] | None = None   # interpreter rich outputs; None if unsupported
    meter: Meter | None = None                # v0.2 — duration + backend, no cost
    streamed: bool = False                    # True → stdout/stderr empty by construction
```

- `ok` property: `exit_code == 0`.
- `raise_for_status()` raises `ExecutionError` on non-zero exit.
- Returns MUST be dataclasses, never raw dicts — `result.stdout` must autocomplete.
- `results` MUST be `None` when the backend lacks rich outputs, and MUST NOT be faked from
  parsed stdout.
- `streamed` is `True` only for a result from `Process.wait()`. When set, `stdout` and
  `stderr` MUST be empty — the caller already consumed the bytes, and streaming exists to
  avoid buffering them twice.


==============================================================================
# FILE: docs/spec/04-errors.md
==============================================================================

# 04 — Errors

Errors are a product surface for two audiences: humans who want the fix, and coding
assistants that need a stable token. See [ADR-0010](../adr/0010-stable-error-codes.md).

## Tree

```
SandboxError                    base; carries .code, .hint, .url
├── ConfigurationError          SBX_E1000   raised directly for a bad argument or DSN
│   ├── BackendNotFound         SBX_E1001   hint: installed backends + install command
│   └── BackendNotInstalled     SBX_E1002   hint: uv pip install "sandboxio[e2b]"
├── CapabilityNotSupported      SBX_E1101   hint: which backends do support it
├── CreationError               SBX_E1201
├── ConnectError                SBX_E1202
├── CreateTimeout               SBX_E1203   also a SandboxTimeout (see below)
├── SandboxGone                 SBX_E1204   sandbox no longer exists — NOT a timeout
├── ExecutionError              SBX_E1301   non-zero exit via raise_for_status()
├── SandboxTimeout              —           base only; never raised directly
│   └── ExecutionTimeout        SBX_E1302
├── NetworkPolicyViolation      SBX_E1401
├── ResourceLimitExceeded       SBX_E1402
├── AuthError                   SBX_E1501   hint: names the exact missing env var
├── RateLimitError              SBX_E1502
├── AuditSinkError              SBX_E1601   only when on_sink_failure="fail"
└── FileSystemError             SBX_E1700
    └── PathNotFound            SBX_E1701   sandbox-internal path does not exist
```

## Timeouts

Resolved in [ADR-0017](../adr/0017-timeout-error-naming.md).

- **No sandboxio exception inherits from a builtin exception**, and **no name in the
  `sandboxio` namespace shadows a builtin**. Enforced by ruff flake8-builtins (A001/A004).
- `SandboxTimeout` is a catch-all base carrying no code; `except SandboxTimeout` catches
  every timeout. Concrete classes carry the codes.
- `CreateTimeout` inherits both `CreationError` and `SandboxTimeout`, so either catch works.
- `SandboxGone` is **not** a timeout: the sandbox's lifetime ended or the backend reclaimed
  it while the caller was still using it. Its hint points at the sandbox `timeout=` that
  governs lifetime.
- `except TimeoutError` does **not** catch sandboxio timeouts, by design. With an outer
  `asyncio.timeout()` around a sandbox call, builtin `TimeoutError` means the caller's
  deadline fired and `SandboxTimeout` means the sandbox's own limit fired — a distinction
  inheritance would destroy.
- `anyio.fail_after` raises the builtin `TimeoutError` internally. Core and adapters MUST
  catch it and re-raise the matching sandboxio class with `__cause__` preserved. **A bare
  builtin `TimeoutError` escaping a public entry point is a bug**, checked by the contract
  suite.

## Required attributes

Every `SandboxError` MUST carry:

| Attribute | Rule |
|-----------|------|
| `.code` | Stable `SBX_Ennnn`. Semver-covered. Renaming or repurposing is breaking. |
| `.hint` | The **exact fix**, as a command where one exists. Not a restatement of the problem. |
| `.url` | Redirect-stable docs page, one per code: `https://<docs>/errors/SBX_E1002`. |
| `__cause__` | Preserved when wrapping a provider exception. |

## Rendering

```
sandboxio.errors.BackendNotInstalled: [SBX_E1002] The 'e2b' backend is not installed.
  Fix:  uv pip install "sandboxio[e2b]"
  Docs: https://<docs-domain>/errors/SBX_E1002
```

- The library MUST NOT install `rich` tracebacks; the CLI entry point MAY
  ([ADR-0012](../adr/0012-no-telemetry-no-import-side-effects.md)).
- Messages MUST NOT contain secrets, credentials, env values or full user code
  ([06](06-observability.md#redaction)).

## Hint quality

Hints are specific, which means errors are constructed with context rather than raised bare:

- `AuthError` names the exact missing environment variable for that backend.
- `BackendNotFound` lists the backends actually installed **and** the install command for a
  known-but-missing one.
- `CapabilityNotSupported` names the capability and which backends provide it.
- `ResourceLimitExceeded` names the limit that was hit and its current value.

## Mapping rules (adapters)

1. Every provider exception crossing an adapter boundary MUST be mapped into this tree.
   A raw provider exception escaping is a bug, checked by the contract suite.
2. `raise ... from e` always — `__cause__` is how users debug provider-specific failures.
3. Do not invent codes per provider. If a provider failure has no good home, that is a
   signal to add a code to this catalog, with a docs page, in the same change.
4. Transient provider failures MUST map to `RateLimitError` or `CreationError` rather than
   being retried silently. sandboxio does not retry on the caller's behalf in v0.1.
5. A `kill()` the provider refuses MUST map to `ConnectError`, the same code `kill_managed`
   raises, with a hint naming `sandboxio reap --kill`. It is a teardown failure, not a
   command that exited non-zero, so `ExecutionError` is wrong. A sandbox already gone is
   `SandboxGone`, and an idempotent `kill()` raises neither ([02](02-ports.md#cancellation-and-teardown)).

## Catalog is generated

The per-code docs pages MUST be generated from the in-code catalog, so codes, hints and docs
cannot drift. Adding an error means adding code, hint and page in one change; CI MUST fail
if a code exists without a page or a page without a code.

The generated pages live in [`docs/errors/`](../errors/README.md); regenerate with
`uv run python scripts/gen_error_catalog.py`.


==============================================================================
# FILE: docs/spec/05-security-policy.md
==============================================================================

# 05 — Security Policy

The v0.1 headline. See [ADR-0005](../adr/0005-secure-by-default.md) and
[ADR-0006](../adr/0006-isolation-tiers-first-class.md).

## Threat model

In scope:

1. Malicious or buggy agent-generated code — exfiltration, resource exhaustion, escape
   attempts.
2. Prompt-injected agents invoking tools with attacker-chosen code or arguments. sandboxio cannot
   prevent the injection; it bounds the blast radius.
3. Cross-tenant leakage — tenant A's code, files or state reaching tenant B.
4. Credential leakage into sandboxes, logs, traces or audit events.
5. Supply-chain compromise of sandboxio itself ([ADR-0004](../adr/0004-thin-core-lazy-adapters.md),
   [runbook](../runbook.md#release)).

Explicitly out of scope, and documented as such: making untrusted code safe on the
`CONTAINER` tier; defending against prompt injection; managing tenant identity or
authorization.

Container-tier hardening that costs no compatibility is still applied — dropped
capabilities, `no-new-privileges`, a PID ceiling and swap bounded with memory
([ADR-0028](../adr/0028-docker-container-hardening.md)). Running as a non-root user and a
read-only rootfs are **not** applied, because both break ordinary images; that gap is
recorded in the ADR rather than papered over here.

## Network policy

- Default is `NetworkPolicy(egress="deny")`. This applies to `create()` with no arguments.
- A backend that cannot enforce a requested policy MUST raise `CapabilityNotSupported`.
  Accepting and ignoring a network policy is the single most dangerous possible bug in this
  library ([H1](../hazards.md#h1--a-security-default-silently-does-not-apply)).
- `egress="learn"` is v0.2; in v0.1 it MUST raise rather than degrade to `deny`.
- Where the backend reports blocked egress attempts, the count MUST appear in the audit
  record ([06](06-observability.md)).
- **Network policy governs the sandbox's egress, not the host's image pull.** `docker pull`
  runs on the host daemon, outside the container, so a deny-egress sandbox still starts from
  a remote image. This distinction is what made the zero-config demo look like it conflicted
  with the default when it never did.

Adapters map the policy to the provider's real control:

| Backend | Deny | Allowlist |
|---------|------|-----------|
| Docker | network mode `none` | **not supported** — Docker has no per-host egress filtering; `--network` accepts only `none \| bridge \| host \| container \| <custom>`. A non-empty `allow` MUST raise `CapabilityNotSupported` ([ADR-0023](../adr/0023-docker-network-and-dependencies.md)) |
| E2B | `allow_internet_access=False` | `network={deny_out: [0.0.0.0/0], allow_out: [...]}` at create |
| Modal | empty `outbound_cidr_allowlist` — **not** legacy `block_network=True`, which is incompatible | `outbound_cidr_allowlist` |
| Vercel | deny-all `networkPolicy` | `networkPolicy` |
| Daytona | `network_block_all=True` | unverified |

Per-backend docs MUST state that allowlists are an E2B/Modal capability, so nobody writes
one against Docker and assumes it applies.

### Getting dependencies into a deny-egress sandbox

Two supported patterns on Docker, both working under the default policy:

1. **Image prep** — dependencies baked into the image ahead of time. The primary pattern.
2. **Offline wheelhouse** — `files.upload()` the wheels, then
   `uv pip install --offline --find-links /wheels`. Needs no network at all.

A mutable "install then lock down" setup window is **rejected**: Docker cannot change a
running container's network mode, and a policy that varies over a sandbox's life makes the
policy in effect time-varying in the audit record.

## Mandatory timeouts

- `create()`, `run()` and `run_code()` MUST refuse unbounded execution. Default 300 s.
- `timeout=None` at the call site means "inherit the sandbox timeout", never "no limit".
- Timeout MUST raise (`CreateTimeout` or `ExecutionTimeout`), MUST NOT hang, and MUST NOT
  return partial output as success.
- A sandbox whose lifetime expires while in use MUST raise `SandboxGone`
  ([04](04-errors.md#timeouts)).

## Resource caps

- Modest CPU, memory and disk defaults MUST always be applied.
- A caller MAY raise a cap. A caller MUST NOT be able to unset one.
- A memory cap MUST bound memory **and swap together**, or it is advisory rather than a cap.
- A process-count ceiling MUST be applied where the backend has one, so a fork bomb inside
  a sandbox cannot exhaust the host ([ADR-0028](../adr/0028-docker-container-hardening.md)).
- Exceeding a cap MUST surface as `ResourceLimitExceeded` naming the limit, where the
  backend reports it.

### Where caps belong to the template

Some providers fix CPU, memory and disk in the image or template, with no per-sandbox
override: E2B is the v0.1 example. Such a backend still applies concrete caps — the
template's — so the guarantee above holds, but it cannot honour a caller's `Resources`.

- It MUST refuse a non-default `Resources(...)` with `ConfigurationError` naming the
  template as the place to change them, and MUST NOT accept and ignore the value.
- It MUST declare `resource_caps_supported = False` to the contract suite
  ([08](08-adapter-contract.md#coverage-map)), which then asserts the refusal instead of
  the per-sandbox cap rows.
- A backend that can neither apply caps nor name a template that does MUST NOT create.

## Guaranteed teardown

- Context-manager exit MUST kill the sandbox, including on exception and on cancellation,
  in a **shielded scope bounded by `TEARDOWN_GRACE`** (default 5 s)
  ([02](02-ports.md#cancellation-and-teardown)).
- The mandatory `create(timeout=...)` is the guaranteed backstop for teardown that fails:
  it is enforced provider-side and cannot be cancelled away. This is why timeouts are
  non-negotiable — the rule is as much about cost as about security.
- Grace expiry MUST emit `OrphanedSandboxWarning` with the sandbox id and labels so an
  operator can reap it.
- The Docker adapter MUST ship a Ryuk-style reaper so a crashed test run leaks no
  containers. CI asserts zero sandboxio-labelled containers remain after Docker jobs.
- `kill()` MUST be idempotent.

## Secrets

- `secrets=` is a **separate parameter from `env=`**, and MUST be redacted from logs,
  exception messages, reprs, audit events and spans.
- Secrets MUST NOT appear in a DSN. The DSN parser SHOULD detect credential-looking
  parameters and raise `ConfigurationError` pointing at the env-var convention.
- Where a backend supports broker-style injection (Vercel credential brokering, Modal
  Secrets), adapters SHOULD prefer it over writing values into the environment.
- sandboxio MUST NOT persist credentials anywhere.
- Provider credentials themselves: docs MUST prescribe least-privilege, short-lived keys.

## Tenancy

- Default pattern is **one sandbox per session or tenant-run**, labelled
  `metadata={"tenant_id": ..., "session_id": ...}`.
- Labels MUST propagate to provider-native labels where supported, so orphans are findable.
- Tenant-scoped storage MUST be used where the backend provides it (Modal Volume `sub_path`).
- Warm pools and fork-per-tenant are **opt-in documented patterns**. sandboxio MUST NOT silently
  share a sandbox across tenants under any circumstance.

## Isolation enforcement

- `require_isolation=` MUST be evaluated **before provisioning**; failure raises
  `ConfigurationError` and provisions nothing.
- Comparison uses the explicit rank in [01](01-domain-model.md#isolationtier), which orders
  escape resistance only ([ADR-0018](../adr/0018-isolation-tier-ordering.md)).
- An `UNKNOWN`-tier backend satisfies no requirement at all, and creating on one without a
  requirement MUST emit `UnverifiedIsolationWarning`.
- `require_isolation=UNKNOWN` MUST raise `ConfigurationError`.
- Docs MUST state that `CONTAINER` is for trusted/dev/CI code, that gVisor is defence in
  depth rather than VM-equivalent, and that `MICROVM` is the recommended floor for untrusted
  multi-tenant code.

## Deferred to v0.2+

- Egress learning mode (`egress="learn"`) → records attempted domains, emits a paste-ready
  allowlist.
- Pre-execution security lint hook (Bandit/Semgrep) with a policy callback
  (`allow | block | require_approval`), **advisory by default** — static analysis cannot
  parse every generated snippet.
- First-class recipe for the MCP code-execution pattern: MCP tools as code APIs executed in
  a sandbox, returning summarized results.


==============================================================================
# FILE: docs/spec/06-observability.md
==============================================================================

# 06 — Observability

Three surfaces — audit, tracing, metering — MUST be three renderings of **one** internal
record. See [ADR-0011](../adr/0011-otel-mapping-layer.md) and
[Q9](../open-questions.md#q9--one-event-three-sinks-audit--otel--meter).

## The operation record

Every `run`, `run_code`, stream and file operation produces exactly one internal record.
Redaction is applied **once, upstream of all sinks**, so "no secret reaches any sink" is a
single testable claim.

```
record → redact → ├── audit sink(s)      (AuditEvent)
                  ├── OTel span          (sandboxio/otel.py attribute mapping)
                  └── ExecResult.meter   (v0.2)
```

Adding a field means one change in three renderers, not three independent changes.

## Audit

```python
@dataclass(frozen=True)
class AuditEvent:
    ts: datetime
    event: str                      # "exec" | "run_code" | "file_read" | "file_write" | ...
    sandbox_id: str
    backend: str
    isolation: IsolationTier
    tenant_id: str | None           # from metadata
    session_id: str | None          # from metadata
    code_sha256: str
    argv: tuple[str, ...] | None    # a tuple, like every sequence in a frozen value object
    exit_code: int | None
    duration_ms: int
    bytes_in: int
    bytes_out: int
    network_denials: int            # blocked egress attempts, where the backend reports them
    code: str | None = None         # only with capture_code=True; redacted like everything else
```

Sinks are configured per backend instance through `AuditConfig(sinks=..., on_sink_failure=...,
capture_code=...)`, passed to the adapter's constructor — never through global state.

### Sink protocol

```python
class AuditSink(Protocol):
    async def emit(self, event: AuditEvent) -> None: ...
```

- `emit` **MUST return promptly.** Buffering is the sink's responsibility, as with
  `logging.Handler`. A sink that blocks on network I/O adds latency to every execution.
- It is awaited **inline**, bounded by `SBX_AUDIT_TIMEOUT` (default 5 s). sandboxio owns no
  background drain task ([ADR-0020](../adr/0020-cancellation-semantics.md)).
- Failure or timeout is governed by **`on_sink_failure`**:
  - `"warn"` (default) — emit `AuditSinkWarning`, the operation proceeds.
  - `"fail"` — raise `AuditSinkError` (`SBX_E1601`); the operation fails. For a regulated
    buyer, an unrecorded operation may be one that should not have happened.
- Shipped sinks: **`NoopSink` (default)**, **`LoggingSink`** (one JSON line per event on a
  stdlib logger; configures no handler), **`FileSink`** (one JSON line per event appended to
  a file), and **`QueueSink`** — a bounded wrapper whose `emit` enqueues instantly and whose
  `drain()` coroutine the **host application** runs in its own task group. Overflow drops
  oldest and warns. The JSON line is `AuditEvent.as_dict()`, so every sink renders the same
  fields.
- Code is **hashed by default**; `AuditConfig(capture_code=True)` adds the redacted code to
  the event and the span. Secrets are redacted from it like from everything else.
- Secrets and env values MUST NOT appear in an event, ever, including in `argv`.

## Tracing

- Sandbox operations map to OTel GenAI `execute_tool` spans, nesting under the caller's
  `invoke_agent` span.
- Attributes: backend, isolation tier, sandbox id, exit code, duration, bytes in/out.
- Content capture (code, file contents) is **opt-in**, per the spec's privacy modes.
  Secrets are never captured at any setting.
- **All attribute strings live in `sandboxio/otel.py`.** No other module writes an attribute name.
  The targeted semconv version is pinned and documented; a bump is a changelog entry.
  **Target: GenAI semconv 1.37.0** (`sandboxio.otel.SEMCONV_VERSION`). Semconv has no
  vocabulary for a sandbox, so `gen_ai.operation.name` and `gen_ai.tool.name` come from the
  convention and the rest — backend, tier, sandbox id, exit code, duration, bytes — are
  `sandboxio.*` attributes.
- The OTel **API** is the only requirement, installed by `sandboxio[otel]` or already
  present in any traced application. It is imported on the first operation, never at
  `import sandboxio`. A span is opened when the record opens; with no tracer provider
  configured the span is non-recording and nothing else happens.
- Zero-config: participate if the host app configured a tracer provider, no-op otherwise.
  sandboxio MUST NOT install exporters or start providers.

## Metering (v0.2)

```python
@dataclass(frozen=True)
class Meter:
    duration_ms: int
    backend: str
```

**There is no `cost_usd`.** Per-execution cost does not exist at execution time on either
backend: E2B's SDK exposes no cost surface at all, and Modal's billing is a post-hoc,
account-level `workspace_billing_report(start, end, resolution="d", tag_names=[...])`
attributed per object, not per execution. An inline figure could only be a client-side
estimate from a price table we maintain — a churn surface with no SDK release to trip the
canary, and wrong under committed-use pricing
([ADR-0021](../adr/0021-observability-record.md)).

### Cost reconciliation (capability-gated, v0.2)

Cost ships as post-hoc attribution instead, using the `metadata` labels that
[05](05-security-policy.md#tenancy) already requires be propagated to provider-native
labels.

```python
@dataclass(frozen=True)
class CostEntry:
    object_id: str
    description: str
    interval_start: datetime
    cost_usd: Decimal            # Decimal, never float
    labels: dict[str, str]

# Backend-level, requires Capability.COST_REPORTING
async def costs(self, *, since: datetime, until: datetime | None = None,
                labels: dict[str, str] | None = None) -> list[CostEntry]: ...
```

- Requires `Capability.COST_REPORTING`; otherwise `CapabilityNotSupported`. Modal supports
  it, E2B does not.
- Amounts MUST be `Decimal`. Money is never a float.
- Resolution is the provider's, not ours, and MUST be reported rather than interpolated —
  Modal's default is daily.
- Surfaced as `sandboxio costs --since ... --label tenant_id=...` ([10](10-cli.md)).

## Redaction

Applied once, before any sink, to:

- everything in `secrets=`, by value, wherever it appears;
- credential-shaped environment values;
- anything the caller marks sensitive.

Redaction MUST cover: log records, exception messages and `__notes__`, `__repr__` output,
audit events, span attributes, and CLI output.

## Debugging

- `SBX_DEBUG=1` or `debug=True` raises verbosity via structured logging. Nothing else does.
- Library logger uses `NullHandler`. The library MUST NOT print.
- High-quality `__repr__` everywhere:
  `<Sandbox e2b:i8x3… microvm running caps=RUN_CODE|FILESYSTEM>`.


==============================================================================
# FILE: docs/spec/07-configuration.md
==============================================================================

# 07 — Configuration

See [ADR-0008](../adr/0008-dsn-and-typed-config.md).

## DSN grammar

```
<backend>://[<template>][?<param>=<value>&…]
```

Examples: `docker://python:3.12-slim`, `e2b://code-interpreter?timeout=600`, `fake://`, and
— once the Modal adapter lands in v0.1.1 — `modal://base?gpu=T4`.

Rules:

- **`timeout` is the only parameter defined in v0.1.** A backend MAY define further scalar
  parameters, and they arrive with that adapter, not before: `gpu` is Modal's and is not
  accepted by any v0.1 backend. Anything not defined by the resolved backend is an unknown
  parameter and raises.

- The grammar is **public API under semver**. Adding a scheme or parameter is additive;
  changing the meaning of an existing parameter is breaking.
- Query parameters are **simple scalars only**. Anything structured — network allowlists,
  resource shapes, secrets, metadata — is typed-object-only. A DSN MUST NOT be able to
  express a security policy ambiguously.
- An unknown scheme raises `BackendNotFound` listing installed backends, plus the install
  command for a known-but-missing one.
- An unknown parameter MUST raise `ConfigurationError`, never be ignored.
- The DSN parser is a **front end that constructs the same typed objects**, never a second
  configuration code path.

## Typed configuration

```python
sandboxio.create(E2BConfig(
    template="code-interpreter",
    network=NetworkPolicy(allow=("api.openai.com",)),
    resources=Resources(memory_mb=2048),
))
```

Production usage SHOULD prefer typed config: reviewable, autocompleting, no stringly-typed
policy.

## Credentials

- Credentials come from **per-provider environment variables** by that provider's own
  convention (`E2B_API_KEY`, `MODAL_TOKEN_ID`/`MODAL_TOKEN_SECRET`, …).
- Credentials MUST NOT appear in a DSN. The parser SHOULD detect credential-looking
  parameters and raise `ConfigurationError` naming the correct env var.
- sandboxio MUST NOT persist credentials, and MUST NOT log them even at debug verbosity.
- A missing credential MUST raise `AuthError` naming the exact variable
  ([04](04-errors.md#hint-quality)).

## Backend resolution

1. Explicit `sandboxio.register(name, "pkg:Class")` registrations.
2. Entry points in group `sandboxio.backends`, read lazily via `importlib.metadata`.
3. Nothing found → `BackendNotFound` / `BackendNotInstalled`.

Resolution MUST be lazy and cached. Entry-point scanning counts against the import budget
([ADR-0004](../adr/0004-thin-core-lazy-adapters.md)).

## Routing file

> **Deferred to v0.2. Nothing in this section is implemented in v0.1**, and no v0.1 caller
> can load a routing file or call `route_for()`. The design is kept here because the DSN and
> typed-config surfaces above were shaped to leave room for it; the MUSTs below bind the
> implementation when it lands, not v0.1 adapters
> ([build-order](../build-order.md#v02)).

Designed now, loadable by the **library** before any server exists
([ADR-0009](../adr/0009-library-first-server-later.md)). Mental model: Kubernetes
`RuntimeClass` for isolation classes, LiteLLM `config.yaml` for the policy surface.

```yaml
# sandboxio-routing.yaml
backends:
  docker-local: { adapter: docker, image: "python:3.12-slim" }
  e2b-fast:     { adapter: e2b, template: code-interpreter, api_key: os.environ/E2B_API_KEY }
  modal-gpu:    { adapter: modal, gpu: T4, api_key: os.environ/MODAL_TOKEN }

isolation_classes:            # cf. Kubernetes RuntimeClass
  standard:  { backend: docker-local }    # trusted / dev
  sandboxed: { backend: modal-gpu }       # gVisor tier
  isolated:  { backend: e2b-fast }        # microVM tier — untrusted multi-tenant

routes:                       # first match wins
  - match: { tool: run_python }
    class: isolated
  - match: { tool: data_transform }
    class: sandboxed
  - match: { tenant_tier: enterprise }
    class: isolated

default_class: standard       # mandatory

policy:
  network: { egress: deny }
  limits:  { timeout_s: 300, memory_mb: 1024 }
  spend:   { per_tenant_daily_usd: 50 }    # server-mode only
```

Rules:

- Secrets **only** as `os.environ/NAME` references. A literal secret in this file MUST be a
  load-time error, not a warning.
- First-match routing. `default_class` is a **top-level, mandatory** key — a config without
  it MUST fail to load. It is deliberately not a pseudo-route in the `routes` list: a
  default is not a match rule, and encoding it as one made the list heterogeneous.
- Every route entry has exactly the keys `match` and `class`. An unknown key MUST be a
  load-time error.
- The example above is **parse-tested in CI**. The original design set's version of this config was
  not valid YAML at all ([readme errata](../README.md#corrections-to-the-original-design)); every config
  sample in this spec MUST be machine-verified, not eyeballed.
- Library use: `sandboxio.create(route_for(tool="run_python", tenant_tier="enterprise"))`.
- Which backend or isolation class a tool or tenant gets MUST be a YAML change, never a code
  change.
- `spend` is meaningful only in server mode; the library MUST reject it with a clear message
  rather than ignoring it.


==============================================================================
# FILE: docs/spec/08-adapter-contract.md
==============================================================================

# 08 — Adapter Contract

`sandboxio.testing.suite` is **normative**. Where this prose and the suite disagree, the suite
wins; behaviour covered by neither is not guaranteed
([ADR-0007](../adr/0007-contract-suite-as-spec.md)).

## Conforming in one import

```python
from sandboxio.testing.suite import BackendContractSuite

class TestFlyAdapter(BackendContractSuite):
    backend = FlyBackend(...)
```

The suite reads `backend.capabilities` and runs the matching tests, **plus** negative tests
for every undeclared capability.

Command probes (`cmd_echo`, `cmd_hang`, …) default to a POSIX shell and are overridable
for other images. Behaviour probes (`assert_no_orphans`, `simulate_kill_hang`,
`expire_sandbox`, …) let the suite drive situations only the adapter can provoke; a probe
the adapter does not implement **skips visibly** rather than passing. Observability rows
need the adapter constructed with an `AuditConfig` holding a `RecordingSink`, exposed as
`audit_sink`. `FakeBackend`'s own subclass in `tests/test_fake_contract.py` is the reference.

## Coverage map

| Area | Requirements |
|------|--------------|
| Lifecycle | create → run → kill; context-manager teardown on normal exit **and** on exception; double-`kill()` idempotent; `connect()` to unknown id raises `ConnectError` |
| Cancellation | cancel mid-`run`, mid-`run_code`, mid-stream and mid-`create` each leave no orphan; teardown is shielded and bounded; only the id-known window of `create()` is shielded, not the whole call |
| Cancellation identity | cancellation surfaces as `CancelledError` / the anyio cancelled class, **never** wrapped in a `SandboxError`; `kill()` is callable from inside a shielded scope; grace expiry emits `OrphanedSandboxWarning` |
| `run` | exit codes; stdout/stderr separated; `env` injected; `list[str]` does not go through a shell; timeout raises `ExecutionTimeout` and does not hang |
| `run_code` | basic execution; syntax and runtime errors surface with non-zero exit; rich outputs present iff declared; `context_id` works iff `STATEFUL_CODE`, else raises |
| Streaming | `stream()` is not awaitable; per-stream ordering preserved; stdout/stderr distinguishable; `wait()` returns `streamed=True` with empty stdout/stderr and is idempotent; `CapabilityNotSupported` raised from `stream()` before entry |
| Streaming cleanup | the remote process is terminated after **each** of: full iteration, early `break`, exception inside the block, cancellation inside the block — four separate cases, each asserting no orphan remains |
| Filesystem | read/write/upload/download/ls/mkdir/remove round-trips; binary safety; missing path raises mapped error; no host-path escape |
| Policy | **deny actually denies** — egress to a canary host fails; allowlist permits only listed hosts; resource caps applied; caps cannot be unset. An adapter whose caps come from the template sets `resource_caps_supported = False`, and the suite asserts the `ConfigurationError` refusal instead ([05](05-security-policy.md#where-caps-belong-to-the-template)) |
| Errors | every provider exception mapped into the sandboxio tree; `__cause__` preserved; unsupported typed kwargs raise `CapabilityNotSupported`; `AuthError` names the missing env var |
| Timeouts | `create()` timeout raises `CreateTimeout`, execution timeout raises `ExecutionTimeout`, expired sandbox raises `SandboxGone`; **no bare builtin `TimeoutError` escapes any public entry point** |
| Capability honesty | every declared flag has a passing test; every undeclared flag raises when invoked |
| Isolation honesty | the backend reports a tier; an undeclared tier resolves to `UNKNOWN`; `require_isolation` above the reported tier fails **before** provisioning; `UNKNOWN` satisfies nothing |
| Observability | one operation record per operation; secrets absent from every sink; `metadata` propagated to provider-native labels |
| Sync parity | every public async member has a sync counterpart with a matching signature; errors through the facade are the same classes with `__cause__` intact; the per-sandbox portal is stopped on exit |

## Adapter rules

1. **Map every native exception**, `raise ... from e`. A raw provider exception escaping the
   adapter is a bug.
2. **Never silently no-op.** An unhonourable typed argument raises.
3. **Never fake a capability.** No parsing stdout into `results`, no emulated stateful
   contexts, no pretend network policy.
4. **Never block the event loop.** Sync provider SDKs go through `anyio.to_thread`.
5. **Propagate `metadata`** to provider labels so orphans are findable.
6. **Apply policy before user code can run**, or raise.
7. **Pin nothing hard.** Adapters declare loose lower bounds; the nightly latest-SDK canary
   is what catches breakage ([runbook](../runbook.md#provider-churn-response)).

## FakeBackend

`sandboxio.testing.FakeBackend` is a supported **product surface**, not an internal test helper.
It MUST pass the same contract suite.

- In-memory; no Docker, no cloud, no network.
- Deterministic: seeded outputs, virtual clock for timeouts, scripted responses
  (`fake.on_run_code(match="import pandas", returns=ExecResult(0, "2.2.1\n", ""))`).
- Records every call for assertions (`fake.calls`), including audit events and the policy in
  effect.
- Simulates failures on demand: timeout, `NetworkPolicyViolation`, non-zero exit, flakiness.
- Reachable as `sandboxio.create("fake://")` through the same DSN mechanism as real backends.
- Ships a pytest plugin with an `sbx_fake` fixture, registered by entry point.

```python
def test_my_agent_tool(sbx_fake):
    sbx_fake.on_run_code(match="import pandas", returns=ExecResult(0, "2.2.1\n", ""))
    result = my_agent_tool(sandbox=sbx_fake.sandbox, query="check pandas version")
    assert "2.2.1" in result
    assert sbx_fake.calls[0].network == NetworkPolicy(egress="deny")   # == not is (Q13)
```

If `FakeBackend` diverges from real backends, that is a **suite gap**: the fix is a new
shared test, not a special case in the fake.

## CI matrix

| Job | Backend | When |
|-----|---------|------|
| unit + fake contract | FakeBackend | every PR |
| docker contract | Docker via docker-py, Ryuk-style reaper | every PR |
| e2b contract | E2B (real) | nightly + release, gated on secret |
| modal contract | Modal (real) | nightly + release, gated on secret |
| **latest-SDK canary** | all cloud adapters against `pip install -U <provider>` | nightly — the churn early-warning system |
| framework matrix | LangGraph / OpenAI Agents adapters across supported versions | weekly |

Python matrix: **3.11, 3.12, 3.13, 3.14** on the unit and fake-contract job; 3.11 is a
required check and 3.14 is the default ([ADR-0015](../adr/0015-python-version-floor.md)).
Note `crewai` caps at `<3.14`, so its adapter job runs on 3.11-3.13 only.

Canary failures MUST open an auto-labelled `provider-churn` issue, which feeds the public
churn-absorption log.

## Hygiene gates (CI-enforced)

- Import budget: `python -X importtime -c "import sandboxio"` under 150 ms; hard fail over 200 ms.
- No network at import, verified with `pytest-socket`.
- No leaked containers: Docker jobs assert zero sandboxio-labelled containers remain.
- pyright + mypy strict on the public API; `py.typed` present in the wheel.
- Wheel contents: base install pulls no adapter code; extras resolve.
- Error catalog: every code has a docs page and vice versa.

## Adapter authoring

**Planned for v0.2** ([build-order](../build-order.md#v02)). An
**adapter template repo** wired to the contract suite ships alongside the authoring guide.
It is the ecosystem lever: a third party should reach a conforming adapter without reading
core's source. Until it exists, the reference is `tests/test_fake_contract.py` and this
page; everything above it in this document is v0.1 and normative now.


==============================================================================
# FILE: docs/spec/09-integrations.md
==============================================================================

# 09 — Integrations

Integrations are the distribution strategy: being the recommended sandbox layer inside a
popular framework is worth more than standalone stars. All integration modules are lazily
imported and dependency-isolated behind extras.

## Framework adapters (`sandboxio.integrations.*`)

Each adapter returns the framework's **native tool object** wrapping `run_code`/`run` on a
provided sandbox or per-call factory.

```python
from sandboxio.integrations.langgraph import make_code_tool        # -> BaseTool
from sandboxio.integrations.crewai import SbxCodeTool
from sandboxio.integrations.pydantic_ai import sandbox_tool

# OpenAI Agents SDK — both directions (ADR-0013)
from sandboxio.integrations.openai_agents import make_code_tool    # sandboxio as a plain tool
from sandboxio.integrations.openai_agents import SbxSandboxClient  # sandboxio backends AS a SandboxClient
```

Rules:

- Under 100 lines each. All logic stays in core; an adapter that needs logic is telling you
  core is missing something.
- Tool descriptions are written **for LLM consumption**: clear, constrained, one obvious way.
- Framework dependencies get loose lower bounds, are tested in the weekly CI matrix, and the
  supported version range is documented
  ([how-to](../how-to/integrations.md#supported-framework-versions)). A bound MUST be a
  version the matrix runs, not an aspiration.
- An integration MUST NOT weaken a security default. It inherits deny-egress, timeouts and
  caps like any other caller.

Decisions taken for v0.1: the adapters live in core under `sandboxio.integrations.*` with
their framework behind an extra — `sandboxio[langgraph]` (`langchain-core>=0.3`, the tool
type is `langchain_core.tools.BaseTool`) and `sandboxio[openai-agents]` (`openai-agents>=0.1`).
Each ships `make_code_tool()` and `make_command_tool()`, taking either a ready sandbox or a
per-call factory. A `SandboxError` is rendered into the tool's return string — code, fix and
docs URL — rather than raised into the framework, so the model can read what went wrong and
try something else, as it can on the MCP server. `SbxSandboxClient` is v0.2.

| Priority | Integration | Rationale |
|----------|-------------|-----------|
| P0 (v0.1) | LangGraph tool, OpenAI Agents tool, MCP server | largest ecosystems + distribution |
| P1 (v0.2) | Pydantic AI, CrewAI, OpenAI `SandboxClient` adapter | "works with all four" |
| P2 | LlamaIndex, Vercel AI SDK (via MCP), K8s agent-sandbox adapter at ≥beta | follow demand |

## MCP server (`python -m sandboxio.mcp`)

Both an integration and the first server surface
([ADR-0009](../adr/0009-library-first-server-later.md)).

```bash
python -m sandboxio.mcp --backend docker://python:3.12-slim
docker run ghcr.io/<org>/sandboxio-mcp --backend e2b://code-interpreter
```

Tools exposed — deliberately few, code-execution-pattern first:

| Tool | Returns |
|------|---------|
| `run_python(code)` | `{stdout, stderr, exit_code, results}` |
| `run_command(cmd)` | same shape |
| `read_file(path)` / `write_file(path, content)` / `list_files(path)` | file ops |
| `sandbox_info()` | backend, isolation tier, capabilities, policy in effect |

One `run_python` tool instead of many narrow schemas is the point: it is the MCP
code-execution pattern, and it is where the large token reduction comes from.

### Security requirements

Non-negotiable, informed by the LiteLLM CVE chain where the worst RCE lived in an MCP
endpoint:

- The server inherits sandboxio defaults: deny egress, mandatory timeouts, resource caps.
- **No tool ever executes on the host.** Everything routes through the sandbox.
- Configuration — backend DSN, policy — is fixed at process start. There MUST be **no
  runtime config-mutation tool**, and no unauthenticated management or test endpoint.
- The container runs rootless and MUST NOT have a Docker socket reachable from sandboxed
  code.
- Bind to localhost unless explicitly configured otherwise.

Behind `sandboxio[mcp]` (`mcp>=1.2`; built on the SDK's `MCPServer`). One sandbox per
server process, created on the first tool call and killed at shutdown. A `SandboxError`
raised by a tool reaches the model as a tool error carrying the code, the fix and the docs
URL — the SDK hides any other exception behind a generic line. Options: `--backend`,
`--timeout`, `--egress deny|allow`, `--transport stdio|streamable-http`, `--host`, `--port`.

Distribution: publish to the Docker MCP Catalog. Containerized distribution is the launch
centerpiece — it is the multi-language, multi-client story at a fraction of a full server's
attack surface.

Later (v0.2): a `search_docs` tool on the same server, for AI-assistant docs access.


==============================================================================
# FILE: docs/spec/10-cli.md
==============================================================================

# 10 — CLI

Stdlib `argparse`, in core, at `sandboxio.cli`. `uvx sandboxio demo` MUST work from the bare
distribution, and core takes no dependency beyond `anyio` + `typing-extensions`
([ADR-0004](../adr/0004-thin-core-lazy-adapters.md)), so a CLI framework is out. The CLI is
the only place allowed to be pretty: `rich` tracebacks are installed at the CLI entry point
when `rich` is importable and `SBX_DEBUG=1`, never at library import
([ADR-0012](../adr/0012-no-telemetry-no-import-side-effects.md)). Colour is plain ANSI,
only on a TTY, and `NO_COLOR` wins.

## Commands

| Command | Purpose | Ships |
|---------|---------|-------|
| `sandboxio doctor` | Per-backend availability, credentials found (**names only**), Docker reachability, versions — with a fix hint per failure. Also programmatic as `sandboxio.doctor()`. | v0.1 |
| `uvx sandboxio demo` | Self-contained Docker-backed demo — create sandbox, run code, stream output, prove egress is denied, tear down — with no account and no config. Runs stdlib-only code under the default deny-egress policy. The **under-60 s** budget is measured **warm**: a first run also pulls ~130 MB of image, which is host-side and unaffected by the sandbox's network policy. | v0.1 |
| `sandboxio reap` | List and kill orphaned sandboxes by `metadata` label — the operator-facing backstop when shielded teardown could not finish ([ADR-0020](../adr/0020-cancellation-semantics.md)). Dry-run by default; `--kill` to act. | v0.1 |
| `sandboxio replay <trace>` | Deterministic replay of a recorded execution trace; local static HTML viewer. | v0.2 |
| `sandboxio bench` | Backend comparison: latency. | v0.2 |
| `sandboxio costs` | Post-hoc cost attribution by `metadata` label, e.g. `--since 2026-09-01 --label tenant_id=acme`. Requires `Capability.COST_REPORTING`; reports the provider's own resolution rather than interpolating ([06](06-observability.md#cost-reconciliation-capability-gated-v02)). | v0.2 |

The primary console script is `sandboxio`. A short `sbx` alias script is installed as a
convenience, but **copy-paste examples always use the full name** — `uvx` resolves by
distribution name, and the three-letter name belongs to an unrelated PyPI package
([ADR-0014](../adr/0014-project-name.md)).

## Behaviour requirements

- `--json` on **every** command, rendering the same payload as the human output. Not a
  second code path.
- Respect `NO_COLOR`; detect non-TTY and degrade cleanly.
- Examples inside `--help`, not only in the docs.
- Documented exit codes, stable across releases: `0` success · `1` a problem was found or
  the command failed (`doctor`: an installed backend has a failing check; `reap`: a backend
  could not be listed, with or without `--kill`; `reap --kill`: a kill failed; `demo`: a
  step failed, including the egress probe reaching the network) · `2` usage error · `130`
  interrupted. A dry-run `reap` that lists every requested backend exits `0`, however many
  sandboxes it found.
- Progress bars for genuinely long operations only: image pull, sandbox boot.
- `doctor` MUST print credential **variable names**, never values, and MUST NOT make a
  provider API call that costs money.
- `reap` MUST default to listing only. Killing requires an explicit flag, because the tool
  operates on live infrastructure and a label filter can be wrong.
- Shell completions and `sandboxio upgrade` self-check: v0.2.

## `doctor` output contract

For each backend: installed (yes/no + install command), credentials (which variables are
set, by name), reachability (e.g. Docker daemon responding), SDK version, and the isolation
tier it would provide. Every failure line carries a fix hint
([04](04-errors.md#hint-quality)).

This is the first thing a user runs when something is wrong, and the first thing a
maintainer asks for in a bug report — so its output SHOULD be paste-friendly and MUST be
free of secrets.

## `reap` contract

`reap` asks each backend for the sandboxes it labelled through the optional
[`ReapableBackend`](02-ports.md#reapablebackend-optional) port. Docker MUST list **every**
`io.sandboxio.managed` container, running or stopped — a lifetime-expired container is left
stopped by Docker, and `reap` is its cleanup path. E2B lists every running or paused sandbox
with `sandboxio_managed=true`. `--label key=value` narrows by `metadata`; `--backend` narrows
by backend and defaults to `docker` and `e2b`, reporting *not installed* rather than failing
for a missing extra.

## `demo` contract

Five steps, each timed and reported: create · `run_code` · stream · egress probe · teardown.
The egress probe MUST attempt a real connection from inside the sandbox and MUST fail the
demo if it succeeds — a demo that shows deny-by-default not applying is a bug report, not a
pass ([H1](../hazards.md#h1--a-security-default-silently-does-not-apply)). Without the Docker
extra the demo prints the exact install command from `BackendNotInstalled` and exits `1`.

`--backend <DSN>` runs the same five steps against another backend, defaulting to
`docker://python:3.12-slim`; `--backend fake://` is the in-process form, and the stream step
is skipped for a backend that does not declare `STREAMING`. The flag selects a backend and
nothing else: it MUST NOT be able to weaken the policy the demo runs under, which is why
there is no `--egress` on `demo`.


==============================================================================
# FILE: docs/adr/README.md
==============================================================================

# Architecture Decision Records

One file per decision. Format: Context → Decision → Consequences. Immutable once Accepted —
a reversal is a **new** ADR that supersedes the old one, which is then marked `Superseded by
ADR-nnnn` rather than edited. A later ADR that narrows part of an earlier one without
reversing it is an **amendment**: the earlier ADR keeps its status and gains an
`Amended by` pointer, so the record stays navigable without being rewritten.

**Status values:** `Proposed` · `Accepted` · `Superseded by ADR-nnnn` · `Deprecated`

| ADR | Title | Status |
|-----|-------|--------|
| [0001](0001-ports-and-adapters.md) | Ports and adapters (hexagonal) architecture | Accepted |
| [0002](0002-async-first-anyio.md) | Async-first core on anyio; sync facade derived | Accepted |
| [0003](0003-no-lowest-common-denominator.md) | No lowest common denominator: capabilities + `.native` | Accepted |
| [0004](0004-thin-core-lazy-adapters.md) | Thin core; adapters behind extras, lazily imported | Accepted |
| [0005](0005-secure-by-default.md) | Secure by default, enforced in v0.1 | Accepted |
| [0006](0006-isolation-tiers-first-class.md) | Isolation tier is a first-class, reported property | Accepted |
| [0007](0007-contract-suite-as-spec.md) | The contract suite is the adapter specification | Accepted |
| [0008](0008-dsn-and-typed-config.md) | Dual configuration: DSN strings and typed config objects | Accepted |
| [0009](0009-library-first-server-later.md) | Library first; MCP is the first server; `sandboxio-server` is gated | Accepted |
| [0010](0010-stable-error-codes.md) | Error codes are public, semver-covered API | Accepted |
| [0011](0011-otel-mapping-layer.md) | OTel GenAI semconv isolated behind one mapping module | Accepted |
| [0012](0012-no-telemetry-no-import-side-effects.md) | No telemetry, no import side effects | Accepted |
| [0013](0013-complement-openai-sandboxclient.md) | Complement OpenAI's `SandboxClient`, do not compete with it | Accepted |
| [0014](0014-project-name.md) | Project name: `sandboxio`, short code `SBX` | Accepted |
| [0015](0015-python-version-floor.md) | Python floor 3.11; develop on 3.14 | Accepted |
| [0016](0016-license-mit.md) | MIT license; DCO for contributions | Accepted |
| [0017](0017-timeout-error-naming.md) | `SandboxTimeout` does not inherit the builtin; timeouts split by phase | Accepted |
| [0018](0018-isolation-tier-ordering.md) | `IsolationTier` ordering via an explicit rank; `UNKNOWN` is the default | Accepted |
| [0019](0019-streaming-process-handle.md) | Streaming returns a `Process` context manager, not a bare iterator | Accepted |
| [0020](0020-cancellation-semantics.md) | Shielded teardown with a bounded grace; the mandatory timeout is the backstop | Accepted |
| [0021](0021-observability-record.md) | One operation record, three renderings; no inline cost | Accepted |
| [0022](0022-sync-facade.md) | Hand-written sync facade over a per-sandbox portal, with a parity test | Accepted |
| [0023](0023-docker-network-and-dependencies.md) | Docker cannot filter egress; dependencies come from images or wheelhouses | Accepted |
| [0024](0024-stateful-code-on-docker.md) | Docker declares `STATEFUL_CODE` off in v0.1 | Accepted |
| [0025](0025-v01-scope-cut.md) | v0.1 ships Docker + E2B + Fake; Modal moves to v0.1.1 | Accepted |
| [0026](0026-docs-license-cc-by.md) | Documentation under CC BY 4.0; code stays MIT | Accepted |
| [0027](0027-adapter-thread-budget.md) | Blocking adapters get their own thread budget, not anyio's | Accepted |
| [0028](0028-docker-container-hardening.md) | Docker containers ship hardened, minus the flags that break images | Accepted |
| [0029](0029-docs-site-mkdocs.md) | Docs site: MkDocs Material on GitHub Pages at docs.sandboxio.dev | Accepted |

Decisions not yet made live in [`../open-questions.md`](../open-questions.md) and graduate
to an ADR here when settled.


==============================================================================
# FILE: docs/hazards.md
==============================================================================

# Hazards

What can kill this project, or its users. Each hazard has a **tripwire** — the observable
signal that it is happening — and a response. Hazards are reviewed quarterly
([runbook](runbook.md#quarterly-review)); market facts date fast in this space.

Unresolved *decisions* live in [open-questions.md](open-questions.md). This file is about
risks that persist after the decisions are made.

Numbers are permanent anchors — the runbook and several ADRs link to them — so a new hazard
takes the next free number and sits with its topic, never renumbering what is above it.

---

## Product hazards (users get hurt)

### H1 — A security default silently does not apply

**The worst possible bug in this library.** An adapter accepts `NetworkPolicy(egress="deny")`
and provisions a sandbox with full internet access; or a resource cap is dropped; or
`require_isolation` is not checked. The user believes they have a control they do not have,
and only finds out from an exfiltration.

- **Tripwire:** a contract test for policy enforcement is skipped, xfailed, or absent for a
  declared capability.
- **Response:** policy enforcement tests hit a real canary host against the real backend.
  Never mock the thing that proves the control works. A backend that cannot enforce a
  requested policy raises `CapabilityNotSupported`
  ([spec/05](spec/05-security-policy.md#network-policy)).

### H2 — Leaked sandboxes

A cancelled task, a crashed process, or a cancellation during `create()` leaves cloud
sandboxes running and billing. At agent scale this is a runaway cost, not an untidiness.

- **Tripwire:** CI reports non-zero sandboxio-labelled containers after a Docker job; a provider
  console shows sandboxes with no corresponding run.
- **Response:** shielded teardown bounded by `TEARDOWN_GRACE`, a Ryuk-style reaper for
  Docker, `metadata` labels propagated to provider-native labels so orphans are findable,
  `sandboxio reap` for operators, and a CI assertion on every Docker job
  ([ADR-0020](adr/0020-cancellation-semantics.md)). The mandatory `create(timeout=...)` is
  the guaranteed backstop — it bounds the worst case at ~300 s of billing even when every
  other mechanism fails.

### H3 — A secret reaches a log, trace, repr or audit event

Three observability sinks plus exceptions plus reprs means five chances to leak. Redaction
implemented per-sink will be wrong in at least one.

- **Tripwire:** any sink constructed downstream of its own formatting rather than from the
  shared record.
- **Response:** one operation record, one redaction pass upstream of all sinks, one test
  asserting a canary secret appears in none of them
  ([spec/06](spec/06-observability.md#redaction)).

### H4 — Isolation claims we cannot defend

Publishing "MICROVM" or "GVISOR" for a third party's infrastructure is a security claim
about someone else's system. If it is wrong, or becomes wrong, users made a trust decision
on our word.

- **Tripwire:** a tier in the table with no dated source; a provider changing its runtime.
- **Response:** every tier above `CONTAINER` carries a dated provider-documentation source;
  Daytona stays unverified; a provider's mechanism change is a **changelog entry**, not a
  footnote ([ADR-0006](adr/0006-isolation-tiers-first-class.md)).

### H16 — Docker sandboxes run as root on a writable rootfs

Agent code on the `CONTAINER` tier runs as uid 0 with `/` writable. Capabilities are
dropped, `no-new-privileges` is set, PIDs are capped and swap is bounded with memory
([ADR-0028](adr/0028-docker-container-hardening.md)), but a kernel bug reachable from an
unprivileged syscall still lands on the host as root, and anything inside the container can
rewrite the image's own binaries for the life of the sandbox.

This is a **decided, accepted gap, not an oversight**. Setting `user` breaks writes to
`/work` on ordinary images, and `read_only=True` breaks `tempfile` unless `/work` and
`/tmp` become tmpfs — both measured against a real daemon, both recorded in the ADR. The
threat model already puts "making untrusted code safe on the `CONTAINER` tier" out of scope
([spec/05](spec/05-security-policy.md#threat-model)), so no published guarantee is broken.
The risk is that the docs stop saying so, or that someone reads the hardening we *did* ship
as permission to run untrusted multi-tenant code on Docker.

- **Tripwire:** any doc, README or example implying Docker is safe for untrusted code;
  a user asking for tenant isolation and being pointed at the Docker adapter; a support
  thread that starts with an escape from a sandbox someone believed was isolated.
- **Response:** `MICROVM` stays the documented floor for untrusted multi-tenant code, and
  per-backend docs say so at the top. Closing the gap properly needs an image contract — a
  non-root uid that owns `/work`, or a tmpfs mount that does — which is its own change with
  its own ADR, not a flag added in passing. Until then the honest statement, not a better
  default, is the control.

---

## Technical hazards (the project gets hurt)

### H5 — Provider churn outpaces maintenance

E2B shipped v0→v1→v2 in about a year, with `beta_` methods and yanked releases. Daytona
rebuilt product, SDK and license. Modal enforces pre-1.0 deprecations. Absorbing this is the
value proposition; failing to absorb it is the death of the value proposition.

- **Tripwire:** the nightly latest-SDK canary red for more than one week; `provider-churn`
  issues accumulating unclosed.
- **Response:** canary in CI from the start, loose lower bounds rather than hard pins, and
  the **public churn-absorption log** — which is simultaneously the mitigation and the
  marketing ([runbook](runbook.md#provider-churn-response)).

### H6 — Core stops being provider-ignorant

A provider concept leaks into core — E2B's context semantics, Modal's Volume model — and
the one-file-per-provider containment quietly stops holding.

- **Tripwire:** a provider name, or a provider-shaped concept, appearing in a core module;
  an adapter needing more than a thin mapping.
- **Response:** core imports nothing provider-specific, enforced by an import-linter rule.
  Widening core is deliberate and goes through an ADR.

### H7 — Abstraction drifts toward the lowest common denominator

Each individual "let's just not support that, only one backend has it" is reasonable. Fifty
of them is `apache-libcloud`.

- **Tripwire:** a capability removed rather than flagged; `.native` usage growing in our own
  examples; a feature request closed as "not portable".
- **Response:** `Capability` + `.native` is the answer, never removal. Promotion into the
  typed API at **two** stable backends ([ADR-0003](adr/0003-no-lowest-common-denominator.md)).

### H8 — Import bloat and eager imports

A slow or side-effecting import is fatal for a library agents load on every cold start, and
disqualifying in offline or air-gapped CI.

- **Tripwire:** import budget over 150 ms.
- **Response:** hard CI fail over 200 ms; **feature freeze until fixed** — this is one of
  the pre-committed thresholds.

### H9 — FakeBackend diverges from reality

Users test against the fake, ship, and discover real backends behave differently. The fake
then actively costs trust instead of building it.

- **Tripwire:** a bug that the fake could not have caught, twice in the same area.
- **Response:** the fake passes the same contract suite. Divergence is a **suite gap** — fix
  the shared test, never special-case the fake
  ([ADR-0007](adr/0007-contract-suite-as-spec.md)).

---

## Supply-chain hazards

### H10 — A litellm-class incident in sandboxio itself

sandboxio is a credential-adjacent dependency that executes untrusted code. A compromise here is
maximally bad, and the enterprise accounts we are targeting are exactly the ones that will
never come back.

- **Tripwire:** any new base dependency; any transitive tree growth; a maintainer account
  without hardware 2FA.
- **Response:** `typing-extensions` + `anyio` only in core, adapters isolated and lazy,
  Trusted Publishing with PEP 740 attestations, `SECURITY.md` and an advisory process from
  day one, OpenSSF Scorecard tracked
  ([ADR-0004](adr/0004-thin-core-lazy-adapters.md), [runbook](runbook.md#security-advisories)).

### H11 — AI-slop issues and PRs

The curl project's experience: plausible-looking, wholly fabricated reports consuming
maintainer attention until the maintainers burn out. A security-adjacent project attracts
more of it.

- **Tripwire:** unreproducible reports rising as a share of the issue queue.
- **Response:** issue templates requiring reproduction, a stated and enforced triage policy,
  and closing without debate — from day one, not after it hurts.

---

## Market hazards

### H12 — Framework absorption — **highest likelihood**

LangChain's sandbox backends and OpenAI Agents SDK's `SandboxConfig` become good enough that
framework-agnosticism stops being worth a dependency.

- **Tripwire:** `SandboxClient` gaining non-OpenAI adopters; framework sandbox layers adding
  security policy depth.
- **Response:** complement rather than fight — ship adapters **into** them, in both
  directions ([ADR-0013](adr/0013-complement-openai-sandboxclient.md)). If it wins, we are
  already inside it.

### H13 — Provider consolidation

The sandbox market collapses to one or two providers and the abstraction loses its point.

- **Tripwire:** a major backend shutting down or being acquired; new projects defaulting to
  one provider without evaluation.
- **Response:** shift weight to the security-policy layer, the local/CI story (which
  survives consolidation intact), and Stage 2 orchestration.

### H14 — "Yet another abstraction layer" fatigue

- **Tripwire:** adoption conversations spent justifying the layer's existence rather than
  discussing features.
- **Response:** thin core, visible escape hatches, substrate positioning. The fsspec
  comparison does a lot of work here; earn it rather than claim it.

### H15 — A hyperscaler or standards body ships the standard

AWS AgentCore going self-hostable, or the Kubernetes agent-sandbox CRD reaching 1.0 with
broad adoption.

- **Tripwire:** `agents.x-k8s.io` leaving alpha with multi-vendor backing; an AgentCore OSS
  edition.
- **Response:** become the best adapter into it. This is a planned pivot, not a defeat.

---

## Server-mode hazards (Phase 2)

Only live if `sandboxio-server` is triggered, but the constraints are decided now. The server
holds cloud credentials **and** executes untrusted code — a tier-1 credential surface. The
LiteLLM 2026 failure chain, not to be repeated:

| Their failure | Our rule |
|---------------|----------|
| CVE-2026-42208 — pre-auth SQLi in the API-key verification path, exploited within ~36h of advisory, CISA KEV | Parameterize **everything** in the auth path |
| CVE-2026-42271 + Starlette BadHost chain — assessed CVSS 10.0 unauth RCE via MCP *test* endpoints | No unauthenticated endpoints at all; role-gate every management and test endpoint; validate Host headers |
| `/config/update` without a role check → runtime config rewrite → RCE | No config hot-reload without authz; prefer immutable config at boot |
| JWT cache keyed on `token[:20]` | No clever auth caching |

Plus: rootless container, least-privilege per-backend credentials, bind localhost unless
configured otherwise, and **never** a Docker socket reachable from sandboxed code.

---

## Pre-committed engineering gates

Pre-committed, so the call is not made under schedule pressure. Both are CI-enforced.

| Signal | Action |
|--------|--------|
| Docker adapter cannot pass 100% of the contract suite cleanly | Fix the abstraction before any cloud adapter |
| Import time >200 ms, or any import-time network call | **Feature freeze until fixed** |


==============================================================================
# FILE: docs/build-order.md
==============================================================================

# Build Order

Sequenced implementation plan. Each step has **exit criteria** — the next step does not
start until they are green. Ordering is deliberate: the two things the whole bet rests on
(the contract suite and the docs) are the two things that slip if left last, so they move
early.

---

## Step 0 — Lock the P0 decisions (½ day)

Nothing is committed until the name is settled, because it is baked into module paths, DSN
schemes, the entry-point group, error codes, env vars and docs URLs.

- [x] [Q1](open-questions.md#q1--project-name) name — **`sandboxio`**, short code `SBX` ([ADR-0014](adr/0014-project-name.md))
- [x] [Q2](open-questions.md#q2--python-version-floor) Python floor — **>=3.11**, dev on 3.14 ([ADR-0015](adr/0015-python-version-floor.md))
- [x] [Q3](open-questions.md#q3--license) license — **MIT** + DCO ([ADR-0016](adr/0016-license-mit.md))

**Exit:** three ADRs written ([0014](adr/0014-project-name.md), [0015](adr/0015-python-version-floor.md), [0016](adr/0016-license-mit.md)); the name reserved on PyPI; the repo named. **Done.**

---

## Step 1 — Skeleton and gates, before any feature code (1 day)

Gates are cheap now and expensive to retrofit: an accidental eager import spreads fast, and
an import budget added in month three is a week of untangling.

- [x] `uv` project, `src/` layout, `py.typed`, ruff, pyright strict, mypy strict
- [x] `requires-python = ">=3.11"`; CI matrix 3.11-3.14, dev default 3.14. Making 3.11 a
  **required** check is a branch-protection setting, applied when the repo goes public
- [x] CI with the four hard gates **already failing closed**:
  import budget <150 ms · no sockets at import (`pytest-socket`) · wheel-contents test ·
  type check · doc-sample parse test · doc-link check (`scripts/check_doc_links.py`)
- [x] `LICENSE` (MIT), `LICENSE-DOCS` (CC BY 4.0, [ADR-0026](adr/0026-docs-license-cc-by.md)), `SECURITY.md`, `CONTRIBUTING.md` with DCO and inbound-equals-outbound
- [x] PyPI Trusted Publishing, PEP 740 attestations, `CHANGELOG.md` — the release workflow
  is token-free and attesting; the PyPI-side publisher entry is configured at first release
- [x] `AGENTS.md` at repo root carrying the Step 0 decisions and the links into [`spec/`](spec/README.md)
- [x] Issue templates requiring reproduction; stated triage policy

Adapter extras (`sandboxio[docker]`, `[e2b]`, `[modal]`) are deliberately **not** declared
yet: an extra that installs nothing is a lie, and each provider SDK choice belongs to the
step that writes its adapter.

**Exit:** empty package installs, imports in 0.2 ms, gates green on 3.11 and 3.14, and both
a deliberately-added eager import and a socket at import turn them red. **Done.**

> `AGENTS.md` belongs here, not at the end. Most of this will be built through coding
> agents, and without the pinned decisions in context every session re-invents the sync
> facade.

---

## Step 2 — Core types, zero behaviour (2-3 days)

`models.py`, `errors.py`, `protocols.py`, `registry.py`. No I/O.

Dependencies resolved: [Q4](open-questions.md#q4--timeouterror-shadows-the-builtin) error naming
([ADR-0017](adr/0017-timeout-error-naming.md)), [Q5](open-questions.md#q5--isolationtier-needs-ordering)
tier ordering ([ADR-0018](adr/0018-isolation-tier-ordering.md)). **Unblocked.**

- [x] Value objects per [spec/01](spec/01-domain-model.md), all frozen with value equality
- [x] `IsolationTier` with its rank map, plus the test asserting every member is ranked
- [x] Full error tree with codes, hints and URLs per [spec/04](spec/04-errors.md); the catalog
  is data, and the docs pages generate from it (`scripts/gen_error_catalog.py` → [`errors/`](errors/README.md))
- [x] Protocols per [spec/02](spec/02-ports.md)
- [x] Registry: entry points + `register()`, lazy and cached

Decisions taken here that the spec left implicit: `ConfigurationError` raised directly
carries `SBX_E1000`; `RichOutput` and `FileInfo` got minimal shapes in spec/01;
`ExecResult.results` is a tuple, like every other sequence in a frozen value object.

**Exit:** `BackendNotFound` and `BackendNotInstalled` raise with the exact install command;
`--check` on the generated catalog is a pytest gate; import is ~9 ms. **Done.**

---

## Step 3 — Contract suite first, then FakeBackend (1 week)

The suite is written **before** any adapter, so it specifies behaviour instead of describing
whatever Docker happened to do. This is the step that makes everything after it cheap.

Dependencies resolved: Q6 streaming ([ADR-0019](adr/0019-streaming-process-handle.md)),
Q7 cancellation ([ADR-0020](adr/0020-cancellation-semantics.md)), Q9 observability record
([ADR-0021](adr/0021-observability-record.md)). **Unblocked.**

- [x] `BackendContractSuite` covering the map in [spec/08](spec/08-adapter-contract.md),
  including the four streaming-cleanup cases, the four cancellation cases, and capability honesty
- [x] `FakeBackend` + `sbx_fake` pytest fixture, registered by entry point
- [x] The one operation record, redaction at close, and the no-op + queue audit sinks
- [x] Pulled forward because the fake had to be reachable as `create("fake://")`: the DSN
  parser, `sandboxio.create()`/`connect()` with `require_isolation`, shielded teardown and
  request validation shared by every adapter

Decisions taken here: sinks are configured per backend via `AuditConfig`; a backend that
does not declare `NETWORK_POLICY` refuses to create at all, since deny-by-default cannot be
honoured; the filesystem family `SBX_E1700`/`SBX_E1701` was added under spec/04's rule 3.

**Exit:** `FakeBackend` passes the suite in three configurations (everything declared,
almost nothing declared, `UNKNOWN` tier); three deliberately broken fakes fail it exactly
where they are broken and nowhere else. **Done.**

---

## Step 4 — Docker adapter to 100% (1-2 weeks)

Dependencies resolved: Q8 sync facade ([ADR-0022](adr/0022-sync-facade.md)), Q10 network and
dependencies ([ADR-0023](adr/0023-docker-network-and-dependencies.md)), Q11 `STATEFUL_CODE`
([ADR-0024](adr/0024-stateful-code-on-docker.md)). **Unblocked.**

- [x] Full adapter: lifecycle, `run`, `run_code`, streaming, filesystem — shipped as the
  workspace distribution `sandboxio-docker` (import `sandboxio_docker`), which is what
  `sandboxio[docker]` installs; the same shape a third-party adapter takes
- [x] `network_mode: none` by default; deny verified against a canary host; non-empty `allow`
  raises `CapabilityNotSupported`; `STATEFUL_CODE` declared off
- [x] Ryuk-style reaper (one sidecar per process, `SBX_DOCKER_REAPER=0` disables); CI asserts
  zero leaked containers
- [x] Sync facade ([ADR-0022](adr/0022-sync-facade.md)) landed with the parity test; exercised
  by behaviour tests against the fake and a smoke test on real Docker, **not** by the full
  contract suite — the suite is async, and wrapping sync back into async would not test the
  cancellation rows honestly. Open point for the suite, not the facade.

Decisions taken here: docker-py in worker threads, never an asyncio-only client (ADR-0002's
trio point); a timeout or cancellation restarts the container because Docker cannot signal
one `exec` — every process dies, the filesystem survives, and the docs say so; the PID 1 is
`sleep <timeout>`, which is the provider-side lifetime backstop Docker otherwise lacks;
`disk_mb` is refused rather than silently ignored.

**Threshold:** if Docker cannot pass the suite cleanly, **fix the abstraction before
touching a cloud adapter** — a suite bent to fit Docker is worthless for E2B.

**Exit:** 100% of the suite green on real Docker (locally against OrbStack; the CI job is
in place); zero leaked containers; deny-egress proven against a canary host. **Done.**

---

## Step 5 — E2B adapter (1-2 weeks)

The real stress test of the abstraction: rich outputs, PTY, pause/resume, microVM isolation.

- [x] Full adapter as the workspace distribution `sandboxio-e2b`; rich outputs surfaced
  through `ExecResult.results` as `RichOutput(mime_type, data)` per Jupyter format;
  `STATEFUL_CODE` declared, every run owning a code context we can restart
- [x] PTY and pause/resume stay behind `.native` for v0.1
- [x] Native exception mapping with `__cause__`; `AuthError` names `E2B_API_KEY`
- [x] Nightly + release CI job, gated on the API key

Decisions taken here: a `list[str]` command is `shlex`-quoted into E2B's bash (the SDK
runs shell strings only); a timed-out or cancelled `run_code` restarts its code context,
the only way to stop a cell; a dropped stream the SDK reports as a timeout is checked
against `is_running()` and surfaces as `SandboxGone` when the lifetime ended; CPU and
memory come from the template, so `Resources(...)` is refused — the suite gained
`resource_caps_supported = False` for adapters like this.

What the abstraction did **not** need: no `Capability` widened, nothing pushed to `.native`
that the spec had not already put there.

**Expect to widen `Capability` or push things to `.native` here.** Never bend toward the
lowest common denominator ([ADR-0003](adr/0003-no-lowest-common-denominator.md)).

**Exit:** 100% of the suite for declared capabilities against the real service (73 s,
≈60 sandboxes); `tests/test_one_line_swap.py` runs the same downstream code on `fake://`,
`docker://` and `e2b://`. **Done.**

---

## Step 6 — Distribution surface (2 weeks)

- [x] `sandboxio doctor` (+ `--json`, credential names only, no paid calls; programmatic
  `sandboxio.doctor()` → `DoctorReport`), `sandboxio reap` (dry run by default; Docker lists
  stopped containers too; `--label`, `--kill`), `uvx sandboxio demo` (create · run · stream ·
  egress probe · teardown) — [spec/10](spec/10-cli.md). Console scripts `sandboxio` and the
  undocumented `sbx` alias; `python -m sandboxio`.
- [x] MCP server `python -m sandboxio.mcp` behind `sandboxio[mcp]`, six tools, one sandbox per
  process, config fixed at start; `docker/mcp/Dockerfile` (rootless, E2B backend, no socket)
  plus the Docker MCP Catalog `server.yaml`/`tools.json` ([spec/09](spec/09-integrations.md)).
  **Publication is blocked** until v0.1: the image push and the Docker MCP registry PR both
  need a released version to point at.
- [x] LangGraph tool (`sandboxio[langgraph]`) and OpenAI Agents tool (`sandboxio[openai-agents]`),
  each a native tool object under 100 lines, tested against the fake
- [x] `sandboxio/otel.py` — GenAI semconv 1.37.0 `execute_tool` spans, zero-config, every
  attribute string in one file (a test greps for strays); `LoggingSink` and `FileSink`
  beside `NoopSink`/`QueueSink`; `sandboxio[otel]` extra for the API
- [x] Diátaxis docs: [`quickstart.md`](quickstart.md), [`how-to/`](how-to/README.md) (Docker with image
  prep and offline wheelhouse, E2B, offline testing, observability, CI, operations,
  integrations), [`explanation/`](explanation/README.md), the generated [`errors/`](errors/README.md),
  `llms.txt` + generated `llms-full.txt` (`scripts/gen_llms_full.py --check` is a gate), the
  paste-ready [`AGENTS.md` snippet](reference/agents-snippet.md)
- [x] The same Markdown published at `docs.sandboxio.dev` — MkDocs Material, GitHub Pages,
  strict build, `tests/test_docs_site.py` proving every `SBX_E` code resolves at the URL
  the exception prints ([ADR-0029](adr/0029-docs-site-mkdocs.md))
- [x] Copy-paste GitHub Actions workflow for users in [`how-to/ci.md`](how-to/ci.md): fake job
  on PRs, Docker job on `main`, leak check
- [x] `tests/test_readme_examples.py`: every README Python block is executed verbatim —
  scripts against fakes registered as `docker`/`e2b`, pytest-style blocks through pytester
- [x] [`examples/`](https://github.com/bit-agents/sandboxio/blob/main/examples/README.md): complete runnable programs — hello world, the
  one-line backend swap, streaming and timeouts, deny-by-default egress, a LangGraph agent
  and an OpenAI Agents one, and `sbx_fake` for the reader's own tools. `tests/test_examples.py`
  executes each script against the fakes; `examples/test_my_tool.py` is collected by pytest.

Decisions taken here: the CLI is stdlib `argparse` in core, not the Typer app spec/10 named,
because `uvx sandboxio demo` must run from the bare distribution and ADR-0004 forbids a new
base dependency; `rich` is used only for tracebacks under `SBX_DEBUG=1` when importable.
Exit codes are `0/1/2/130`; `doctor` exits `1` when an installed backend has a failing check.
`uvx sandboxio demo` on the bare distribution prints the install command **and** the
`uvx --from "sandboxio[docker]" sandboxio demo` form, since that is the stranger's next step.
`reap` talks to an optional `ReapableBackend` port (`list_managed`/`kill_managed`) and a
`ManagedSandbox` value object, both added to spec/01–02; every first-party backend
implements it. `AuditEvent` gained `code: str | None`, populated only with
`AuditConfig(capture_code=True)` and redacted; the JSON-line sinks render `as_dict()`.
Semconv has no sandbox vocabulary, so beyond `gen_ai.operation.name`/`gen_ai.tool.name` the
span attributes are `sandboxio.*`. `FakeBackend` now applies `NetworkPolicy` to recognised
network calls in `run_code` (`urllib.request.urlopen`, `requests.get`, …) so the demo's
egress probe is honest on `fake://`. Integrations live in core under
`sandboxio.integrations.*` with the framework behind an extra; the MCP server translates
`SandboxError` into the SDK's `ToolError` so the code and fix reach the model (the SDK hides
other exceptions). `SbxSandboxClient` stays v0.2.

**Exit:** every README example runs verbatim — the gate is `tests/test_readme_examples.py`
(six blocks) — and every program under [`examples/`](https://github.com/bit-agents/sandboxio/blob/main/examples/README.md) runs against the
fakes, the gate being `tests/test_examples.py`, which also fails on an example missing from
that index. Quickstart timing on this machine, fresh venv, cold `uv` cache, image already
pulled: `uvx --from ".[docker]" sandboxio demo` **6.1 s** end to end (the demo's own five
steps 2.5 s, create 1.3 s); `uvx --from . sandboxio demo` on the bare distribution 2.6 s to
the install hint. The MCP image builds and runs as uid 10001 with
`python -m sandboxio.mcp` as entrypoint; pushing it and the catalog PR wait for the org name.
Docker suite green including reap, zero containers left; E2B suite green including reap,
zero sandboxes left; 3.11 and 3.14 gates green. **Done.**

---

## v0.1 ship

**Scope: Docker + E2B + Fake. Modal moves to v0.1.1**
([ADR-0025](adr/0025-v01-scope-cut.md)). Two backends fully carry the "swap with one line"
narrative; three does not buy a better launch, it buys a later one. Docker↔E2B is also the
widest gap in the set, so it stresses the abstraction hardest.

Note: no v0.1 backend represents the `GVISOR` tier. The tier stays in the enum — it is a
property of the model, not of what shipped — but the docs must not imply Modal is available.

Launch narrative: *one secure Python API for running AI-agent code in any sandbox — swap
Docker↔E2B with one line; no network by default; test your agent tools offline with the
built-in fake.* Publish the MCP server to the Docker MCP Catalog the same day.

---

## v0.1.1 — Modal

GPU, Volumes with per-tenant `sub_path`, gVisor tier. Third backend also re-tests the
abstraction against a genuinely different filesystem model.

## v0.2

1. **Flight recorder + `sandboxio replay <trace>`** — record code, file diffs, stdio and timing to
   a portable trace; deterministic replay; local static HTML viewer.
2. **Egress learning mode** — `egress="learn"` records attempted domains, emits a
   paste-ready allowlist.
3. **Per-execution meter** — `{duration_ms, cost_usd?, backend}` on every result, plus
   `sandboxio bench`.
4. **Auto dependency inference** — PEP 723 / import parsing → uv provisioning, sandbox-gated.
5. Pre-execution security lint hook (advisory by default).
6. Pydantic AI + CrewAI adapters; OpenAI `SandboxClient` adapter.
7. MCP `search_docs`; adapter authoring guide + template repo; shell completions.
8. **Routing file** — `sandboxio-routing.yaml`, isolation classes and `route_for()`, already
   specified in [spec/07](spec/07-configuration.md#routing-file) and marked deferred there.
   It was written into the spec at design time and never scheduled; this is where it lands.

## v0.3+ — capability-gated and ecosystem

Snapshot/fork once ≥2 backends are stable · shadow mode (run on two backends, diff) ·
Daytona and Vercel adapters · K8s agent-sandbox adapter near CRD 1.0 · TUI dashboard ·
`sandboxio-server` **only when its trigger fires**
([ADR-0009](adr/0009-library-first-server-later.md)).

---

## Dependency summary

| Step | Blocked by |
|------|-----------|
| 0 | — |
| 1 | Q1, Q2, Q3 |
| 2 | — (Q4, Q5 decided) |
| 3 | — (Q6, Q7, Q9 decided) |
| 4 | — (Q8, Q10, Q11 decided) |
| 5 | Step 4 exit criteria, in full |
| 6 | — (all decided) |
