writing/tutorial/2026/08
TutorialAug 3, 2026·22 min read

Sandboxing AI Agent Code Execution with Microsoft MXC and TypeScript

Learn how to use Microsoft Execution Containers (MXC) to isolate untrusted code run by AI agents. This tutorial covers installation, policy configuration, one-shot and stateful sandbox execution, and integration with Claude tool calls in TypeScript.

When an AI agent calls a tool that executes code — running a shell command, spawning a subprocess, or writing to the filesystem — you have a choice: trust the model's output blindly, or enforce hard limits at the OS level.

Microsoft Execution Containers (MXC), launched at Build 2026, make the second option practical. MXC is an OS-level policy engine that constrains exactly what a process can touch — filesystem paths, network interfaces, CPU time — using a unified JSON policy schema and a TypeScript SDK that works on Windows, macOS, and Linux.

This tutorial walks through sandboxing an AI agent's tool calls with MXC so that even maximally adversarial model output cannot escape its container.

Preview note: MXC is in early preview as of August 2026. The SDK (@microsoft/mxc-sdk v0.7.0, MIT-licensed) is stable enough for development and non-security-critical production use. Full VM-level security boundary enforcement is still maturing — the team recommends treating the process backend as defence-in-depth, not a hard security boundary, until the microvm backend reaches GA.

Prerequisites

Before starting, ensure you have:

  • Node.js 20+ with npm or pnpm
  • TypeScript 5.5+ with strict mode enabled
  • A supported OS: Windows 11 24H2+, macOS 14 Sonoma+, or Linux with kernel 5.15+ (for process sandboxing) or Firecracker installed (for microVM isolation)
  • Comfort with async/await and TypeScript generics
  • An Anthropic API key for the agent integration step (optional)

What You'll Build

By the end of this tutorial you will have:

  1. A reusable SandboxExecutor class powered by MXC
  2. Granular JSON policies restricting filesystem and network access
  3. Both one-shot and stateful sandbox execution patterns
  4. A full end-to-end integration wiring MXC into a Claude agent's bash tool call

Step 1: Install the SDK

pnpm add @microsoft/mxc-sdk

The postinstall script downloads the platform-appropriate native binary (mxc-host). On Linux it also detects whether Firecracker is available for the microvm backend.

Add the following compiler options to tsconfig.json if not already present:

{
  "compilerOptions": {
    "moduleResolution": "bundler",
    "target": "ES2022",
    "lib": ["ES2022"],
    "strict": true
  }
}

Step 2: Check Platform Support

Always query platform capabilities before creating a sandbox — backend availability varies by OS and kernel.

// src/sandbox/platform.ts
import { getPlatformSupport } from '@microsoft/mxc-sdk';
 
export async function requireSandboxSupport() {
  const support = await getPlatformSupport();
 
  if (support.backends.length === 0) {
    throw new Error(
      `MXC is not available on this platform. ` +
      `OS: ${support.os}, kernel: ${support.kernelVersion}`
    );
  }
 
  // Prefer microvm for strongest isolation; fall back to process
  const preferred = support.backends.includes('microvm') ? 'microvm' : 'process';
 
  console.log(`MXC ready — using "${preferred}" backend`);
  return { ...support, preferred };
}

On a typical Ubuntu 22.04 host with Firecracker installed, support.backends will be ["process", "microvm"]. On macOS, only ["process"] is returned.

Step 3: Define a Sandbox Policy

MXC policies follow a default-deny model. Every permission must be explicitly granted. Here is a policy suitable for running arbitrary shell commands from an AI agent:

// src/sandbox/policy.ts
import {
  createConfigFromPolicy,
  getAvailableToolsPolicy,
  getTemporaryFilesPolicy,
  type MxcPolicy,
} from '@microsoft/mxc-sdk';
import * as os from 'node:os';
import * as path from 'node:path';
 
/**
 * Policy for running agent tool calls.
 * Network: blocked. Filesystem: project read-only + dedicated temp dir.
 */
export function buildAgentToolPolicy(projectRoot: string): MxcPolicy {
  const tmpDir = path.join(os.tmpdir(), 'mxc-agent-sandbox');
 
  return {
    network: 'none',
    filesystem: {
      readonlyPaths: [projectRoot],
      readWritePaths: [tmpDir],
      denyPaths: [os.homedir(), '/etc/passwd', '/etc/shadow'],
    },
    process: {
      maxPid: 32,
      allowedExecutables: ['/bin/sh', '/usr/bin/node', '/usr/bin/python3'],
    },
    resources: {
      timeoutSeconds: 30,
      memoryMb: 256,
      cpuPercent: 50,
    },
  };
}
 
/**
 * Alternative: compose MXC's built-in preset policies.
 */
export async function buildComposedPolicy() {
  const toolsPolicy = await getAvailableToolsPolicy();
  const tempPolicy = await getTemporaryFilesPolicy();
 
  return {
    ...toolsPolicy,
    ...tempPolicy,
    network: 'none' as const,
  };
}

denyPaths take priority over both readonlyPaths and readWritePaths, so you can apply a broad allow and carve out sensitive locations afterwards.

Step 4: One-Shot Execution

For commands that complete in a single invocation, use spawnSandboxFromConfig. It provisions the container, runs the command, then tears everything down automatically.

// src/sandbox/exec.ts
import { spawnSandboxFromConfig, createConfigFromPolicy } from '@microsoft/mxc-sdk';
import { buildAgentToolPolicy } from './policy.js';
 
export interface ExecResult {
  exitCode: number;
  stdout: string;
  stderr: string;
  durationMs: number;
}
 
export async function execInSandbox(
  command: string,
  args: string[],
  projectRoot: string,
): Promise<ExecResult> {
  const policy = buildAgentToolPolicy(projectRoot);
  const config = await createConfigFromPolicy(policy);
 
  const start = Date.now();
 
  const result = await spawnSandboxFromConfig(config, {
    command,
    args,
    cwd: projectRoot,
    env: {
      HOME: '/tmp',
      PATH: '/usr/bin:/bin',
    },
  });
 
  return {
    exitCode: result.exitCode,
    stdout: result.stdout,
    stderr: result.stderr,
    durationMs: Date.now() - start,
  };
}

Verify with a smoke test:

// smoke-test.ts
import { execInSandbox } from './src/sandbox/exec.js';
 
const result = await execInSandbox('node', ['--version'], process.cwd());
console.log(result.stdout.trim()); // v26.x.x
console.log('exit code:', result.exitCode); // 0

If the command exceeds timeoutSeconds, spawnSandboxFromConfig throws MxcTimeoutError. If it tries to open a denied path, the kernel blocks the syscall and the sandboxed process receives EACCES.

Step 5: Stateful Sandbox Lifecycle

When an agent needs to run multiple commands in sequence — installing a dependency, then running tests, then reading output — spinning up a fresh container each time wastes 100–500 ms per call. MXC exposes an explicit lifecycle for reuse:

// src/sandbox/session.ts
import {
  createConfigFromPolicy,
  type MxcSandbox,
} from '@microsoft/mxc-sdk';
import { buildAgentToolPolicy } from './policy.js';
 
export class SandboxSession {
  private sandbox: MxcSandbox | null = null;
 
  constructor(private readonly projectRoot: string) {}
 
  async open() {
    const policy = buildAgentToolPolicy(this.projectRoot);
    const config = await createConfigFromPolicy(policy);
 
    this.sandbox = await config.provision();
    await this.sandbox.start();
  }
 
  async exec(command: string, args: string[] = []) {
    if (!this.sandbox) throw new Error('Session is not open');
 
    return this.sandbox.exec({
      command,
      args,
      cwd: this.projectRoot,
      env: { HOME: '/tmp', PATH: '/usr/bin:/bin' },
    });
  }
 
  async close() {
    if (!this.sandbox) return;
    await this.sandbox.stop();
    await this.sandbox.deprovision();
    this.sandbox = null;
  }
}

Sequential commands inside a single container:

const session = new SandboxSession(process.cwd());
await session.open();
 
try {
  const install = await session.exec('npm', ['ci', '--ignore-scripts']);
  console.log('install exit:', install.exitCode);
 
  const tests = await session.exec('npm', ['test']);
  console.log(tests.stdout);
} finally {
  // always deprovision, even if a command throws
  await session.close();
}

Step 6: Integrate with Claude Tool Use

The highest-value scenario is wiring MXC into an AI agent that can run arbitrary code. The following example uses the Anthropic TypeScript SDK with a sandboxed bash tool:

// src/agent/sandbox-agent.ts
import Anthropic from '@anthropic-ai/sdk';
import { execInSandbox } from '../sandbox/exec.js';
 
const client = new Anthropic();
 
const BASH_TOOL: Anthropic.Tool = {
  name: 'bash',
  description: 'Run a shell command and return stdout and stderr.',
  input_schema: {
    type: 'object' as const,
    properties: {
      command: {
        type: 'string',
        description: 'The shell command to execute.',
      },
    },
    required: ['command'],
  },
};
 
export async function runSandboxedAgent(userPrompt: string): Promise<string> {
  const messages: Anthropic.MessageParam[] = [
    { role: 'user', content: userPrompt },
  ];
 
  while (true) {
    const response = await client.messages.create({
      model: 'claude-sonnet-5',
      max_tokens: 4096,
      tools: [BASH_TOOL],
      messages,
    });
 
    messages.push({ role: 'assistant', content: response.content });
 
    if (response.stop_reason === 'end_turn') {
      const text = response.content.find(b => b.type === 'text');
      return text?.type === 'text' ? text.text : '';
    }
 
    if (response.stop_reason !== 'tool_use') break;
 
    const toolResults: Anthropic.ToolResultBlockParam[] = [];
 
    for (const block of response.content) {
      if (block.type !== 'tool_use') continue;
 
      const input = block.input as { command: string };
 
      try {
        const result = await execInSandbox(
          '/bin/sh',
          ['-c', input.command],
          process.cwd(),
        );
 
        const output =
          result.stdout + (result.stderr ? `\nSTDERR: ${result.stderr}` : '');
 
        toolResults.push({
          type: 'tool_result',
          tool_use_id: block.id,
          content: output || `(exit code ${result.exitCode})`,
        });
      } catch (err) {
        toolResults.push({
          type: 'tool_result',
          tool_use_id: block.id,
          is_error: true,
          content: err instanceof Error ? err.message : 'Unknown sandbox error',
        });
      }
    }
 
    messages.push({ role: 'user', content: toolResults });
  }
 
  return '';
}

Every shell command the model emits is intercepted, wrapped in an MXC container, and executed with network: 'none' and a 30-second timeout — before the result flows back to the model. The agent cannot touch the real filesystem or network directly.

Call it like this:

import { runSandboxedAgent } from './src/agent/sandbox-agent.js';
 
const answer = await runSandboxedAgent(
  'Count the number of TypeScript files under src/ and show me the 5 largest.'
);
console.log(answer);

Step 7: Error Handling

MXC throws named error classes you can catch and handle individually:

import {
  MxcTimeoutError,
  MxcPolicyViolationError,
  MxcBackendUnavailableError,
} from '@microsoft/mxc-sdk';
 
try {
  await execInSandbox('/bin/sh', ['-c', 'sleep 60'], process.cwd());
} catch (err) {
  if (err instanceof MxcTimeoutError) {
    console.error('Timed out after', err.timeoutMs, 'ms');
  } else if (err instanceof MxcPolicyViolationError) {
    console.error('Policy violation — denied resource:', err.deniedResource);
  } else if (err instanceof MxcBackendUnavailableError) {
    console.error('No MXC backend available on this host');
  } else {
    throw err;
  }
}

In an agent loop, map MxcTimeoutError and MxcPolicyViolationError to tool results with is_error: true. A clear error message lets the model recover gracefully instead of retrying the same blocked command repeatedly.

Step 8: Production Considerations

Choose the right backend. The process backend starts in under 10 ms and works everywhere but shares the host kernel. The microvm backend on Linux provides VM-level isolation via Firecracker and is the right choice when running truly untrusted model output. Specify it explicitly: backend: 'microvm' in your policy.

Start with the narrowest possible policy. Begin with network: 'none' and an allowedExecutables list covering only what you need. Expand only when a concrete test requires it.

Mount the project read-only. Pass your source tree in readonlyPaths and a dedicated temporary directory in readWritePaths. The agent can write artifacts without ever touching source files.

Add observability. Log durationMs, exitCode, and the raw command to your tracing system. Clusters of policy violations are early signals of prompt-injection attempts.

Troubleshooting

MxcBackendUnavailableError on Linux — Install Firecracker and set FIRECRACKER_BIN=/usr/local/bin/firecracker, or explicitly request the process backend with backend: 'process' in your policy.

Sandbox exits immediately with code 1 — Inspect stderr. The most common cause is the target binary not being in allowedExecutables. Add its absolute path.

High latency on the first call — The microvm backend cold-starts a Firecracker VM in 200–500 ms. Use SandboxSession (Step 5) to keep the VM alive across multiple tool calls.

macOS permission dialogs — MXC uses the Sandbox.framework process backend on macOS. If Gatekeeper blocks the native binary, run:

xattr -d com.apple.quarantine ./node_modules/@microsoft/mxc-sdk/bin/mxc-host

Next Steps

  • Read the full policy schema in the MXC GitHub repository — the reference covers backend-specific knobs and advanced containment profiles
  • Combine MXC with the Sentry Next.js integration to trace sandbox executions through your observability pipeline
  • For agents that legitimately need outbound network access, set network: 'loopback' and route all egress through an audited local proxy

Conclusion

Microsoft MXC gives you a principled, OS-enforced boundary between a model's output and your host environment. With a few dozen lines of TypeScript, every tool call your agent makes runs inside a container that cannot read sensitive files, phone home, or consume unbounded resources. The same SDK will upgrade from defence-in-depth to a hard VM-level boundary with no code changes when the microvm backend reaches GA — making now the right time to wire it in.