Metadata-Version: 2.4
Name: maf-sandbox
Version: 0.12.0
Summary: One seam between an application and any sandbox provider, for Microsoft Agent Framework — reference implementation of agent-framework#7568: backend protocol, selection against a minimum-isolation floor, and a match against the capabilities a workload declares it needs.
Keywords: agent-framework,microsoft-agent-framework,sandbox,isolation,ai-agents
Author: SOKOLAI BV
Author-email: SOKOLAI BV <info@sokolai.com>
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Typing :: Typed
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Dist: agent-framework-core>=1.13.0,<2
Requires-Python: >=3.12, <3.15
Project-URL: Homepage, https://www.sokol.ai
Project-URL: Source, https://github.com/sokolaidev/maf-extensions
Project-URL: Issues, https://github.com/sokolaidev/maf-extensions/issues
Description-Content-Type: text/markdown

# maf-sandbox

[![PyPI](https://img.shields.io/pypi/v/maf-sandbox)](https://pypi.org/project/maf-sandbox/) [![Python](https://img.shields.io/pypi/pyversions/maf-sandbox)](https://pypi.org/project/maf-sandbox/) [![License](https://img.shields.io/badge/license-MIT-green)](https://github.com/sokolaidev/maf-extensions/blob/main/LICENSE)

> **Experimental.** This package is early-stage (pre-1.0, `Development Status :: 4 - Beta`) — its API may change or be removed in a future release without notice. Importing it emits a one-time `MafSandboxExperimentalWarning`; suppress it with `warnings.filterwarnings("ignore", category=maf_sandbox.MafSandboxExperimentalWarning)` once you've read the notice.

This package is not affiliated with, endorsed by, or a product of Microsoft — it is a third-party reference implementation of [microsoft/agent-framework#7568](https://github.com/microsoft/agent-framework/issues/7568), written for use with [Microsoft Agent Framework](https://aka.ms/AgentFramework) but with no dependency on it in its protocol layer.

## Quickstart

```bash
pip install maf-sandbox
```

```python
from maf_sandbox import Isolation, SandboxKey, SandboxRouter, SandboxSpec, CallerContext

# Implement SandboxBackend against your own provider — or install maf-sandbox-acas for a
# ready-made Azure Container Apps Sandboxes backend — then wire it into a router. Configuring
# nothing gets the production posture (the default floor is Isolation.MICROVM); a developer
# machine opts down explicitly:
router = SandboxRouter([my_backend], min_isolation=Isolation.CONTAINER)
sandbox = await router.acquire(SandboxKey(scope="tenant-1", thread_id="t-1", agent_dir="devops"), SandboxSpec(kind="bicep", image="bicep-sandbox:0.46.1", egress_allow=("mcr.microsoft.com",), work_dir="/workspace"))
```

This snippet never calls `ensure_can_serve` (below) and is checked anyway: `acquire` runs the same floor, capability and egress refusals itself before it ever reaches the backend, so the only thing calling `ensure_can_serve` first buys you is the closed-egress-vs-allowlist-spec warning, which `acquire` deliberately stays silent about.

[`samples/01_acas_bicep`](https://github.com/sokolaidev/maf-extensions/tree/main/samples/01_acas_bicep) is that wiring as a runnable program, including the part no snippet shows well: building the `CallerContext` out of callables rather than values, which is what keeps a `SandboxKey` a property of the host's request.

## Threat model

This package draws no isolation boundary itself — it is protocol and policy over whatever a `SandboxBackend` implementation actually provides. `Isolation` is a six-rung ladder a backend declares itself onto, weakest to strongest: `process` (no boundary at all — same process as the host), `runtime` (a software boundary inside the host process, e.g. a restricted interpreter or a WASM runtime's fault isolation), `container` (shared-kernel namespaces and cgroups), `hardened_container` (syscall interception in a userspace kernel — gVisor-class), `microvm` (a hypervisor boundary with a minimal or absent guest OS and no ambient identity reachable from inside — the default floor), and `vm` (a dedicated, full VM provisioned for the workload). `SandboxRouter` enforces the checks below on top of that declaration; the package's job is to make an unsafe backend selection fail loudly at construction or attach, not silently at first use. Beyond backend selection this layer holds no credentials, executes nothing and reaches no network, and everything security-relevant about a *specific* sandbox lives in the backend that implements it. It has exactly one boundary of its own, and it is on the way out rather than in: `make_file_system_sink` writes guest-produced bytes under a host directory, so it resolves each destination and refuses one that leaves that directory — see *Getting files back* below, which is also where a host landing somewhere other than a filesystem is told it owns the same question.

## The vocabulary

| | |
|---|---|
| `SandboxKey` | `(scope, thread_id, agent_dir)` — the one sandbox a caller may reach |
| `SandboxSpec` | what a sandbox of a given *kind* needs: image, egress allowlist, work dir, `requires` capabilities, and an optional `min_isolation` that may raise the host's floor |
| `Sandbox` | `write_file` + `exec` — all a workload gets |
| `SandboxBackend` | `acquire` / `dispose` / `dispose_scope`, plus the `isolation`, `egress` and `capabilities` it declares |
| `SandboxRouter` | picks the backend, enforces the minimum-isolation floor, the capability match, and the egress rule |
| `SandboxPurger` | duck-typed `purge_scoped_thread(scope, thread_id)` for a host's delete path |

`Isolation`, weakest to strongest: `process < runtime < container < hardened_container < microvm < vm`. `SandboxRouter`'s default `min_isolation` is `microvm`; an unrecognised rung refuses rather than guesses which side of the floor it falls on.

`SandboxKey`'s scope and thread come from the host's request context through `CallerContext`, whose fields are **callables read at call time** rather than values. That is deliberate: a key a caller can supply is a key a *model* can supply, and that would let one conversation address another's sandbox.

`SandboxSpec.egress_allow` is an allowlist — everything not named is denied, so an empty tuple means no network. Stating it positively means a spec that forgets to mention egress gets the closed configuration rather than the open one.

## Two axes, three checks that are not conveniences

```python
router = SandboxRouter(backends)                                   # default floor: Isolation.MICROVM
router = SandboxRouter(backends, min_isolation=Isolation.VM)       # stricter: dedicated full-VM only
router = SandboxRouter(backends, min_isolation=Isolation.PROCESS)  # a developer machine, opted down
```

**1. The minimum-isolation floor.** A backend declares its own `isolation`, ranked on the ladder above. The router refuses, at construction, any backend below `min_isolation` — or one whose declared value is not a rung this package recognises, because nothing here can tell whether an unrecognised boundary is stronger or weaker than the floor. A spec may also carry its own `min_isolation`; the effective floor is the *stricter* of the host's and the spec's — a spec may raise the floor for itself and never lower it.

It refuses rather than degrades. Falling back to a stronger backend would hide a misconfiguration; proceeding with the weaker one would break claims the host's security posture makes about every execution surface. Neither is better than an error.

**2. The capability match.** A backend declares `capabilities: frozenset[Capability]` (`EXEC`, `RUN_CODE`, `HOST_TOOLS`, `FILES_IN`, `FILES_OUT`, `FILES_LIST`, `NETWORK`, `SNAPSHOT`, `ATTACHED_IDENTITY`) — what it can actually do — and a spec declares `requires`, what its workload cannot run without. `ensure_can_serve(spec)` raises `SandboxCapabilityNotSupported` when the backend is missing something the spec requires. Unlike the floor, silence here is a functionality claim rather than a safety one: an undeclared `capabilities` reads as exactly `DEFAULT_CAPABILITIES = {EXEC, FILES_IN}` — what this package's own `Sandbox` protocol already obligates, so a backend written before `Capability` existed does not have to start lying to keep working.

**3. The egress rule**, unchanged in substance. `egress_allow` was a contract nothing checked, so a backend that reads it and one that ignores it have the same type, the same methods and the same passing tests — each one declares an `Egress` level instead: `allowlist` (deny by default, allow the named hosts), `closed` (all or nothing), or `unrestricted` (cannot confine egress at all). `ensure_can_serve(spec)` refuses the last one. Here silence is *not* read charitably: an undeclared `egress` is treated as `unrestricted` and refused, because a backend written before the property existed cannot have been enforcing an allowlist it never read.

Which direction a backend misses egress by decides the outcome, and it is not symmetrical. A backend that confines **less** than the spec asks silently widens what the workload was designed to reach — refused. One that confines **more** is permitted, with a warning: the sandbox reaches nothing it should not, and the workload fails visibly at whatever it could not fetch.

Note that the checks answer to different owners. How strong the boundary must be *here* is the *host's* policy, read from `min_isolation` — and a spec may raise that floor for itself, never lower it. What a sandbox may reach, and what it must be able to do, are properties of the *workload*, stated in its spec. Keeping the axes apart is deliberate: merging isolation into a "required capabilities" list would let a workload ask for a weaker boundary than the deployment mandates.

`ensure_can_serve` is also the whole of a wiring test, in your own repository, against your own backend choice:

```python
router.ensure_can_serve(bicep_sandbox_spec())
```

## Getting files back — the declaration, and where it lands

A workload's only return channel used to be `ExecResult.stdout`, which is right for a diagnostic and wrong for a rendered image. `Capability.FILES_OUT` is the pull surface, and it is narrow in two deliberate ways: this library never *discovers* what a workload produced, and it never decides where the bytes go.

**Declare it.** A `DeclaredOutput` names one artifact as a literal path relative to `work_dir`, in `SandboxSpec.declared_outputs`. Literal rather than a glob: resolving a pattern means enumerating a directory, which is the primitive `Capability.FILES_LIST` exists to gate, so a kind that cannot name its outputs in advance requires *that* capability and a backend serving only `FILES_OUT` refuses it. `media_type` is declared rather than sniffed, because sniffing lets guest-produced content decide how the host handles it. `required=False` is how a workload says an absence is normal — a renderer exiting non-zero produces no file, and the model needs that diagnostic rather than a transfer error stacked on top of it. `name` is the spelling the artifact *lands* under and defaults to `path`; the two come apart as soon as a kind writes into a per-call directory, which warm sandbox reuse forces on any kind whose outputs would otherwise persist into the next round.

`disposition` keeps the two flows apart because they answer to different legs of a host's policy: `LAND` goes to the sink and the question is confidentiality, while `CONSUME` is parsed by the kind that asked for it and the question is integrity. A `CONSUME` output is still counted against every cap — `files_out` bounds the collection the spec declared, not the subset of it that lands.

**Receive it.** `await collect_outputs(sandbox, spec, sink=...)` returns `LandedArtifact`s in declaration order. The order of its phases is part of the contract rather than an implementation detail: everything the declaration alone decides — a sink for anything that lands, a valid name for every output, no two landing names that collide — is settled before the sandbox is touched, then every declared output is stat-ed and capped, then the landing ones are read, and only then is anything delivered. Delivery is a push nothing can take back, so a refusal arriving after the first `deliver` could not leave the host as it found it.

`spec.files_out` is a `TransferLimits` and all three of its fields are load-bearing: a byte ceiling alone does not bound a collection, since ten thousand files one byte under the per-file cap cost exactly what the cap was written to prevent. What comes back when a collection does not fit is specific rather than generic — `SandboxTransferCapExceeded` names both the cap and the file that breached it, `SandboxOutputMissing` names a `required` output that was not there, `SandboxOutputSizeUnknown` is a backend that could not say how large something was, and `SandboxArtifactNameCollision` is two landing names that are one file at the destination: identical, or differing only by case or by Unicode form.

**Land it.** An `OutputSink` wraps a single `async def deliver(artifact) -> LandedArtifact`. This library holds no opinion about where an artifact goes — a directory, a blob container, a file store — which is what keeps that flow visible to the host's own information-flow policy instead of buried in a dependency. `LandedArtifact.display` is the one line the model is allowed to see; `handle` is the host's own reference, and nothing renders it into the transcript.

**`validate_artifact_name` is lexical, so a sink still has to confine its own destination.** It refuses `..`, absolute paths, backslashes and empty segments, so the *name* cannot traverse — which is not the same as safe, because it says nothing about what is already sitting at the path that name resolves to. A symlink in the output directory carries the write straight out of it: the same failure class as [#142](https://github.com/sokolaidev/maf-extensions/issues/142), on the host side of the boundary.

**`make_file_system_sink(root)` is that check, packaged.** It resolves each destination, refuses anything leaving `root` with `SandboxLandingNotConfined`, creates the parents a nested name needs, and writes. Reach for it rather than writing the four lines yourself — two samples here wrote them by hand and only one got it right. Pass `display` when the kind introduces its artifacts in its own words. It stays a check rather than a guarantee, and that is a property of the filesystem rather than of the helper: resolving and writing are two calls, so a host landing genuinely hostile output wants no-follow primitives underneath. What it closes is the standing case — something already in the way when the run started.

A sink landing somewhere that is *not* a filesystem — a blob container, a file store, a UI panel — writes its own `deliver` and owns the equivalent question for that destination.

```python
from pathlib import Path

from maf_sandbox import (
    Capability, DeclaredOutput, SandboxSpec, TransferLimits, collect_outputs,
    make_file_system_sink,
)

spec = SandboxSpec(
    kind="diagram",
    image="diagram-sandbox:1",
    egress_allow=(),
    work_dir="/workspace",
    requires=frozenset({Capability.EXEC, Capability.FILES_IN, Capability.FILES_OUT}),
    declared_outputs=(DeclaredOutput(path="diagram.png", media_type="image/png", required=False),),
    files_out=TransferLimits(max_bytes_per_file=8 * 1024 * 1024, max_total_bytes=16 * 1024 * 1024, max_files=4),
)

landed = await collect_outputs(sandbox, spec, sink=make_file_system_sink(Path("out")))
```

**Reclaim the sandboxes when the conversation ends.** `router.scope(scope, thread_id)` is an async context manager that calls `dispose_scope` however the block ends, and cannot mask an application error on its way out — `dispose_scope` already swallows and logs each backend's failure. Its own reason is why this is packaged rather than left to every host to remember: *a sandbox nobody reclaims is a sandbox somebody pays for.*

```python
async with router.scope(scope, thread_id) as reclaimed:
    ...                                    # attach tools, run the turn
print(f"Disposed {reclaimed.disposed} sandbox(es).")   # the count arrives after the block
```

A workload whose artifact names are not knowable when its tool is built passes the same `DeclaredOutput` type to `collect_outputs(outputs=...)` instead. That is refused unless the spec sets `outputs_named_at_call_time`: without the flag, the tool was attached with no sink required of it and no outbound cap agreed, and collecting there would land artifacts behind both checks.

[`samples/08_docker_codeact_files`](https://github.com/sokolaidev/maf-extensions/tree/main/samples/08_docker_codeact_files) is all of the above as a runnable program, against a real engine.

## Host tools — the contract before the capability

`Capability.HOST_TOOLS` is the one capability where trust crosses *outward*: a dispatched function body runs in the host process, with the host's privileges, driven by model-written code, and each dispatched call bypasses whatever middleware the host runs. No shipped backend declares it — what ships first is the safety contract, so it exists before anything can use it: `HostToolRegistry` starts empty (nothing is dispatchable until a developer registers it, and registering emits a one-time, suppressible `MafSandboxHostToolsWarning`); `@sandbox_tool(source=..., sink=..., identity=...)` makes the developer answer every information-flow leg with no defaults (`None` is an answer — "not that role"); a `require_declared` gate refuses unstamped functions at registration, which is the only place the declaration is ever read — `register` captures it, `HostToolRegistry.aggregate()` seals the registry as it derives policy from it, and a stamp swapped or removed afterwards reaches nothing; each run is bounded by a dispatch cap (`DEFAULT_MAX_DISPATCHES_PER_RUN`, refusals included) and by response size caps that reuse `TransferLimits`; arguments are validated host-side at the registry's one door, never in a guest shim; and a host whose posture wants a hard stop rather than awareness passes `denied_capabilities={Capability.HOST_TOOLS}` or `denied_identities={Identity.USER}` to its router.

One sentence to read before registering anything, because a declaration reads like a control and is not one: **`Identity.APP` is not the safe option, only the declared one.** It is the application's full authority, and the only real bounds on it are the emptiness of the registry and the dispatch cap — least privilege for dispatched tools comes from what a host registers, never from what it declares. `Identity.USER` is declarable but not servable: registering such a tool raises the whole surface to approval-gated, and dispatching one is refused with the prerequisites named.

## Upgrading to 0.11

`0.11.0` retired the word `workspace` from the public vocabulary. It was carrying three unrelated things, and only one of them keeps the stem. Two edits, both mechanical.

**`WorkspaceContext` is `CallerContext`, and `make_workspace_context` is `make_caller_context`.** The type was never a storage concept: two of its three fields are identity, and `list_files` *receives* a store rather than holding one. Its first parameter is now `list_files` where it was `store_walker` — a positional call needs no edit, a keyword one does.

**`work_dir` and `working_directory` are unchanged.** They name the guest's working directory, they are the most common use of the stem by an order of magnitude, and they were never the concept being retired. If you were looking for a rename here, there isn't one.

The dependent packages moved with it: `maf-sandbox-bicep` and `maf-sandbox-codeact` take `file_store` where they took `workspace_store`, and each has its own note.

## Upgrading from 0.4.x

`0.5.0` replaced the `deployed` boolean with a declared isolation floor, and added a capability axis. Four changes need an edit; nothing else moves.

**`SandboxRouter(..., deployed=...)` is gone — pass `min_isolation` instead.** `deployed=True` becomes `min_isolation=Isolation.MICROVM`, which is also the default, so a deployed host can drop the argument entirely. `deployed=False` on a developer machine becomes the rung that host actually accepts, stated explicitly — `min_isolation=Isolation.CONTAINER` for a container backend, `Isolation.PROCESS` for an in-process fake. There is no longer a value meaning "anything goes": a host that wants the weakest rung names it.

**`DEPLOYED_ISOLATION` is removed.** The policy it expressed is `min_isolation`'s default.

**`Isolation` and `Egress` are `StrEnum`s, and the ladder grew.** Values are unchanged, so `backend.isolation == "vm"` and any stored configuration keep working. The ladder is now `process < runtime < container < hardened_container < microvm < vm`; a declared value outside it is refused at construction rather than silently permitted.

**`AcasSandboxBackend` now declares `microvm`, not `vm`.** ACA Sandboxes are hardware-isolated micro-VMs; `vm` now means a dedicated, full VM on remote infrastructure. A host that pinned `min_isolation=Isolation.VM` expecting ACA Sandboxes to satisfy it should use `Isolation.MICROVM` — the default, and the rung the micro-VM standard defines.

Backends need no edit to keep working: one that declares no `capabilities` is read as declaring `DEFAULT_CAPABILITIES` (`exec` + `files_in`), which is what the `Sandbox` protocol already obliges. Declare a wider set to serve workloads that require more.

## Writing a backend

Implement `name`, `isolation`, `egress`, `acquire`, `dispose`, `dispose_scope`. `capabilities` is optional. Four things worth knowing before you start:

**Declare `egress` honestly.** It is read before any workload's tool is attached, and a backend that omits it is treated as `unrestricted` and refused: one written before the property existed cannot have been enforcing an allowlist it never read, so silence is read as enforcing nothing rather than excused.

**`capabilities` is optional, and silence is the opposite of `egress`'s.** Omitting it reads as `DEFAULT_CAPABILITIES = {EXEC, FILES_IN}`, so a backend that has always offered exec and file-write does not need to add the property to keep working — only declare it once you offer more, or less.

**`acquire` is get-or-create.** A workload's fix-round loop calls it every iteration; returning a cold sandbox each time turns a seconds-long loop into a minutes-long one.

**`dispose_scope` must not consult only your process's memory.** A multi-replica host serves a conversation delete wherever it lands, so the replica that created the sandbox is usually not the one deleting it. Derive the set from the service — labels, a listing, whatever your provider offers. A backend that skips this leaves billable compute running and the bug is invisible on a single-replica dev box.

Both `dispose` methods are best-effort by contract: purge must never fail a delete.

**If you serve `FILES_OUT`, do not write the confinement walk yourself.** A path whose *parent* is a link satisfies every lexical test and still reads outside, and two backends written independently against the prose shipped that same escape ([#142](https://github.com/sokolaidev/maf-extensions/issues/142)). `maf_sandbox.paths.refuse_symlinked_parents` is that walk, taking your own stat — which must be **unconfined** (it covers the work dir's own ancestors) and **no-follow** (a stat that resolves a link describes its target and hides the escape).

Then check yourself against `maf_sandbox.conformance`, **on a real instance**: probes that plant a hostile layout through your public surface and attack it. Fill in a `ConformanceSubject` — a sandbox, a working directory, and how your guest plants a file and a link, with `PosixGuestSubject` covering a Linux guest that has `ln` — and `await assert_files_out_conformance(subject)`. It imports no test framework, and a failure names every probe that failed rather than the first.

## Provenance

Extracted from a production agent application, where this seam was written for its first execution surface: a tool that compiles agent-authored infrastructure code in a sandbox. The minimum-isolation floor above is not a preference — it is what a security review concluded when it worked through what a shared-kernel boundary does *not* close for code an agent wrote.

---

Maintained by [SOKOLAI BV](https://www.sokol.ai).
