Metadata-Version: 2.4
Name: maf-sandbox-acas
Version: 0.25.0
Summary: Azure Container Apps Sandboxes as a sandbox backend for Microsoft Agent Framework agents.
Keywords: azure,container-apps,sandbox,agent-framework,microsoft-agent-framework,isolation
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 :: Security
Requires-Dist: maf-sandbox>=0.40.0,<0.41
Requires-Dist: azure-core>=1.41.0,<1.42
Requires-Dist: azure-identity>=1.25.1,<2
Requires-Dist: azure-containerapps-sandbox>=0.1.0b4,<0.1.0b5
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-acas

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

> **Renamed.** This package was published as `maf-sandbox-aca` up to `0.2.3`. ACA is Azure Container *Apps*, the broad service, while this backend targets Azure Container Apps **Sandboxes** — so it gained the `s` the rest of the project already used. PyPI names cannot be reused, so this is a new distribution rather than a continuation, and there is no compatibility shim: `maf_sandbox_aca` and the `Aca…` classes do not forward here.

> **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 `MafSandboxAcasExperimentalWarning`; suppress it with `warnings.filterwarnings("ignore", category=maf_sandbox_acas.MafSandboxAcasExperimentalWarning)` once you've read the notice.

This package is not affiliated with, endorsed by, or a product of Microsoft — it is a third-party package for [Microsoft Agent Framework](https://aka.ms/AgentFramework), built on the [Azure Container Apps Sandboxes](https://learn.microsoft.com/azure/container-apps/sandboxes-overview) preview.

```
app  ->  maf_sandbox  ->  maf_sandbox_acas  ->  the sandbox
```

An agent that writes code should not be the thing that runs it. This package gives it somewhere else to run: a microVM-isolated sandbox with Deny-default egress and optional identity configured on its Azure sandbox group, reached as an ordinary tool call so the agent framework's middleware still sees the call and classifies its result — only the *work* leaves the process.

This package is the backend only, with no sandbox kind of its own. [`maf-sandbox-bicep`](https://github.com/sokolaidev/maf-extensions/tree/main/packages/maf-sandbox-bicep) is the first kind that runs on it, written against [`maf-sandbox`](https://github.com/sokolaidev/maf-extensions/tree/main/packages/maf-sandbox)'s protocol rather than against this backend.

For workloads requiring `EXEC` or any `FILES_*` capability, `acquire` ensures the bound storage base exists, including on warm reuse. `spec.work_dir=None` lets this backend allocate `/maf-sandbox/work`; an explicit value requires that exact guest-native base. Relative working directories resolve beneath it, with `"."` naming the base; commands and argv remain untouched. Existing directories retain their contents, ownership and modes; an unreadable path, a symlink or a non-directory fails acquire. This guarantees the base's existence on return, not additional guest permissions or the creation of per-call children. Runtime-only workloads require no directory. Missing parents are created through the SDK's data-plane `mkdir`, without a guest command. Ownership follows the service's file plane; its documented concurrent-redirection residual also applies to creation.

## Quickstart

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

```python
from maf_sandbox_acas import AcasSandboxBackend, AcasSandboxConfig
from maf_sandbox import SandboxRouter

backend = AcasSandboxBackend(AcasSandboxConfig(endpoint="https://management.<region>.azuredevcompute.io", subscription_id="<sub-id>", resource_group="<rg>", sandbox_group="<group>", registry="<acr>.azurecr.io"))
router = SandboxRouter([backend])  # microVM isolation meets the router's default floor
```

[`samples/01_acas_bicep`](https://github.com/sokolaidev/maf-extensions/tree/main/samples/01_acas_bicep) runs that pair end to end: the same two lines, plus the caller context and the workload tool they exist to serve, in a program that validates a Bicep file and disposes the sandbox afterwards.

`azure-containerapps-sandbox` — the data-plane SDK this backend calls — is a hard dependency pinned to the tested `0.1.0b4` preview. The backend uses private SDK surfaces for bounded exec responses and complete file metadata, so a later preview is admitted only after compatibility and live conformance are verified. Authentication defaults to `DefaultAzureCredential`; see [Azure Identity's docs](https://learn.microsoft.com/python/api/overview/azure/identity-readme) for how it resolves credentials in your environment.

## Host-selected credentials

**Breaking shutdown change:** `aclose()` raises `AcasClientCloseError` for incomplete SDK cleanup instead of logging and suppressing close failures. Handle that exception in the host's teardown policy and keep owner loops running until closure completes. Closing permanently refuses new work; create a new backend to resume service.

Set `AcasSandboxConfig.credential_resolver` to an async host callback returning `AcasCredentialBinding(authority, generation, create_credential)`. The callback receives an `AcasCredentialRequest` naming `acquire`, `dispose` or `dispose_scope` and its trusted scope/thread/key. Acquire may select an exchanged request grant; disposal must independently recover an authorized cleanup grant on any host replica. A custom resolver failure never falls back to the default identity. These credentials stay in the host and do not enable guest-attached identity.

Each binding's factory creates a fresh async Azure credential on its owning loop; shared singleton credentials are unsupported. The backend owns closure. Equal authority/generation references assert equivalent grants; new generations isolate authentication state. Acquired wrappers keep their captured grant and borrow clients per operation, including polling and streaming. Idle eviction does not delete sandboxes or invalidate warm wrappers.

`max_clients_per_loop=32`, `client_wait_seconds=30` and `client_close_seconds=30` bound cache capacity, resolver/client waits and shutdown. Capacity multiplies across host replicas and event loops. Call `await backend.aclose()` before stopping owner loops: it drains operations, permanently refuses new leases, and raises `AcasClientCloseError` if cleanup remains incomplete. It closes SDK resources, not sandboxes. See the [credential contract and host wiring example](https://github.com/sokolaidev/maf-extensions/blob/main/docs/sandbox/acas-credentials.md) for cleanup principals, grant recovery, rotation and failure behavior. Live service acceptance of a particular delegated token remains deployment-specific and unverified by the offline tests.

## Threat model

**The micro-VM boundary.** `AcasSandboxBackend` declares `Isolation.MICROVM`: execution happens in a hardware-isolated microVM, not a shared-kernel container, and that rung is `maf-sandbox`'s router's default floor — a host that configures nothing already permits this backend (see that package's README). Everything below this line assumes that boundary holds; it is a property of the Azure Container Apps Sandboxes service, not of this package's code.

**What identity is reachable.** ACAS supports managed identity configured on the sandbox group. The host owns the group's identity assignments, permissions and intended sharing; this backend trusts that configuration without ARM assignment reads or a management-read permission requirement. The host-selected control-plane credential remains in the host. Guest identity surfaces depend on deployment. The router does not apply core attached-identity opt-in or bounds to that group configuration, so the host routes workloads to groups with the intended authority. Lifecycle intervals are applied separately by the backend to each new sandbox; [the lifecycle contract](https://github.com/sokolaidev/maf-extensions/blob/main/docs/sandbox/backends/acas.md#lifecycle) describes configuration failures and retention limits. `dispose_scope` deletes sandboxes by service-side label, not the shared group principal, and makes no claim to revoke previously issued tokens.

## The backend

Acquire checks byte capture for both `EXEC` and `HOST_TOOLS`: working `sh`, `mkdir`, `mkfifo`, `head`, `cat`, `wc`, `dd`, `base64`, `rm` and `rmdir`, plus writable `/tmp`. `HOST_TOOLS` also requires `mv` and `nohup`. These checks accompany the existing observed-removal gate. Missing prerequisites raise `SandboxCapabilityNotSupported`; successful command checks are cached with that sandbox, and failed checks are retryable. A failed capture probe invalidates and attempts to dispose the sandbox. The interpreter remains the workload's choice, and `setsid` stays optional. See the [image command contract](https://github.com/sokolaidev/maf-extensions/blob/main/docs/sandbox/guest-platform-and-commands.md#decision-3--a-static-ceiling-matched-at-attach-and-a-probe-at-acquire).

`AcasSandboxBackend` implements `maf_sandbox.SandboxBackend`:

| | |
|---|---|
| `acquire(key, spec)` | get-or-create, keyed `(scope, thread, agent, call, kind)`. Equivalent egress policies reuse a warm sandbox; changed hosts or mode on a usable instance raise `AcasEgressPolicyConflict`. Dispose the kind before changing policy, or use another key. At `IsolationScope.CONVERSATION` the key's `call_id` is empty and one sandbox serves the conversation's calls; at `IsolationScope.CALL` it names the tool call, so no acquire repeats it and get-or-create finds nothing warm. |
| `dispose(key, *, kind=None)` | Deletes the selected kind, or every kind when omitted; retained failures keep their kind for retries; reaches sandboxes known to this process |
| `dispose_scope(scope, thread)` | delete every sandbox for a conversation — **from the service, by label**, not from process memory; report an incomplete purge if a local acquire is active, and refuse new local acquires during the purge |
| `stat_file` / `read_file` / `list_dir` | native file operations with path checks that are not held through the operation; concurrent swaps can redirect bytes or metadata outside `working_directory`. Reads refuse classified symlinks/directories and over-cap bytes; stat describes a final symlink. See the residual below |
| `isolation` | `microvm` — the router's default floor, so a host that configures nothing already permits this backend |
| `declarations.capabilities` | `EXEC, FILES_IN, FILES_OUT, FILES_LIST, FILES_DELETE, HOST_TOOLS` are a ceiling. `FILES_IN` always uses guest authority and requires guest write permission and transfer utilities. `acquire` withdraws `FILES_OUT` and `HOST_TOOLS` on a failed guest removal probe and withdraws `FILES_DELETE` unless the file plane confirms the guest removed the probe file |
| `declarations.limits` | the transfer ceilings a spec may not exceed, per direction |
| `declarations.os_families` | `{posix}` — a constant, because every sandbox the service boots is a Linux microVM |
| `declarations.isolation_scopes` | `{conversation, call}` — the service mints the id, so the key's `call_id` folds into the registry entry and the `call` label a disposal selects on; a spec asking for one sandbox per tool call is served rather than refused |

**Two image namespaces, and `spec.image` says which by whether it carries a tag.** The service prebuilds images and keeps them Ready for every sandbox group — `python-3.13`, `node-22`, `ubuntu` and a dozen more — and a spec reaches them by naming one, with **no registry and no tag**, because the version is part of the name. Anything else is the `repository:tag` the rest of this package is written around: qualified by the configured `registry` and resolved against the disk images this deployment imported with `scripts/import_disk_image.py`.

```python
codeact_sandbox_spec(image="python-3.13")           # the service already has it — import nothing
bicep_sandbox_spec(image="bicep-sandbox:0.46.1")    # yours, imported once, qualified by `registry`
```

The tag is what separates them, and it has to be: `bicep-sandbox:0.46.1` names no registry either, so a rule that looked only for a registry would swallow every deployment configuring an imported image the way `SandboxSpec` documents. A bare name the service does not have is refused before anything is created, with the catalogue in the message — the likely way to arrive at one is a forgotten tag, and the fix is then visible where the error is. `image_id` still skips both lookups, as the field promises.

Microsoft's docs call these *public images*, glossed as "prebuilt images available to all sandbox groups", in the same paragraph that calls Docker Hub a public registry. This package says **prebuilt** to keep those apart; the SDK spells them `list_public_disk_images()` and `begin_create_sandbox(disk=…)`.

`tests/test_acas_e2e.py` is the live suite, skipped unless `ACAS_SANDBOX_ENDPOINT` and `MAF_SANDBOX_ACAS_E2E_IMAGE` name a sandbox group and a guest image. It is what exercises the real data plane — the shared `FILES_OUT` conformance probes, the cap and confinement refusals, the read timeout that turns a fifo from a hang into a refusal, and teardown read back from the service rather than from this process's memory. It runs in `verify-live.yml`, not on a pull request, because every sandbox in it is billable. Most of it shares one sandbox; the prebuilt-image probes need a second, booted from `python-3.13` (override with `MAF_SANDBOX_ACAS_E2E_PREBUILT`), because a name from the catalogue is the thing they exist to prove boots.

**`Capability.FILES_LIST` as well as `FILES_OUT`, and this is the only backend that declares it.** The service enumerates a directory natively, which is the test the protocol's split applies — name the backend that lacks it. A kind whose output names are unpredictable is refused on Docker and wslc and served here.

**`Capability.HOST_TOOLS`, and what it claims is narrower than the others.** It is the only member of the vocabulary with no backend method behind it — the transport is composed by the *kind* out of `exec`, `write_file`, `stat_file` and `read_file`, all of which the capabilities above already cover. What this backend adds by declaring it is one property: **`exec` detaches.** A process started by one call outlives it and is still observable from the next, because the sandbox is a microVM the group keeps between calls, and `host_tool_calls_over_exec` is built on exactly that — its launcher returns at once and the appearance of the exit-code file is the run's only witness. That is measured against the service, not asserted: `TestWhetherThisBackendCouldServeHostTools` in the live suite watches the exit marker be absent when the launcher's `exec` returns and appear afterwards. It is **not** a claim about the image — the shipped launcher wants `sh`, `nohup`, `printf`, `mv`, `mkdir`, `rm` and `kill`, and `setsid` where the image has it, and a kind wants whatever interpreter it names, none of which this backend chooses.

**Guest removal compatibility is checked at acquire.** The file plane writes as root, while `exec` runs as the image's `USER`. The backend plants a probe file in a fresh root-owned directory under `/`, asks the guest to remove it with `rm`, and checks the result through the file plane. The file must be gone and its directory must remain. Cleanup runs through the file plane even on cancellation. Preparation, execution and observation share a 30-second timeout, followed by separately bounded cleanup. This checks the image's `rm`; it cannot establish the authority of ordinary workload code.

A completed removal failure refuses `FILES_OUT` and `HOST_TOOLS` and warns an `EXEC`-only workload, conservatively screening guests unable to write beside uploaded files. An inconclusive result serves that functional pair, but `FILES_DELETE` requires an observed removal. Each sandbox keeps its own compatibility result for warm reuse; transient failures are retried. An image-level hint can refuse before a create for 60 seconds from its completed probe; cached refusals do not extend that deadline. After expiry the next cold acquire creates and probes again, so a repaired catalogue image can recover without restarting the host. Every new sandbox that needs the probe must pass its own check. A warm sandbox retains its own verdict: dispose it to acquire from a repaired image, allowing any image hint to expire first.

**Every `remove` runs as the guest.** It executes `rm -f -- <path>` or `rm -rf -- <path>` over guest `exec`, then confirms absence through the file plane. A failed command or an entry still present raises `OSError`; command execution and observation are bounded by `read_timeout_seconds`. A missing path needs no command. The image controls `rm` and could supply a privileged wrapper that passes only the probe, so the probe never authorizes a host-plane delete. Path checks still refuse symlinked parents, but are not held across execution: a swapped parent can redirect removal within the guest's existing reach. `Sandbox.reclaim` raises `NotImplementedError` before any service call because safe ancestry for a host-authority delete cannot be established. ACAS withholds `RECLAIM` and `SNAPSHOT`, so router-managed cleanup disposes the sandbox. Direct callers must dispose it as well.

**`write_file` always runs as the guest.** Every call uses `maf_sandbox.write_file_over_exec`; no acquire-time probe or guest output can select the host file API. This holds on cold acquisition, first `FILES_IN` use after warm reuse, and alternate absolute working directories. A parent swapped after the path check may redirect placement within the guest's existing reach; guest authority does not make the check and write atomic or confine a root guest.

**Compatibility change:** writes previously served by the host fallback now fail if the guest cannot perform them. A non-root guest cannot write into a service-created root-owned `0755` base. Supply `sh`, `mkdir`, `mv`, `rm`, `base64` and the bounded-exec capture commands, plus an existing guest-writable directory selected by `SandboxSpec.work_dir`. Bake that directory into the image or create it through guest execution under a writable parent; acquisition may create a missing base as root even below `/tmp`. An alternate writable absolute working directory can accept writes even when the storage base is protected. `FILES_IN` remains available, with permission checked by the actual guest write. A completed permission refusal raises `PermissionError`; missing utilities fail the transfer or execution, with no host retry. The [backend guide](https://github.com/sokolaidev/maf-extensions/blob/main/docs/sandbox/backends/acas.md#write_file-always-runs-as-the-guest) records the policy and upstream directory-ownership request.

**The road's cost is per chunk.** It runs two control commands plus one for every 48 KiB of content, so a file under that costs three guest commands (0.44 s against the plane's 0.21 s, measured) and one at the declared 32 MiB `files_in` ceiling costs 685, sequentially; `read_timeout_seconds` bounds the whole transfer, so a large file on a slow link is refused where the plane would have taken it. A deadline that expires *between* commands takes the staged sibling back and raises with the sandbox whole, because nothing was running. A deadline reached while the Azure SDK sleeps on an HTTP 429 `Retry-After` also leaves the sandbox whole: the throttled request answered and no retry started. A command that fails *in flight* or receives another retryable status invalidates and disposes it the way any `exec` failure does, and direct cancellation still disposes it.

**`declarations.os_families` is `{posix}`, and it is stated rather than read.** A workload names the guest shape its commands and scripts are written for in `SandboxSpec.requires_os_family`, and the router refuses a backend whose `os_families` does not hold it. Every sandbox this backend hands out is a Linux microVM — from the prebuilt catalogue or from a disk image imported into the group, since the service boots nothing else — so there is no daemon to ask and nothing to probe inside the guest, the way [`maf-sandbox-docker`](https://pypi.org/project/maf-sandbox-docker/) has to. The declaration is what `exec`'s `shlex.join` quoting and this package's `posixpath` path arithmetic already rest on. What it changes is one direction only: an undeclared `os_families` is the empty set, which refuses *every* spec that names a family, so a `posix` workload this backend could always have run was turned away at attach. A `windows` one is still refused here, as it should be — a backend that hands out Windows guests declares them and is matched instead.

**Only regular files are read, and the refusal happens at stat time.** This backend's read *follows* symlinks: a path linking to `/etc/hostname` returns that file's contents, so classifying after the bytes come back would be too late. The type comes from the data-plane payload's `isSymlink` and `isDir` flags, read raw — the SDK's typed `FileInfo` exposes neither, and a payload missing them is refused as `AcasEntryPayloadIncomplete`, never assumed regular.

**What the type check cannot prove.** `isDir` and `isSymlink` establish that an entry is *neither* of those; they do not establish that it is a regular file, and `mode` is permission bits with the type stripped. A FIFO is reported identically to an empty regular file and is classified `FILE` — and reading one never returns, so `read_timeout_seconds` bounds it and a hang becomes a refusal rather than a held-open turn.

**Every path component is checked, not just the last one.** A guest that points `out` at `/etc` gets a stat of `out/hostname` that says "regular file, 12 bytes" — the parent link is invisible there — so `stat_file`, `read_file` and `list_dir` stat every parent component from the **filesystem root** down, not from the working directory, whose own ancestors the guest can replace just as easily: with `/maf-sandbox -> /` unchecked, `/maf-sandbox/work` stats as a real directory and serves `/`. A link anywhere among those ancestors is refused as an escape; any other non-directory is an ordinary `ENOTDIR`. The check is `maf_sandbox.paths.refuse_symlinked_ancestors`, shared with every other backend serving `FILES_OUT`; what this package supplies is the unconfined, no-follow stat it runs on. At the **final** component, `stat_file` describes a link as `SYMLINK`, while `read_file` and `list_dir` refuse a classified link. These checks do not hold the filesystem state: a later swap can redirect a parent for all three methods, the final file for a read, or the listed directory. See the [per-method residual table](#native-reads-retain-a-confinement-residual).

That `dispose_scope` detail is the one worth reading twice. A multi-replica host serves a conversation delete wherever it lands, so the replica that created a sandbox is usually not the one deleting it. A backend that consults only its own registry leaves billable sandboxes running, and the bug is invisible on a single-replica dev box. Sandboxes are labelled at create time so the service can answer the question instead.

Cleanup admission also applies to direct backend callers. Acquire raises `SandboxOutputError` while retained deletion remains unsuccessful or scope purge is active. If an acquire for that scope and thread is already active, scope purge returns an incomplete result with code `unknown`; retry purge after acquisition finishes. An admitted purge refuses new acquires for its scope and thread until all overlapping purges finish, including cancellation cleanup. Other scopes and threads remain available. Hosts must stop new work across replicas before conversation deletion because this barrier belongs to one backend object.

Egress comes from the **spec**, not from configuration: `default_action: Deny` plus one `Allow` rule per host the kind declares. A deployment that could widen a kind's egress could undo the containment its design rests on.

`AcasEgressPolicyConflict` subclasses `SandboxEgressNotEnforced`, so callers can distinguish a held-policy conflict from an unsupported mode while existing catches still work. Warm reuse compares the mode and case-insensitive host set with the policy used to create that sandbox. Acquisition for the same key and kind is serialized across event loops; unrelated keys and kinds can progress concurrently. Host order and equivalent spelling do not force a new sandbox. A mismatch refuses while retaining the original instance for its existing users and disposal; it never replaces a live instance automatically. Coordinate active calls, await `router.dispose_kind(key, spec.kind, timeout=60)` and require `True` before acquiring a changed policy, or choose a different key. Direct backend callers can await `backend.dispose(key, kind=spec.kind)` and require `None` (no disposal failure) before changing policy. A stale held record also requires explicit disposal; a failed resume would not prove the instance is gone. A capture-invalidated instance instead follows the deletion retry path, which must succeed before replacement under any policy. Acquire refuses if invalidation precedes its final guarded check, including during work-directory preparation; retry acquire to recover. Later invalidation can still dispose an accepted instance.

`EGRESS_METHODS` remains unsupported. The live service denies every verb a rule does not name, including custom ones, but redirects, rule precedence, wildcard overlap and the non-TLS path are unmeasured, so enforcement is not established. Both router matching and direct backend acquisition refuse method-scoped rules with `SandboxCapabilityNotSupported`. A GET-only rule also permits request content; it is not a body-free or read-only channel. The [live measurements](https://github.com/sokolaidev/maf-extensions/blob/main/docs/sandbox/research/acas-egress-methods.md) record the distinction.

## Native reads retain a confinement residual

`FILES_OUT` and `FILES_LIST` remain declared with native, host-authority operations. The path checks refuse links they observe; they do not hold the checked filesystem state until the operation finishes. The per-method guarantee is:

| Method | What is checked | What a concurrent swap can return |
| --- | --- | --- |
| `read_file` | Ancestors and the final entry; classified links and directories are refused | Outside bytes through a replaced parent or final file, including bytes the guest cannot read |
| `stat_file` | Ancestors; a final symlink is described as itself | Outside metadata through a replaced parent, labelled with the requested path |
| `list_dir` | Ancestors and the listed directory; returned children must have the requested prefix | Outside names and metadata through a replaced ancestor or directory, still bearing that prefix |

A successful removal-compatibility probe is not a read-authority guarantee, and the conservative non-root `FILES_OUT` refusal does not protect direct method calls. A root image removes the privilege difference, but a swap can still cross the caller's working directory. Byte caps, timeouts and disposal do not close that window.

The native route remains available to avoid adding shell or interpreter dependencies to file retrieval and enumeration, or withdrawing those capabilities from existing workloads. A host requiring a held filesystem boundary must choose a backend with that guarantee. Atomic confinement requires service-side support; [the measured contract and alternatives](https://github.com/sokolaidev/maf-extensions/blob/main/docs/sandbox/backends/acas.md#native-reads-retain-a-confinement-residual) record what is retained here. This is a documented residual, not a claim of atomicity.

## Upgrading to 0.15

**The four optional declarations moved into one `BackendDeclarations`.** `maf-sandbox` 0.26 replaced `capabilities`, `limits`, `egress_modes` and `os_families` as backend attributes with one `declarations` object holding them as fields, and this backend follows it. A host that read them off the backend gets an `AttributeError`:

| Was | Is |
| --- | --- |
| `backend.capabilities` | `backend.declarations.capabilities` |
| `backend.limits` | `backend.declarations.limits` |
| `backend.egress_modes` | `backend.declarations.egress_modes` |

Nothing about what this backend declares changed — the values, and how they are derived from the config, are exactly as they were. `maf-sandbox`'s own README carries the reasoning and what a backend author has to do.

## Extracting this package

It imports nothing from its host application — only `maf-sandbox` and `azure-*` — so moving it to its own repository is a file move plus a dependency line. `src/`, `tests/`, `scripts/` and `pyproject.toml` are already the future repo root.

`TestOnlyDeclaredDependencies` is what keeps that true: it scans this package's sources and fails on any import that is neither the standard library, this package itself, nor a distribution its own `pyproject.toml` declares. Nothing else would notice a stray one, because a workspace has every sibling already on the path — and an undeclared import is exactly what breaks a fresh `pip install` of the published wheel.

What stays behind is the host's adapter — a single module in the host application that maps the host's settings onto an `AcasSandboxConfig` and supplies the request context. Read it first if you want to know what integrating this package involves.

## Provenance

Extracted from a production agent application, where a security review chose a microVM-isolated sandbox over running agent-authored code in the host process. Both halves of that conclusion are visible in this backend's design: the boundary it declares, and the fact that no credential of the host's ever travels inside it.

---

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

## Exec bytes and text views

After complete output retrieval, a reported scratch-removal failure logs a warning naming the guest directory and returns the captured bytes and exit code. This includes a program that removes its own shell. Scratch may remain until sandbox disposal; no host-authority file operation removes it. Exceptions, timeouts, cancellation, and incomplete capture or retrieval still invalidate and attempt to dispose the sandbox.

`ExecResult.stdout_bytes` and `stderr_bytes` preserve returned program bytes; `stdout_text` and `stderr_text` (also `stdout` and `stderr`) are UTF-8 display views with replacement decoding. Use the byte fields for artifacts and byte counts, and the text views for model or JSON display. See the [output contract, ACAS prerequisites and release migration](https://github.com/sokolaidev/maf-extensions/blob/main/docs/sandbox/exec-output.md).
