Skip to content

Sandboxed execution ​

This document describes the design of mxc-based sandboxed command execution in Agentweaver — how the sandbox engine plugs into the existing governance and runner layers, how executor selection works, and the security model behind it.

Overview ​

Agentweaver originally permitted no shell execution. The governance policy categorically denied any shell tool call, and agents were restricted to path-contained in-process file operations.

This feature adds sandboxed shell execution by adopting Microsoft's mxc sandboxing engine. The key constraint is that mxc is an early preview and its own documentation says its profiles are not yet hardened security boundaries. It is therefore adopted as a defense-in-depth layer only — it augments the existing in-process path containment and deny-by-default governance, and never replaces them.

Live run_command registration requires ShellEnabled and either real isolation or direct. The local factory may fall back to direct automatically, not only through explicit opt-in. Direct is not isolation and requires a trusted/disposable surrounding environment. Native shell stays denied.

Once a backend is selected (see the sandbox deep dive), a run_command invocation flows through the triple-layer governance gate, into the chosen executor, and back out as redacted output events:

Executor selection ​

Executor selection happens at startup via SandboxExecutorFactory.Create. The factory probes the host in order and returns the first available executor:

OrderBackend namePlatform condition
1processcontainerWindows + wxc-exec.exe found + MxcSdk.GetPlatformSupport().IsSupported
2wsl-bwrap / wsl-unshareWindows + WSL2 available (wsl-bwrap when bubblewrap is present in the distro, otherwise wsl-unshare)
3linux-bwrapLinux + bubblewrap (bwrap) available — preferred Linux backend (selective mount allowlist)
4lxc-native-linuxLinux + lxc-exec found at /usr/local/bin/lxc-exec or /usr/bin/lxc-exec (only when bwrap is unavailable)
5directFallback when no isolation backend is available, or selected explicitly via direct: true in .agentweaver/settings.yml

The API router first chooses Kubernetes versus local from Sandbox:Backend and cluster detection. Explicit local bypasses cluster selection; selected Kubernetes initialization fails closed. Only then does the local path invoke the factory. See the host selector.

The selected executor is injected into the per-run governance context and GitHub Copilot SDK runner. Its key properties are:

  • IsRealIsolation — true for processcontainer, wsl-bwrap, linux-bwrap, lxc-native-linux, and kubernetes-sandbox-claim; false for wsl-unshare and the direct fallback. Shell execution requires this to be true, except for the direct backend, which can be an explicit choice or automatic fallback (see Layer C).
  • HasNetworkWarning — true for the Windows processcontainer and wsl-unshare tiers, whose backends cannot enforce a network allowlist. When set, the runner emits a sandbox.warning event (see Limitations).

The executor selection decision, backend name, and probe reason are emitted as a sandbox.selected event when a run starts.

Command timeouts ​

run_command uses a five-minute timeout when the caller does not provide timeout_ms. An explicit caller timeout is used as requested unless the active runtime policy has a minimum or maximum limit. The controlled Build/Test shell keeps its ten-minute minimum and maximum limit.

The GitHub Copilot SDK runner adds a separate five-minute grace period after the effective command timeout. The executor cancels the command first and returns timed_out: true. Its streaming watchdog stops the agent turn only when the process still has not exited after that grace period and emits shell-progress heartbeats while waiting. This extra time is important for Kata VM pods, where process termination can take longer while the Kata agent relays the signal.

Tool architecture ​

This table is the canonical tool catalog, not a fixed live count. The runner selects intent/outcome, optional question and controlled-command tools; native file tools avoid duplicate custom exposure. AgentHost adds purpose-appropriate preview tools.

ToolPurposeConditional
run_commandRun a shell command in the sandbox`ShellEnabled && (IsRealIsolation
read_fileRead a file inside the sandbox rootNo
grep_searchSearch file contents with a regexNo
file_searchFind files by name patternNo
str_replace_editorMake targeted line or text replacementsNo
apply_patchApply a unified-diff patchNo
create_fileCreate a new fileNo
write_fileWrite a file inside the sandbox rootNo
report_intentEmit an agent.intent event for UI displayNo
report_outcomeReport the completed outcomeNo
ask_questionRequest a user answer through the run gateNo

Permission-gated native tools ​

The Copilot runner intentionally does not set the SDK AvailableTools allowlist: the SDK's native file tool names differ from Agentweaver's logical tool names, and setting the logical allowlist would hide usable file tools from the model. Instead, the deny-by-default OnPermissionRequest handler is the authoritative gate for every native file, shell, MCP, or other tool request.

When shell access is not allowed, the permission handler denies shell-like requests before execution.

Why native shell is always denied ​

The YAML policy loaded into the governance kernel has an explicit deny-native-shell rule that matches tool_name == 'shell' with action Deny. This fires before the executor gate and before any path check, unconditionally. Shell execution must go through the run_command custom tool, which routes through ISandboxExecutor under the triple-layer evaluation described in the security model.

Sandbox policy ​

Each project stores its SandboxPolicy as .agentweaver/settings.yml in the project repository root (GitOps). The file is version-controlled alongside the code: policy changes are reviewable via PR and auditable via git log. When the file does not exist, SandboxPolicy.Default(repositoryPath) applies automatically.

The API (GET /api/sandbox-policy, PUT /api/sandbox-policy) reads and writes this file. PUT writes the YAML file; the operator should commit and push the change to record it in the project history. No database row is required.

FieldTypeDefaultPurpose
RepositoryPathstring—Lookup key
ShellEnabledbooltrueWhether run_command is permitted at all. false disables shell regardless of isolation state.
AllowedRepositoryRootsstring[][]Additional paths mounted read-only inside the sandbox. If empty, only the run's working directory is accessible.
DestructiveCommandPatternsstring[]["rm -rf", "del /s", "format ", ...]Patterns that trigger human approval before execution.
RequireApprovalForAllShellboolfalseWhen true, every shell command requires human approval, not just destructive ones.
RedactPiibooltrueWhether to redact emails and IP addresses from command output in addition to secrets.
MaxOutputBytesint4194304 (4 MB)Output cap. Results exceeding this are truncated and marked with OutputTruncated: true.

The policy is read through ISandboxPolicyStore.GetPolicyAsync and is configurable via the API at GET /api/sandbox-policy and PUT /api/sandbox-policy. See sandbox-setup.md for operator instructions. When the settings file has no sandbox section, or its sandbox section omits destructive_command_patterns, the canonical default approval patterns apply. An explicit list, including [], is an intentional override.

The API sends one short-lived installation credential for the selected repository and run. When that credential is used, the sandbox parses the command and starts the approved git or gh executable directly instead of placing the credential in a shell environment. Credential-bearing Git is limited to argument-free git status; all other Git subcommands and flags are rejected. This prevents repository configuration from selecting external diffs, signing programs, filters, merge drivers, hooks, aliases, helpers, or remote helpers. The direct Git process disables hooks, credential helpers, filesystem monitors, and recursive submodules. Its GitHub authorization header is scoped to that process; it is never supplied to a child process.

Credential-bearing gh invocations must also match a narrow parsed allowlist. It permits direct gh api and gh status calls; explicitly named repository list, view, and fork calls; and explicitly repository-scoped issue, pull-request, and workflow forms that do not launch another program. gh issue develop is limited to its --list form. Repository forks that clone or alter remotes, pull-request creation and checkout, branch-deleting close or merge commands, and browser/editor forms are rejected even after operator approval, so they never receive GH_TOKEN. gh config set is not allowlisted: editor and pager settings persist and can make a later command start an external program. Likewise, gh gist edit and every other editor-facing gist form remain outside the allowlist.

The approval policy gates git push, remote changes, gh pr changes, gh repo changes, gh workflow run, gh api, gh secret set, and gh auth commands (including gh auth token). The API does not inspect or proxy these commands.

The system keeps this credential out of pod specs, files, logs, events, annotations, shared environments, and credential-helper files. Normal release and orphan cleanup log failed revocations. The registry retains failed revocations independently of their SandboxClaim, and each reaper sweep retries due revocations with capped exponential backoff until they succeed or the credential actually expires.

Security model ​

A run_command invocation passes three layers before the sandbox engine sees it.

Layer A — YAML policy ​

The GovernanceKernel evaluates the tool call against the embedded YAML policy. The policy allows run_command and all custom file tools by name; everything else is denied by the default Deny action. The policy also has an explicit deny-native-shell rule for tool_name == 'shell' that fires before the default.

At kernel construction, the runtime asserts that the loaded policy's defaultAction is Deny. If it is not, the run is refused.

Layer B — path containment backend ​

SandboxPolicyBackend runs unconditionally and independently of layer A. It validates that every file path argument is contained within the sandbox root, and denies any call with no identifiable path argument. For run_command, it validates that the directory argument resolves inside the sandbox root. Layer B cannot be bypassed by layer A passing.

Layer C — executor gate ​

Only for run_command, after both A and B allow. SandboxGovernance.EvaluateToolCall checks:

  1. executor.IsRealIsolation — must be true.
  2. policy.ShellEnabled — must be true.

These governance checks deny execution except for direct, which bypasses them. Direct can be explicit or an automatic local fallback. Live registration still requires ShellEnabled, including direct; approval cannot override policy denial.

Any exception in any layer produces a deny result (fail-closed).

TOCTOU mitigations ​

The sandbox root is validated as a non-reparse-point at construction time (SandboxedFileTools asserts this). For file operations, the runtime performs an open-then-verify pass: after opening a file handle, it resolves the final path from the handle (GetFinalPathNameByHandle on Windows, /proc/self/fd/<fd> on Linux) and checks it against the sandbox root again. A handle resolving outside the root is rejected before any bytes are read or written.

Output redaction ​

SandboxOutputRedactor runs on stdout and stderr before the content reaches log or event streams. It always removes secrets (Bearer tokens, AWS access key IDs, GitHub PATs, PEM private key headers, connection string passwords, generic API key patterns). When SandboxPolicy.RedactPii is true, it also removes email addresses and IPv4/IPv6 addresses.

Destructive command detection ​

Before a run_command invocation reaches the executor, the runner checks SandboxPolicy.DestructiveCommandPatterns against the command line. If any pattern matches, or if RequireApprovalForAllShell is set, the runner computes a deterministic SHA-256 hash (first 16 hex characters, lowercase) of the command and checks whether the operator has already approved it for this run.

  • Not yet approved — the runner emits a shell.approval_required event and returns a message to the model with the approval endpoint (POST /api/runs/{id}/shell-approvals) and the command_hash to submit. The model retries the same command on the next turn; because the hash is deterministic, it will find the approval.
  • Already approved — the runner logs the approval and falls through to execution immediately without re-emitting the event.

Approvals are scoped by (runId, commandHash). Local runtime defaults may use an in-memory store; API-hosted runs register durable approval implementations. Not every live approval is process-local or universally cleared at completion.

Deployment parity ​

The API router chooses Kubernetes or local first. Local tiers differ; wsl-unshare and direct do not report real isolation:

TargetExecutor backendNotes
Windows ARM64 (developer)processcontainerRequires wxc-exec.exe on PATH or MXC_BIN_DIR set
Windows with WSL2wsl-bwrap / wsl-unshareFalls through to this when processcontainer is unavailable; wsl-bwrap when bubblewrap is installed in the distro
Linux cloudlinux-bwrapPreferred Linux backend; requires bwrap (bubblewrap)
Linux cloud (no bwrap)lxc-native-linuxUsed when bwrap is unavailable; requires lxc-exec at a known absolute path
AKS / in-clusterkubernetes-sandbox-claimAuto-selected when KUBERNETES_SERVICE_HOST is set; per-run Kata VM pod from a warm pool

Both clients (MCP server and Web UI) reach the same endpoints. Executor selection happens in the backend, not the client. There is no client-side isolation logic.

Windows ARM64 runbook ​

This covers local developer setup and CI pipelines on Windows ARM64.

Download and extract binaries ​

Download mxc-release-binaries.zip from https://github.com/microsoft/mxc/releases. The spike was validated against v0.6.1.

powershell
Expand-Archive mxc-release-binaries.zip -DestinationPath C:\mxc-bin

The zip extracts to arm64\wxc-exec.exe (and equivalent per-arch subdirectories).

Set MXC_BIN_DIR ​

Set the environment variable system-wide or in your CI pipeline:

powershell
[System.Environment]::SetEnvironmentVariable("MXC_BIN_DIR", "C:\mxc-bin", "Machine")

The SDK resolves the binary at %MXC_BIN_DIR%\arm64\wxc-exec.exe (where arm64 matches the process architecture). This variable takes priority over the assembly-adjacent bin\<arch>\wxc-exec.exe path.

Verify ​

powershell
& "C:\mxc-bin\arm64\wxc-exec.exe" --probe

Expected output:

json
{
  "tier": "base-container",
  "needsDaclAugmentation": false,
  "warnings": [],
  "probes": {
    "baseContainerApiPresent": true,
    "bfscfgPresent": false,
    "bfsCompiledIn": false
  }
}

Policy version ​

MxcSandboxExecutor pins SandboxPolicy.Version = "0.5.0-alpha" when calling MxcSdk.SpawnSandboxAsync. This schema provides improved path normalization over 0.4.0-alpha while still routing to the AppContainer fallback tier. ClearPolicyOnExit = true is always set so AppContainer ACL grants are cleaned up on process exit.

At executor construction (once per process start), SandboxPolicyEnrichment.BuildForWindows() calls three PolicyDiscovery helpers from the mxc SDK:

  • GetAvailableToolsPolicy — minimal PATH-based tool directories (excludes paths already accessible to ALL_APPLICATION_PACKAGES)
  • GetUserProfilePolicy — safe user-profile directories (LocalAppData\Programs subdirectories, not the full home directory)
  • GetTemporaryFilesPolicy — the platform temp directory as read-write

These enrichment paths are merged into the sandbox filesystem policy on every command, replacing the need for broad mounts.

SDK execution path ​

MxcSandboxExecutor uses MxcSdk.SpawnSandboxAsync(script, policy, options) with SandboxSpawnOptions { UsePty = false }. This returns a SandboxProcessResult with public Stdout, Stderr, and ExitCode. The alternative SpawnSandboxProcessFromConfig path is not used because ProcessConnection.GetStdout() and GetStderr() are declared internal in SDK v0.1.1.

Optional: DACL augmentation ​

For the appcontainer-dacl isolation tier (higher than 0.4.0-alpha), run elevated once:

powershell
& "C:\mxc-bin\arm64\wxc-host-prep.exe" prepare-system-drive   # one-time
& "C:\mxc-bin\arm64\wxc-host-prep.exe" prepare-null-device    # per-boot

This is not required for the default 0.4.0-alpha AppContainer path.

Linux cloud runbook ​

On Linux hosts, SandboxExecutorFactory prefers bubblewrap. LinuxBwrapExecutor is selected when bwrap is available (the SDK platform probe reports the Bubblewrap backend, or bwrap is found on PATH); it applies a selective mount allowlist scoped to the run's working directory.

Only when bubblewrap is unavailable does the factory fall back to LinuxNativeMxcSandboxExecutor, which probes the following absolute paths in order:

  1. /usr/local/bin/lxc-exec
  2. /usr/bin/lxc-exec

PATH is never consulted for lxc-exec. If neither isolation backend exists, the factory automatically falls back to direct. Live registration still requires ShellEnabled; explicit direct: true is not required for fallback.

Limitations ​

mxc is not a hardened security boundary ​

The mxc project's own documentation states that profiles are not yet hardened security boundaries. Agentweaver adopts mxc as a defense-in-depth layer on top of the existing path containment and deny-by-default governance. mxc is not and must not be presented as the primary security layer.

Network allowlist enforcement on Windows ​

The Windows AppContainer backend cannot enforce a per-host network allowlist. The default network posture for the processcontainer backend is unrestricted outbound. MxcSandboxExecutor.HasNetworkWarning returns true on Windows, which causes the runner to emit a sandbox.warning event with category: "network-unrestricted". Operators who need network restriction must either configure a proxy (set Network.Proxy on the mxc policy) or use the WSL2/Linux path, which supports real allowlisting.

hyperlight and microvm unavailable on ARM64 ​

hyperlight is x86-64 only. microvm requires the nanvixd daemon, which is not distributed in the public release zip. Neither backend is in scope for ARM64.

Executor path artifact in SDK stdout ​

SDK v0.1.1 dev-artifacts builds append the executor binary path as a trailing line on stdout. MxcSandboxExecutor.StripExecutorArtifact removes any trailing line that ends in .exe and contains a path separator. This workaround is in place until the upstream artifact is confirmed fixed or a clean release binary is used.

Known gaps ​

IDDescriptionStatus
T012Binary bundling. The spec (FR-034) calls for bundling wxc-exec.exe per-arch under bin/<arch> for zero-configuration discovery. This is blocked pending redistribution license review for the mxc binaries. Until resolved, operators must set MXC_BIN_DIR manually.Open
Diagram details and constraints
ElementContract
titleSeveral checks contain each action
takeawayNative shell is denied; governed tools combine AGT policy, direct containment and execution isolation.
group-title0TOOL SELECTION / POLICY
group-title1POINT-OF-USE CONTAINMENT
Model tool requestModel tool request
Model tool requestPermission dispatch
Model tool requestNative shell: always denied
Model tool requestURL approvals handled apart
Model tool requestCustom reporting bypass
GovernanceGovernance
GovernanceDeny-by-default policy
GovernanceAGT policy must allow
GovernanceDirect backend must allow
GovernanceBoth checks, not either
Registered toolsRegistered tools
Registered toolsExplicit capability surface
Registered toolsFiles revalidate at use
Registered toolsrun_command gates shell
Registered toolsUnknown tools denied
Workspace boundaryWorkspace boundary
Workspace boundarySandbox filesystem
Workspace boundaryLexical + real-path checks
Workspace boundaryReject symlink escapes
Workspace boundaryBounded / redacted output
Execution boundaryExecution boundary
Execution boundarySelected isolation backend
Execution boundaryShell policy + approval
Execution boundaryKata pod in AKS
Execution boundaryDirect mode is opt-in
Credential handlingCredential handling
Credential handlingCurrent implementation
Credential handlingHost + tool options hold token
Credential handlingDirect git status / allowed gh
Credential handlingNo blanket shell injection
relation-01 governed calls
relation-12 both allow
relation-23 file operation
relation-34 run_command
relation-45 eligible git / gh
assuranceCurrent code delivers repository credentials into Host/tool options; the normative no-credential contract is NOT met.
assurance-0-labelDispatch exceptions
assurance-0-factNative shell denied; URL path separate.
assurance-0-sourceCopilotAIAgent.cs
assurance-1-labelExecution isolation
assurance-1-factSidecar: separate PID namespace.
assurance-1-sourcesandbox-template-agenthost.yaml
assurance-2-labelCredential reality
assurance-2-factNo blanket shell credential inheritance.
assurance-2-sourceRunCommandTool.cs
n0Native shell: always denied; URL approvals handled apart
n1AGT policy must allow; Direct backend must allow
n2Files revalidate at use; run_command gates shell
n3Lexical + real-path checks; Reject symlink escapes
n4Shell policy + approval; Kata pod in AKS
n5Host + tool options hold token; Direct git status / allowed gh
groupsTOOL SELECTION / POLICY; POINT-OF-USE CONTAINMENT
Diagram details and constraints
ElementContract
titleChoose Kubernetes before local probing
takeawayThe API router owns cluster selection; the local factory can fall back to direct.
API executor routerAPI executor router
API executor routerBackend override / cluster detect
API executor routerExplicit local bypasses cluster
Kubernetes selectedKubernetes selected
Kubernetes selectedInitialize claim executor
Kubernetes selectedFailure throws; no local fallback
Claim backendClaim backend
Claim backendBound pod command contract
Claim backendNot the in-pod PodExec client
Local factoryLocal factory
Local factoryOnly when router selects local
Local factoryProbe host-supported backends
Windows ladderWindows ladder
Windows ladderprocesscontainer -> WSL
Windows ladderwsl-bwrap real; unshare not real
Linux ladderLinux ladder
Linux ladderbubblewrap -> LXC
Linux ladderHost tools must be available
No usable isolationNo usable isolation
No usable isolationAutomatic local fallback
No usable isolationAlso explicit direct option
Direct passthroughDirect passthrough
Direct passthroughIsRealIsolation = false
Direct passthroughTrusted/disposable host only
Command registrationCommand registration
Command registrationShellEnabled AND real or direct
Command registrationwsl-unshare: no controlled shell
arrow-1select
arrow-2ready
arrow-3local
arrow-4Windows
arrow-5Linux
arrow-6unavailable
arrow-8warn
arrow-9gate
note-0The local platform ladders are alternatives, not a Windows-to-Linux chain.
note-1Kubernetes failure never silently descends into the local ladder.
note-2Runtime emits sandbox.selected; factory choice is not an isolation guarantee.
notesThe local platform ladders are alternatives, not a Windows-to-Linux chain.; Kubernetes failure never silently descends into the local ladder.; Runtime emits sandbox.selected; factory choice is not an isolation guarantee.