Skip to content

.NET: [Feature]: Backend-neutral sandbox protocol for agent code execution, with a first-party Azure Container Apps Sandboxes backend #7568

Description

Description

What problem does it solve?

Code execution in Agent Framework today is a set of per-runtime tool classes: agent-framework-hyperlight and agent-framework-monty each define their own provider and *ExecuteCodeTool shapes, hosted code interpreters are provider-side, and there is no shared contract in core that a third execution environment can implement. The gap is visible from four independent directions in this repo's own tracker:

  • #7311 proposed a remote micro-VM CodeAct backend (Tenki) and had to mimic the Hyperlight/Monty shape by convention, because there is no protocol to implement — and agents that need a real OS (installs, CLIs, persistent files) still "have no CodeAct story" in-tree.
  • #6490 asks, for a Daytona-style remote HTTP sandbox in .NET: "Beyond Hyperlight, is there a plan for a more general sandbox / CodeAct provider abstraction for remote services?" — and what the recommended minimum scope for a community integration would be. Today the honest answer to both is "no, and it depends".
  • #7319 built a local-machine executor bridge and explicitly asks for "a more idiomatic extension point for 'this step must run on host hardware'" rather than bolting on their own seam.
  • #6475 (first-party) wants skill resources executed in sandboxes and starts from the observation "we have multiple ways of creating sandboxes that isolate code" — which is the fragmentation itself.

We hit the same wall building a production IaC-validation tool (agents author Bicep; a sandbox runs bicep build/bicep lint; the compiler's diagnostics come back as the tool result). The tool logic was ~400 lines; everything around it — a backend protocol, an Azure Container Apps Sandboxes backend, lifecycle binding, and the security wiring — was roughly three times that, and none of it is specific to our app. Every team integrating a remote sandbox today rebuilds exactly this layer, with the same bugs: the multi-replica sandbox leak, the model-supplied-key confused deputy, the command-string injection, the silent cost of never reusing a warm sandbox.

What would the expected behavior be?

Four pieces, in dependency order — the first is the ask, the rest build on it:

1. A small sandbox protocol in core. The contract that made our tool portable is five types: a Sandbox (write_file, execExecResult), a SandboxBackend (acquire/dispose/dispose_scope, get-or-create with warm reuse), a SandboxSpec (workload kind, image reference, egress allowlist where everything unlisted is denied — a spec that forgets egress gets the closed configuration, not the open one), a SandboxKey derived from the host's request context (never from model input — a model-supplied key would let one conversation address another's sandbox), and a declared Isolation level (vm / container / process) that a router checks against the deployment context, refusing shared-kernel isolation in deployed environments rather than degrading silently. A workload written against this runs unchanged on any backend; our Bicep tool contains no Azure import and a test enforces that. With this in place, #7311's Tenki, #6490's Daytona, and #7319's local-machine bridge are each one backend class, not a new tool surface — and each declares its true isolation instead of leaving it implicit.

1b. The protocol unifies the existing runtimes — Hyperlight and Monty are backends, not casualties. agent-framework-hyperlight (in-process hypervisor micro-VM) and agent-framework-monty (in-process interpreter) slot in as the low-latency end of the same seam, and they sharpen the protocol in two ways. First, operations are capabilities: an interpreter has no shell, a micro-VM running WASM has no apt, so the protocol's operations split into exec (shell) and run_code (language runtime), each backend implementing what it truthfully can and declaring it — which turns #7311's prose comparison table (installs / subprocesses / persistent filesystem / host tool callbacks) into machine-readable capability declarations the router matches a spec's requirements against, instead of every integrator re-deriving that table by trial. Host tool callbacks — the CodeAct pattern's differentiator, which Hyperlight and Monty have and remote services generally do not — become one such capability rather than a reason for parallel tool-class hierarchies. Second, containment is two axes: Hyperlight is in-process yet hypervisor-isolated, while Monty is nominally the weakest tier but has no filesystem and no network by construction — so the deployed-environment policy should read declared boundary strength together with the requested capability surface (a no-I/O interpreter evaluating pure Python is a different risk than a shell in a container), rather than a single isolation ladder. The convergence is already visible in-tree: Hyperlight's AllowedDomain and both packages' FileMount are the same concepts our ACA integration independently built as the spec's egress allowlist and sandbox file writes — three integrations arriving at the same nouns is the strongest evidence they belong in a shared protocol.

2. A first-party Azure Container Apps Sandboxes backend. ACA Sandboxes are Microsoft's own remote hardware-isolated micro-VMs — the exact capability #7311 went to a third-party service for — and the integration has real depth that a first-party client should own once: labeling sandboxes at create time so a conversation delete can purge from the service, by label, not from process memory (a multi-replica host serves the delete on a replica that didn't create the sandbox; an in-memory registry leaks billable VMs, and the bug is invisible on a single-replica dev box); resuming a suspended sandbox instead of paying a cold create per fix-round; the label-value length limit (hash, don't truncate — a truncation collision lets one user's purge delete another's sandbox); OCI-reference-to-disk-image resolution; auto-suspend/auto-delete lifecycle policies as the cost backstop when the client process dies (#7311's "server-side lifetime caps" point).

3. Thread-scoped resource lifecycle. A sandbox belongs to a conversation, and the framework knows when conversations end; today there is no hook for "this thread owns external resources — dispose them on delete". This generalizes past sandboxes (provider-side threads, vector stores, temp storage). It also surfaces a primitive the framework is missing more broadly: a durable, host-owned conversation/boundary identity that middleware and backends can key state on. We had to invent it twice — once for sandbox keying, once to scope agent_framework.security label state to a conversation (see #7455, item 1) — and the "session isolation keys" advice in #6490's comment thread is the same concept appearing a third time.

4. Security composition with agent_framework.security (FIDES). A sandboxed exec tool should ship its information-flow classification instead of every integrator hand-maintaining it: it is definitionally a sink under untrusted taint (code influenced by untrusted content is attacker code — the CodeAct injection channel), and its spec's egress allowlist is machine-readable evidence the confidentiality leg cannot otherwise get — egress_allow=() means the tool cannot exfiltrate what it is given, a non-empty list derives the egress cap. This also settles #6490's architecture question ("Agent Executor vs. hosting channel"): the sandbox should surface as a tool on the middleware chain, not as a remote agent, because the security boundary (label tracking, approvals, budgets) is process-local — a remote tool ships only the work out and keeps the call inside the boundary; a remote agent exits it, and everything it returns must be re-treated as untrusted ingress.

Are there any alternatives you've considered?

  • Status quo: one package per runtime/service. This is how Python: [Feature]: Add a CodeAct backend for remote isolated Linux micro-VM sandboxes #7311 ended (closed, not merged), and it scales as N incompatible shapes — every backend re-decides keying, reuse, disposal, egress, and injection handling, which are precisely the parts that go wrong quietly.
  • Per-app integration (AIContextProvider + an execute_code tool). What Recommended integration pattern for remote HTTP sandboxes like Daytona in MAF? #6490 does today and what we did — it works, but the undifferentiated harness is ~3× the workload code, and the hard bugs (multi-replica purge, warm reuse, model-supplied keys) are invariants a framework can enforce once.
  • Sandbox as a remote agent instead of a remote tool. Rejected for the boundary reason in part 4: it silently exits the middleware chain's security context.

Out of scope, deliberately: image building and provisioning (a deployment property), domain-specific result parsing (a workload property), a host-tool callback bridge into remote sandboxes (Hyperlight/Monty have it in-process; #7311's table shows remote services mostly don't — it can arrive later as an optional backend capability), and an async job API for work longer than a turn (#7319's queue/poll shape; a natural v2 on the same protocol).

(Our interest in in Python and #6490 is an ask for the same abstraction, so the protocol shape should land in both SDKs even if backends arrive at different times.)

Code Sample

# ── 1. A workload tool, written once against the protocol ──────────────────────
# No backend import. The same tool runs on ACA Sandboxes in production, Docker on a
# dev box, and an in-process fake in tests.
from agent_framework import tool
from agent_framework.sandbox import SandboxRouter, SandboxSpec

PYTEST_SPEC = SandboxSpec(
    kind="pytest",
    image="pytest-sandbox:1.2",          # repository:tag; the backend's registry qualifies it
    egress_allow=("pypi.org", "files.pythonhosted.org"),  # everything else is DENIED
    work_dir="/work",
)

def make_test_tools(router: SandboxRouter, store, context) -> list:
    if not router.enabled:
        return []                        # nothing configured -> attach no tool, not a failing one

    @tool(name="run_tests")
    async def run_tests(files: list[str]) -> str:
        """Write the named workspace files into the sandbox and run pytest."""
        key = context.sandbox_key()      # scope/thread/agent from HOST request context, never model input
        listing = await context.list_files(store)
        if any(f not in listing for f in files):
            return "Error: unknown file"  # names validated BEFORE any command interpolation
        sandbox = await router.acquire(key, PYTEST_SPEC)   # get-or-create; warm reuse across fix rounds
        for f in files:
            await sandbox.write_file(f"{PYTEST_SPEC.work_dir}/{f}", await store.read(f))
        result = await sandbox.exec("pytest -q", working_directory=PYTEST_SPEC.work_dir, timeout=120)
        return result.stdout

    return [run_tests]



# ── 2. One router, every runtime — selection by declared capabilities ──────────
from agent_framework.azure.sandboxes import AcaSandboxBackend, AcaSandboxConfig  # part 2 (first-party)
from agent_framework.hyperlight import HyperlightSandboxBackend   # existing runtimes become backends
from agent_framework.monty import MontySandboxBackend
from agent_framework.sandbox import SandboxRouter, SandboxSpec

router = SandboxRouter(
    backends=[
        MontySandboxBackend(),        # in-process interpreter: run_code + host callbacks; no OS at all
        HyperlightSandboxBackend(),   # in-process hypervisor micro-VM: run_code + host callbacks
        AcaSandboxBackend(AcaSandboxConfig(endpoint=..., sandbox_group=..., registry=...)),
                                      # remote micro-VM: exec/shell, installs, persistent filesystem
    ],
    deployed=True,   # policy reads declared isolation TOGETHER WITH requested capabilities:
)                    # a no-I/O interpreter may pass where a shell-capable container never does

# The spec's requirements drive selection — the same seam serves both worlds:
SandboxSpec(kind="codeact", requires={"run_code", "host_callbacks"})            # -> Monty / Hyperlight
SandboxSpec(kind="pytest", requires={"exec"}, egress_allow=("pypi.org",))       # -> ACA (or Docker on a dev box)

# A community Tenki (#7311), Daytona (#6490), or local-machine (#7319) integration
# is one SandboxBackend implementation — not a new tool class per service.



# ── 3. Existing surfaces become workloads over the same protocol ───────────────
# CodeAct keeps its provider shape; the spec decides what serves it. In-process with
# host callbacks (today's Hyperlight/Monty experience, unchanged):
codeact = CodeActProvider(router=router, spec=SandboxSpec(kind="codeact", requires={"run_code", "host_callbacks"}))
# ...or with a REAL remote OS (the #7311 / #6490 ask) by requiring a shell instead:
codeact_os = CodeActProvider(router=router, spec=SandboxSpec(kind="codeact-os", requires={"exec"}, image="codeact:3.12", egress_allow=()))
agent = Agent(client=client, context_providers=[codeact])

# Skill-resource execution (#6475): the harness runs skill scripts as one more kind,
# with the skill's declared hosts as the sandbox's entire egress:
skill_spec = SandboxSpec(kind="skill-scripts", image="skills:1.0", egress_allow=tuple(skill.allowed_hosts))



# ── 4. Lifecycle + security compose instead of being rebuilt per app ───────────
# Conversation delete reclaims compute FROM THE SERVICE, by label — correct on the
# replica that never created the sandbox:
deleted = await router.dispose_scope(scope, thread_id)

# agent_framework.security derives the exec tool's classification from its spec:
# an exec tool is a sink under untrusted taint; egress_allow=() means it cannot
# exfiltrate what it is given, a non-empty allowlist derives the confidentiality cap.
# Today integrators hand-maintain exactly this mapping in their own middleware.

Language/SDK

Both

Metadata

Metadata

Assignees

No one assigned

    Labels

    .NETUsage: [Issues, PRs], Target: .NetpythonUsage: [Issues, PRs], Target: PythontriageUsage: [Issues], Target: All issues that still need to be triaged

    Projects

    Status
    No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions