Metadata-Version: 2.4
Name: resoluto-sandbox
Version: 0.1.0
Summary: Resoluto sandbox: store-mediated, Kata-isolated, cloud-agnostic execution substrate
Project-URL: Homepage, https://deepbluecoding.github.io/resoluto-sandbox/
Project-URL: Documentation, https://deepbluecoding.github.io/resoluto-sandbox/
Project-URL: Repository, https://github.com/DeepBlueCoding/resoluto-sandbox
Project-URL: Changelog, https://github.com/DeepBlueCoding/resoluto-sandbox/blob/master/CHANGELOG.md
Project-URL: Issues, https://github.com/DeepBlueCoding/resoluto-sandbox/issues
Author: DeepBlueCoding
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: agentic,execution,isolation,kata,microvm,resoluto,sandbox
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Build Tools
Classifier: Topic :: System :: Distributed Computing
Classifier: Typing :: Typed
Requires-Python: >=3.12
Requires-Dist: pydantic>=2.0.0
Provides-Extra: gcs
Requires-Dist: gcloud-aio-storage>=9.3.0; extra == 'gcs'
Provides-Extra: k8s
Requires-Dist: aioboto3>=13.0.0; extra == 'k8s'
Requires-Dist: kubernetes-asyncio>=31.1.0; extra == 'k8s'
Provides-Extra: s3
Requires-Dist: aioboto3>=13.0.0; extra == 's3'
Description-Content-Type: text/markdown

# resoluto-sandbox

[![PyPI](https://img.shields.io/pypi/v/resoluto-sandbox)](https://pypi.org/project/resoluto-sandbox/) [![Python](https://img.shields.io/badge/python-3.12%2B-blue)](https://www.python.org/) [![License](https://img.shields.io/badge/license-Apache--2.0-green)](https://github.com/DeepBlueCoding/resoluto-sandbox/blob/master/LICENSE) [![CI](https://github.com/DeepBlueCoding/resoluto-sandbox/actions/workflows/ci.yml/badge.svg)](https://github.com/DeepBlueCoding/resoluto-sandbox/actions/workflows/ci.yml) [![Docs](https://img.shields.io/badge/docs-latest-blue)](https://deepbluecoding.github.io/resoluto-sandbox/)

Run untrusted code — AI-generated, third-party, or adversarial — with a dedicated Linux kernel per
run. `resoluto-sandbox` executes any program inside a **Kata microVM** (a hardware-virtualized guest
with its own kernel, the isolation model serverless platforms use to run untrusted multi-tenant code)
and moves data across the boundary through a durable store. A workload that gets root, forks a miner,
or reaches for your credentials is confined to a VM with no host filesystem, no host network, and
nothing you didn't hand it.

The security model is **zero-trust**: the workload is assumed hostile and granted nothing by default.
Your program stays plain — it reads `argv`, writes `stdout`/files, exits, and never imports
`resoluto.sandbox`. A script that runs as `uv run agent.py` on your machine runs unchanged inside the
microVM.

📖 [Documentation](https://deepbluecoding.github.io/resoluto-sandbox/) · 🤖 [llms.txt](https://deepbluecoding.github.io/resoluto-sandbox/llms.txt) for AI agents

<p align="left">
  <img alt="python" src="https://img.shields.io/badge/python-3.12%2B-blue">
  <img alt="status" src="https://img.shields.io/badge/status-beta-orange">
</p>

---

## Install

The Python package installs anywhere (Python 3.12+); its base is pydantic-only. Heavy deps are gated
behind extras:

```bash
pip install resoluto-sandbox            # base — pydantic only, no cloud/k8s deps
pip install "resoluto-sandbox[s3]"      # S3 / minio Conduit           (aioboto3)
pip install "resoluto-sandbox[k8s]"     # k8s backend                  (kubernetes-asyncio + aioboto3)
pip install "resoluto-sandbox[gcs]"     # GCS Conduit                  (gcloud-aio-storage)
```

`uv` is recommended for running programs/examples.

## Requirements (host)

Running a sandbox needs an isolation host. **The `local` backend (Kata microVMs) is Linux + KVM only**
— the `k8s` backend's *client* runs anywhere (the microVMs run in your cluster).

| Backend | Runs the microVM on | Host needs |
|---|---|---|
| `local` | **Linux with `/dev/kvm`** — bare metal, or a VM with **nested virtualization** | Kata Containers, a `nerdctl-full` bundle (containerd + CNI), Docker (builds images), a local OCI registry, `sudo` |
| `k8s` | your Kubernetes cluster | a cluster with the Kata runtime class + an S3/minio store; `resoluto-sandbox[k8s]` on the client |

> **macOS / Windows:** Kata microVMs need a Linux kernel + KVM, so the `local` backend does **not** run
> natively. Use a Linux VM with nested virt enabled, or point the `k8s` backend at a remote cluster —
> the client side runs on any OS.

### `local` backend components (Linux)

`scripts/local-backend-up.sh` **verifies** these, then provisions the rest (dedicated containerd, CNI
bridge, `local.env`, and a green Kata-microVM canary). Egress is **runtime-managed per run**
(`Sandbox.run(egress=[...])`) — the script provisions no firewall. It does **not**
install the kernel/Kata/nerdctl for you:

| Component | Why | How to get it |
|---|---|---|
| **KVM** (`/dev/kvm`) | Kata boots a real VM | your distro's virtualization packages (e.g. `qemu-kvm`/`libvirt` on Debian/Ubuntu, `qemu` on Fedora/Arch); on a VM, enable nested virtualization |
| **Kata Containers** → `/opt/kata` | the VM runtime (`kata-runtime`, `containerd-shim-kata-v2`) | static release tarball from [kata-containers releases](https://github.com/kata-containers/kata-containers/releases) extracted to `/opt/kata` (verified with 3.31.0) — not a distro package |
| **`nerdctl-full`** → `/opt/resoluto-local` | containerd + nerdctl + CNI plugins, standalone from Docker/k3s | the **`nerdctl-full-*`** release tarball from [nerdctl releases](https://github.com/containerd/nerdctl/releases) (verified with 2.3.3) |
| **Docker** | builds the base / provider / your own images (`docker build`, `image build`, `image push`) | Docker Engine — your distro's package or the [official apt repo](https://docs.docker.com/engine/install/) |
| **OCI registry** on `localhost:5000` | bridges Docker's image store → the sandbox's separate containerd (see [Prebuilt provider images](#prebuilt-provider-images)) | `local-backend-up.sh` **starts one for you** if Docker is present; or run it yourself: `docker run -d --restart unless-stopped -p 5000:5000 --name registry registry:2` |

Architecture: amd64 and arm64 Linux both work — match the Kata/nerdctl tarball to your arch (the images
build for the host arch automatically). Then:

```bash
bash scripts/local-backend-up.sh      # verify + provision → green canary
set -a; source local.env; set +a      # exports RESOLUTO_SANDBOX_IMAGE etc.
```

---

## Quickstart

The sandbox runs your program in a **Kata microVM** — there is no zero-isolation mode — so it needs a
one-time host provisioning first: `/dev/kvm` + Kata + a dedicated containerd + the sandbox image. The
provisioning script does all of it and **writes `local.env`** (which is git-ignored and does *not*
ship in the repo). From a repo clone:

```bash
bash scripts/local-backend-up.sh     # provisions the local backend + image, writes local.env, ends on a green canary
set -a; source local.env; set +a     # exports RESOLUTO_SANDBOX_IMAGE

uv run python examples/run_hello_in_sandbox.py   # the bare mechanics — a plain program, isolated
```

Then, in your own code:

```python
from resoluto.sandbox import Sandbox

result = Sandbox(backend="local").run(
    ["python", "-c", "print('hello from the sandbox')"]
)
print(result.output)   # hello from the sandbox
print(result.ok)       # True
```

The result captures the output, the exit code, and any files you asked to collect (`output_paths`).
`stdin` is not supported — pass inputs via argv, env, or workspace files.

> The local backend runs in a Kata microVM and needs a sandbox image present in its **dedicated**
> containerd (not your regular Docker daemon — see [Prebuilt provider
> images](#prebuilt-provider-images) for the transfer step) — pass `Sandbox(backend="local",
> image="…")` (default `resoluto-sandbox-base:<installed wheel version>` — `default_local_image()`,
> never a floating tag; build it from `Dockerfile.base` or `resoluto-sandbox image build`). Run argv with
> the **guest's** `python` and paths relative to `workspace`, not host absolute paths.

**Run an arbitrary program, isolated** — the sandbox runs any untrusted program; an LLM agent is just
one example. `run_agent_in_sandbox.py <provider>` is symmetric across every provider image the sandbox
ships — the name you pass selects the matching prebuilt image, payload, credential, and egress host
(nothing privileges one provider). Each payload is a plain program that never imports the library:

```bash
set -a; source local.env; set +a          # provision the local Kata backend first; build the provider image too
export OPENAI_API_KEY=...                  # each provider brings its OWN credential; the sandbox just forwards it
uv run python examples/run_agent_in_sandbox.py openai "In five words, why isolate an agent?"
#   provider : openai  (resoluto-sandbox:openai-agents-0.17.7)
#   INPUT    : 'In five words, why isolate an agent?'
#   OUTPUT   : 'Untrusted code cannot escape containment.'
# ...or `claude` (CLAUDE_CODE_OAUTH_TOKEN) / `langchain` (ANTHROPIC_API_KEY) — same driver, different image.
```

For the bare mechanics without an LLM, see `examples/run_hello_in_sandbox.py`. The end-to-end
verification harnesses that drive a minimal agent through BOTH backends (local + k8s) live in
`tests/smoke/` (`smoke_both_backends.py`, `smoke_llm.py`).

---

## The program contract

A sandbox program reads `argv`, writes to `stdout` / files, exits with a code, and never imports
`resoluto.sandbox`. A script that works as `uv run agent.py` works unchanged inside the sandbox; test
runners, LLM agents, and shell scripts all qualify. Dependencies are your program's concern — put
`uv run` / `pip install` in your argv, or bake them into the image.

---

## How it works

The host and the sandbox never hold a live connection. They rendezvous through a durable key/value
store (the **Conduit**): the sandbox is passive — it writes append-only JSONL chunks and a final
`result.json`; the host launches it, tails the chunks, and reaps it. The same flow works whether the
sandbox is a microVM next to you or a pod in a cluster, and a network blip can't wedge a run.

### Components

| Component | What it is | What it does |
|---|---|---|
| **Your program** | Any script/binary — plain | Reads `argv`/env, writes `stdout`/files, exits. Never imports `resoluto.sandbox`. |
| **`Sandbox`** | Thin Python facade | `Sandbox(backend=...).run(argv, ...)` — one call, identical for every backend. |
| **`SubstrateBackend`** | The one backend impl | Drives the 3-phase flow (stage → run → collect). Holds one `SandboxRuntime` + one `Conduit`. |
| **`SandboxRuntime`** (ABC) | The isolation/placement seam | Launches, checks status, destroys the isolated sandbox. Impls: `KataNerdctlSandboxRuntime` (local), `K8sSandboxRuntime` (k8s). |
| **`Conduit`** (ABC) | The durable exchange seam | `put` / `get` / `list_prefix` on a key/value store — the ONLY channel between host and sandbox. Impls: `LocalConduit` (bind-mounted dir), `S3Conduit` (minio/S3), `StdoutConduit`, `GcsConduit` (unverified). |
| **`runner_main`** | The in-guest entrypoint | Runs inside the sandbox only — your program never sees it. Verifies the egress canary, stages inputs from the Conduit, execs your `argv`, ships spans/heartbeat/result back to the Conduit. |
| **sandbox image** | The OCI image the sandbox boots | Must contain your program's runtime + the `resoluto-sandbox` wheel. Prebuilt overlays: `resoluto-sandbox:claude-agent-sdk-<ver>`, `:langchain-<ver>`, `:openai-agents-<ver>` (see [Images](#prebuilt-provider-images) below). |

### Architecture — local vs. k8s

Same isolation model on both backends — a Kata microVM with its own guest kernel, egress denied by
default until you open exactly what you need, and a fail-closed canary that verifies isolation
*before* your program runs. Only the placement and the Conduit implementation differ:

```
LOCAL BACKEND  (single host, no cluster)
────────────────────────────────────────
┌────────────────────────────────────────────────┐
│ Host process (your code)                       │
│   Sandbox(backend="local")                     │
└────────────────────────────────────────────────┘
                    nerdctl
                    (dedicated containerd)
                     │
                     ▼
┌────────────────────────────────────────────────┐
│ ISOLATED: Kata microVM                         │
│ (own guest kernel)                             │
│ runner_main -> your argv                       │
│ egress: NONE by default (no network interface) │
│ open per-run -> CNI bridge + SNI proxy         │
└────────────────────────────────────────────────┘
                    bind mount (its own run prefix)
                     │
                     ▼
LocalConduit (host dir)
```

```
K8S BACKEND  (Kubernetes cluster)
─────────────────────────────────
┌────────────────────────────────────────────────┐
│ Host process (your code)                       │
│   Sandbox(backend=SubstrateBackend(            │
│     K8sSandboxRuntime(...)))                   │
└────────────────────────────────────────────────┘
                    kube API
                    (pinned context)
                     │
                     ▼
┌────────────────────────────────────────────────┐
│ ISOLATED: Kata microVM pod                     │
│ (own guest kernel)                             │
│ runner_main -> your argv                       │
│ egress: k8s NetworkPolicy, default-deny        │
│ (DNS + store only, until opened)               │
└────────────────────────────────────────────────┘
                    HTTPS (S3 API)
                     │
                     ▼
S3Conduit (minio / S3 bucket)
```

`local` needs `/dev/kvm` + `nerdctl` + a dedicated containerd, provisioned by
`scripts/local-backend-up.sh` (ends in a green Kata-microVM canary). `k8s` needs a cluster with Kata
Containers installed, an S3-compatible store, and a pinned kube context. Full setup —
including the vendor-neutral k8s stack — is in [`docs/backends.md`](https://github.com/DeepBlueCoding/resoluto-sandbox/blob/master/docs/backends.md).

### Data flow — the Conduit

The host and the sandbox **never talk to each other directly** — every arrow below touches the
Conduit, never the other side. That's what makes a network blip harmless: nothing is waiting on a
live connection.

| Phase | Host does | Conduit holds | Sandbox does |
|---|---|---|---|
| **1. Stage** | `put_dir(workspace)` — writes the input archive | the input archive, keyed to this run | *(not launched yet)* |
| **2. Run** | launches the sandbox, then only tails the Conduit | spans + heartbeat + `result.json` + output archive, as `runner_main` writes them | canary → stages inputs *from* the Conduit → execs your `argv` → ships output *to* the Conduit |
| **3. Collect** | `list_prefix` + `get` → assembles `RunResult`, reaps the sandbox | the final chunks, until reaped | destroyed |

Live output still streams to `stream` (default `sys.stdout`) as it happens during **2. Run** — the
table above is what's durable, not the only thing you see.

### Security model

Zero-trust: the workload is assumed hostile and granted nothing by default. Every crossing of the
boundary is explicit.

| Guarantee | How |
|---|---|
| **Own kernel** | Each run is a Kata microVM with its own Linux kernel — hardware-virtualized, not a shared-kernel namespace/cgroup container. A guest-kernel exploit stops at the VM. |
| **Unprivileged workload** | The program runs as a non-root user with no Linux capabilities and no privilege escalation. The optional docker-in-docker mode is guest-scoped (`privileged-without-host-devices`) and still drops the workload to a non-root user. |
| **No ambient host access** | No host filesystem, no host devices, no host kernel memory, no host control-plane sockets (containerd/Docker), and its own PID namespace. Host credentials are never forwarded — the guest gets only a scoped, short-lived store token. |
| **Isolated per run** | Each run is keyed to its own store prefix. On `local` the guest is bind-mounted only that prefix; on `k8s` it holds only a prefix-scoped credential. Runs cannot read each other's state. |
| **Default-deny egress** | A fresh workload has no network — on `local`, no network interface at all; on `k8s`, a default-deny `NetworkPolicy`. You grant specific domains per run. Cloud metadata (IMDS `169.254.169.254`) and RFC1918 private ranges are refused even on an allowlist match, so an opened domain can't pivot into your network. |
| **Data in, contained out** | Inputs and outputs cross as data, never as control. The host never executes what the workload produces; output collection is scoped to the paths you declare and rejects path-traversal and symlink escapes. |
| **No silent downgrade** | VM-grade or nothing. The Kata runtime-class guard is unconditional and egress is fail-closed; a weaker posture is only ever selected explicitly. A fail-closed canary proves isolation is in force *before* your program starts. |

**Trust assumption.** The boundary is a virtual machine, so the trusted computing base is the
hypervisor and the host kernel — the same assumption behind any virtualization or serverless
platform. Keeping the isolation host (KVM, Kata, your kernel) patched is the residual risk, and the
only one.

### Egress — deny by default

Untrusted code should not be able to phone home, and by default it can't.

- **`local`** — the default run gets **no network interface**. There is nothing to reach: no internet,
  no DNS, no LLM, no registries. The store is a host bind mount, not a network endpoint, so a run is
  fully functional with zero network. Opening an allowlist attaches a NIC on an isolated bridge, routed
  through a built-in **SNI proxy** that forwards only connections whose TLS SNI matches a domain you
  listed.
- **`k8s`** — the pod reaches its S3 store over the network, so it always has an interface, governed by
  a default-deny **NetworkPolicy**: the store and DNS only, until you open specific domains.

Grant egress per run, by domain:

```python
Sandbox(backend="local").run(argv)                                 # no network (default)
Sandbox(backend="local").run(argv, egress=["api.anthropic.com"])   # only Anthropic, this run
Sandbox(backend="local").run(argv, egress=["registry.npmjs.org", "*.openai.com"])
```

The allowlist matches by domain (TLS SNI), not IP, so it survives the address rotation of CDN-backed
APIs; it does no CA/MITM. Private ranges and cloud metadata are refused even on a match. `None`/`[]`
returns to the no-network default. Open `registry.npmjs.org` and `npm install` reaches the registry and
nothing else; drop it and the same install fails.

> `run(egress=[...])` is enforced by the `local` backend. On `k8s`, egress is a per-runtime
> `EgressConfig` rendered to a default-deny `NetworkPolicy`; `public_https=True` is the explicit escape
> hatch that allows all outbound HTTPS for trusted code. See
> [`docs/networking.md`](https://github.com/DeepBlueCoding/resoluto-sandbox/blob/master/docs/networking.md).

---

## `Sandbox.run()` reference

```python
Sandbox(backend="local").run(
    argv,                 # program + arguments
    *,
    workspace=None,       # dir staged into the sandbox at /workspace; None = nothing staged at all
    stdin=None,           # unsupported — raises NotImplementedError
    env=None,             # dict overlaid on the sandbox environment
    env_file=None,        # dotenv file merged UNDER env (env wins) — convenience, not security (see below)
    secrets=None,         # dict[str, str | SecretKeyRef] — see "Secrets" below
    output_paths=None,    # glob patterns collected back as artifacts
    stream=None,          # live output sink; None echoes to sys.stdout
    egress=None,          # domains allowed for THIS run (local); None/[] = no network at all
) -> RunResult
```

`RunResult`: `exit_code`, `output`, `errors` (always empty — stdout/stderr are merged), `artifacts`,
`result` (parsed `result.json` if the program wrote one), `ok` (`exit_code == 0`).

---

## Secrets

Three mechanisms, for three different jobs — none is a drop-in replacement for another:

| Mechanism | Where the plaintext lands | Use for |
|---|---|---|
| `env_file="path/to/.env"` | Literal env entry (pod spec / nerdctl `-e`) — same exposure as `env=` | Local dev config/secrets; pure convenience, not a security boundary |
| `secrets={"VAR": SecretKeyRef("my-secret", "key")}` | Never touches resoluto-sandbox at all — kubelet materializes it | k8s only. References a `Secret` object that already exists (created by `kubectl`, [External Secrets Operator](https://github.com/external-secrets/external-secrets), or anything else) |
| `secrets={"VAR": "vault:secret/data/x#key"}` | Resolved **inside the guest** by a `SecretProvider` — the host never sees the value | Portable across `local`/`k8s`. Ships as an ABC only today — see `secrets.py`; implement a concrete provider (Vault, AWS Secrets Manager, GCP Secret Manager, ...) and dispatch it in `secrets_from_env()` |

```python
from resoluto.sandbox import Sandbox
from resoluto.sandbox.secrets import SecretKeyRef

# env_file — host reads a dotenv file, merges it into the env (env= wins on conflict)
Sandbox(backend="local").run(argv, env_file=".env")

# SecretKeyRef — k8s-native, zero fetch code, ignored on the local backend
Sandbox(backend="k8s", image=...).run(argv, secrets={"ANTHROPIC_API_KEY": SecretKeyRef("anthropic-key", "api_key")})

# SecretProvider ref — guest resolves it itself; host only holds a scoped RESOLUTO_SECRETS_* credential
Sandbox(backend="local").run(argv, secrets={"ANTHROPIC_API_KEY": "vault:secret/data/anthropic#api_key"})
```

The host **never mints credentials** for the `SecretProvider` path — same posture as the Conduit's
`RESOLUTO_STORE_WRITE_TOKEN` today: you mint an already-scoped, short-lived credential yourself
(a Vault token, an AWS STS `AssumeRole` triple, a GCP impersonation-minted OAuth2 token — never a
static key file) and pass it via `RESOLUTO_SECRETS_KIND`/`RESOLUTO_SECRETS_*` env vars; resoluto-sandbox
only plumbs it to the guest and calls `.get(ref)` right before your program execs.

---

## CLI

```bash
resoluto-sandbox run -- echo hi                          # local backend (default)
resoluto-sandbox run --backend k8s --image <img> -- python agent.py
resoluto-sandbox run --env-file .env -- python3 agent.py  # dotenv merged into the sandbox env
resoluto-sandbox doctor                                  # check backend readiness
```

`--` separates sandbox options from the program argv. `--env-file` is CLI-only for now — `secrets=`
(`SecretKeyRef`/`SecretProvider`) is a Python API construct (needs a typed value, not just a string)
and has no CLI flag yet.

---

## Prebuilt provider images

Each overlay pins one SDK version and tags itself by it — the tag says exactly what's inside.
`image build` **builds with Docker and pushes to the registry the local backend pulls from** (see the
box below), so after it runs the image is ready — no manual transfer step. Bring the backend up first
(`scripts/local-backend-up.sh`) so the registry exists:

```bash
resoluto-sandbox image build --provider claude   # -> pushed localhost:5000/resoluto-sandbox:claude-agent-sdk-0.2.110
resoluto-sandbox image build --provider openai   # -> pushed localhost:5000/resoluto-sandbox:openai-agents-0.17.7  (also serves `openrouter`)
resoluto-sandbox image build --provider all      # base once, then every overlay
```

| Provider | Bakes | Example agent | Auth |
|---|---|---|---|
| `claude` | `@anthropic-ai/claude-code` + `claude-agent-sdk` | `examples/payloads/claude_agent.py` | Claude Max/Pro subscription (`claude setup-token`) or `ANTHROPIC_API_KEY` — see [`docs/auth.md`](https://github.com/DeepBlueCoding/resoluto-sandbox/blob/master/docs/auth.md) |
| `langchain` | bare `langchain` + `langgraph` — **no LLM integration** | `examples/payloads/langchain_agent.py` | Depends which integration you add — see below |
| `openai` | `openai-agents` | `examples/payloads/openai_agent.py` | `OPENAI_API_KEY` — pay-as-you-go API only, `OPENAI_MODEL` override |
| `openrouter` | *(reuses the `openai` image)* | `examples/payloads/openai_agent.py` (via `OPENAI_BASE_URL`) | `OPENROUTER_API_KEY` — OpenAI-compatible endpoint `https://openrouter.ai/api/v1`, default model `mistralai/mistral-small-3.2-24b-instruct`, `OPENROUTER_MODEL` override |

> **The `langchain` image is bare on purpose.** LangChain itself is provider-agnostic — it has no
> built-in way to call an LLM. To actually use it, extend the image with the matching integration
> package for whichever provider you want:
> ```dockerfile
> FROM resoluto-sandbox:langchain-1.3.11
> RUN pip install --break-system-packages langchain-anthropic   # or langchain-openai, etc.
> ```
> `examples/payloads/langchain_agent.py` demonstrates the Anthropic integration specifically (needs
> `langchain-anthropic` + `ANTHROPIC_API_KEY`, model override `ANTHROPIC_MODEL`) — it will
> `ImportError` against the plain prebuilt `langchain` image until you extend it this way.

> **Two image stores — and how a built image reaches the sandbox.** `docker build` lands the image in
> your regular **Docker daemon**. The `local` backend does *not* use Docker: it launches Kata microVMs
> via `nerdctl` against its OWN **dedicated containerd** (`/run/resoluto-local/containerd/`, set up by
> `scripts/local-backend-up.sh`) — a **separate image store** that can't see what `docker build`
> produced. The bridge between the two is the on-box **registry**:
>
> - `resoluto-sandbox image build` builds with Docker **and pushes** to the registry
>   (`localhost:5000` by default; set `RESOLUTO_SANDBOX_REGISTRY` for k8s or a shared registry).
> - The examples reference the **registry-qualified** tag (`localhost:5000/resoluto-sandbox:…`, via
>   `images.pullable()`), and the backend **pulls it on demand** — `localhost` registries are
>   insecure/HTTP by default, so `nerdctl run` pulls with no extra flag.
>
> So after `image build` there is **nothing else to do** — the image is in the registry and the first
> `run` pulls it into the containerd (then it's cached). This mirrors how the base image already flows
> (`local-backend-up.sh` pushes it to the same registry). It's the exact mechanism `RESOLUTO_SANDBOX_IMAGE`
> uses — a `localhost:5000/…` reference.
>
> No registry available? Set `RESOLUTO_SANDBOX_REGISTRY=""` (build stays a bare tag) and transfer the
> image into the containerd directly instead:
> ```bash
> docker save resoluto-sandbox:openai-agents-0.17.7 \
>   | sudo "$RESOLUTO_LOCAL_NERDCTL" --address /run/resoluto-local/containerd/containerd.sock \
>       --namespace resoluto-local load
> ```

### Bring your own image

The registry bridge is **not** provider-specific — any image the backend can pull works, so **your own
Dockerfile is a first-class citizen**. Build it, publish it with `image push`, and pass the reference
as `image=` (Python) / `--image` (CLI):

```bash
cat > Dockerfile <<'DOCKER'
FROM localhost:5000/resoluto-sandbox:langchain-1.3.11     # or the base, or any image
RUN pip install --break-system-packages langchain-anthropic
DOCKER

docker build -t my-langchain-agent:1.0 .
resoluto-sandbox image push my-langchain-agent:1.0        # -> pushed localhost:5000/my-langchain-agent:1.0

resoluto-sandbox run --image localhost:5000/my-langchain-agent:1.0 -- python my_agent.py
```
```python
Sandbox(backend="local", image="localhost:5000/my-langchain-agent:1.0").run([...])
```

`image push` tags the local image for the configured registry (`RESOLUTO_SANDBOX_REGISTRY`, default
`localhost:5000`) and pushes it; an already registry-qualified tag is pushed as-is. Equivalently, skip
it by building straight to the registry tag (`docker build -t localhost:5000/my-agent:1.0 . && docker
push …`). Either way the backend pulls it on first `run`. (`FROM` a provider tag needs that image in
your Docker store first — `resoluto-sandbox image build --provider langchain` — or `FROM
localhost:5000/…` to pull the base from the registry.)

Verified end to end against the real Kata sandbox (all three: canary passes, workspace stages, the
script runs and reaches its auth check). `claude` and `openai` run against the plain prebuilt image;
`langchain` needs the one-line extended image from above (built as `my-langchain-anthropic:0.1.0` here):

```python
from resoluto.sandbox import Sandbox

# workspace="examples/payloads" stages that DIRECTORY'S CONTENTS at /workspace — argv paths are
# relative to that root, never prefixed with the directory again.
r = Sandbox(backend="local", image="my-langchain-anthropic:0.1.0").run(
    ["python3", "langchain_agent.py", "Say hello in five words"],
    workspace="examples/payloads",
    env={"ANTHROPIC_API_KEY": "..."},
)
print(r.output)
```

Swap the `image=` tag + example script + env var to switch providers — everything else is identical:

```python
Sandbox(backend="local", image="resoluto-sandbox:claude-agent-sdk-0.2.110").run(
    ["python3", "claude_agent.py", "Say hello in five words"], workspace="examples/payloads",
    env={"CLAUDE_CODE_OAUTH_TOKEN": "..."},   # never set ANTHROPIC_API_KEY alongside this
)
Sandbox(backend="local", image="resoluto-sandbox:openai-agents-0.17.7").run(
    ["python3", "openai_agent.py", "Say hello in five words"], workspace="examples/payloads",
    env={"OPENAI_API_KEY": "..."},
)
```

Or via the CLI (`--workspace` is REQUIRED to stage anything — without it `/workspace` is empty and
your script won't be found; run from the repo root so `.` stages `examples/` alongside it):

```bash
resoluto-sandbox run --workspace examples/payloads --image resoluto-sandbox:openai-agents-0.17.7 -- python3 openai_agent.py "hi"
```

On `k8s`, retag + push to your registry (`docs/backends.md`), then inject the same tag through
`SubstrateBackend(image=...)` — see [`docs/concepts.md`](https://github.com/DeepBlueCoding/resoluto-sandbox/blob/master/docs/concepts.md#k8s) for the full wiring.

---

## Status

| Feature | Status |
|---|---|
| `backend="local"` — Kata microVM via nerdctl + a dedicated containerd, runtime-managed per-run egress | **works today** (run `scripts/local-backend-up.sh`) |
| `backend="k8s"` — Kata microVM pod + object-store Conduit + NetworkPolicy egress | **works today** — needs a Kata cluster + store + kube context |
| `Conduit` + `LocalConduit`, `StdoutConduit`, `S3Conduit` (minio/S3) | **works today** |
| `GcsConduit` | **provided, unverified** — experimental |
| Language-neutral wire spec | **published** — see `spec/PROTOCOL.md` |
| Prebuilt image matrix + `image build` CLI | **works today** — `resoluto-sandbox image build --provider claude\|langchain\|openai\|all` |

---

## The Resoluto ecosystem

Three independent, plug-and-play packages under one `resoluto.*` namespace and one design language. Each stands alone — use any without the others.

| Package | Role |
|---------|------|
| [resoluto-sandbox](https://deepbluecoding.github.io/resoluto-sandbox/) | Store-mediated, Kata-isolated execution substrate |
| [resoluto-agent](https://deepbluecoding.github.io/resoluto-agent/) | Pluggable LLM agent-provider plugins |
| [resoluto-engine](https://deepbluecoding.github.io/resoluto-engine/) | Durable orchestrator of sandboxed, gate-verified agentic work |

`resoluto-engine` builds on `resoluto-agent` (the provider contract) and optionally `resoluto-sandbox` (isolation); `resoluto-sandbox` and `resoluto-agent` depend on nothing else in the ecosystem.

---

## Further reading

- `docs/concepts.md` — the program contract, the run lifecycle, the Conduit
- `docs/backends.md` — backend setup + the vendor-neutral k8s stack install
- `docs/networking.md` — egress isolation (the canary + per-backend enforcement)
- `docs/auth.md` — credentials: passing each provider's secret to the guest (the subscription path is local-dev only)
- `spec/PROTOCOL.md` — the language-neutral host ↔ sandbox wire protocol
- `examples/` — `run_agent_in_sandbox.py <claude|langchain|openai>` runs any provider's agent isolated
  (symmetric across all three images); `run_hello_in_sandbox.py` is the bare mechanics. `payloads/`
  holds the plain programs run inside (`hello.py`, `claude_agent.py`, `langchain_agent.py`,
  `openai_agent.py`, one per prebuilt provider image). See [`examples/README.md`](https://github.com/DeepBlueCoding/resoluto-sandbox/blob/master/examples/README.md).
