Skip to main content

Secure code execution in microVMs with QEMU

Project description

exec-sandbox

Secure code execution in isolated lightweight VMs (QEMU microVMs). Python library for running untrusted Python, JavaScript, and shell code with 7-layer security isolation.

CI Coverage PyPI Python License

Highlights

  • Hardware isolation - Each execution runs in a dedicated lightweight VM (QEMU with KVM/HVF hardware acceleration), not containers
  • Fast startup - 400ms fresh start, 1-2ms with pre-started VMs (warm pool)
  • Simple API - Just Scheduler and run(), async-friendly
  • Streaming output - Real-time output as code runs
  • Smart caching - Local + S3 remote cache for VM snapshots
  • Network control - Disabled by default, optional domain allowlisting
  • Memory optimization - Compressed memory (zram) + unused memory reclamation (balloon) for ~30% more capacity, ~80% smaller snapshots

Installation

uv add exec-sandbox              # Core library
uv add "exec-sandbox[s3]"        # + S3 snapshot caching
# Install QEMU runtime
brew install qemu                # macOS
apt install qemu-system          # Ubuntu/Debian

Quick Start

Basic Execution

from exec_sandbox import Scheduler

async with Scheduler() as scheduler:
    result = await scheduler.run(
        code="print('Hello, World!')",
        language="python",  # or "javascript", "raw"
    )
    print(result.stdout)     # Hello, World!
    print(result.exit_code)  # 0

With Packages

First run installs and creates snapshot; subsequent runs restore in <400ms.

async with Scheduler() as scheduler:
    result = await scheduler.run(
        code="import pandas; print(pandas.__version__)",
        language="python",
        packages=["pandas==2.2.0", "numpy==1.26.0"],
    )
    print(result.stdout)  # 2.2.0

Streaming Output

async with Scheduler() as scheduler:
    result = await scheduler.run(
        code="for i in range(5): print(i)",
        language="python",
        on_stdout=lambda chunk: print(f"[OUT] {chunk}", end=""),
        on_stderr=lambda chunk: print(f"[ERR] {chunk}", end=""),
    )

Network Access

async with Scheduler() as scheduler:
    result = await scheduler.run(
        code="import urllib.request; print(urllib.request.urlopen('https://httpbin.org/ip').read())",
        language="python",
        allow_network=True,
        allowed_domains=["httpbin.org"],  # Domain allowlist
    )

Production Configuration

from exec_sandbox import Scheduler, SchedulerConfig

config = SchedulerConfig(
    max_concurrent_vms=20,       # Limit parallel executions
    warm_pool_size=1,            # Pre-started VMs (warm pool), size = max_concurrent_vms × 25%
    default_memory_mb=512,       # Per-VM memory
    default_timeout_seconds=60,  # Execution timeout
    s3_bucket="my-snapshots",    # Remote cache for package snapshots
    s3_region="us-east-1",
)

async with Scheduler(config) as scheduler:
    result = await scheduler.run(...)

Error Handling

from exec_sandbox import Scheduler, VmTimeoutError, PackageNotAllowedError, SandboxError

async with Scheduler() as scheduler:
    try:
        result = await scheduler.run(code="while True: pass", language="python", timeout_seconds=5)
    except VmTimeoutError:
        print("Execution timed out")
    except PackageNotAllowedError as e:
        print(f"Package not in allowlist: {e}")
    except SandboxError as e:
        print(f"Sandbox error: {e}")

Asset Downloads

exec-sandbox requires VM images (kernel, initramfs, qcow2) and binaries (gvproxy-wrapper) to run. These assets are automatically downloaded from GitHub Releases on first use.

How it works

  1. On first Scheduler initialization, exec-sandbox checks if assets exist in the cache directory
  2. If missing, it queries the GitHub Releases API for the matching version (v{__version__})
  3. Assets are downloaded over HTTPS, verified against SHA256 checksums (provided by GitHub API), and decompressed
  4. Subsequent runs use the cached assets (no re-download)

Cache locations

Platform Location
macOS ~/Library/Caches/exec-sandbox/
Linux ~/.cache/exec-sandbox/ (or $XDG_CACHE_HOME/exec-sandbox/)

Environment variables

Variable Description
EXEC_SANDBOX_CACHE_DIR Override cache directory
EXEC_SANDBOX_OFFLINE Set to 1 to disable auto-download (fail if assets missing)
EXEC_SANDBOX_ASSET_VERSION Force specific release version

Security

Assets are verified against SHA256 checksums and built with provenance attestations. For offline environments, set EXEC_SANDBOX_OFFLINE=1 after pre-downloading assets.

Documentation

  • QEMU Documentation - Virtual machine emulator
  • KVM - Linux hardware virtualization
  • HVF - macOS hardware virtualization (Hypervisor.framework)
  • cgroups v2 - Linux resource limits
  • seccomp - System call filtering

Configuration

Parameter Default Description
max_concurrent_vms 10 Maximum parallel VMs
warm_pool_size 0 Pre-started VMs (warm pool). Set >0 to enable. Size = max_concurrent_vms × 25% per language
default_memory_mb 256 VM memory (128-2048 MB). Effective ~25% higher with memory compression (zram)
default_timeout_seconds 30 Execution timeout (1-300s)
images_dir auto VM images directory
snapshot_cache_dir /tmp/exec-sandbox-cache Local snapshot cache
s3_bucket None S3 bucket for remote snapshot cache
s3_region us-east-1 AWS region
enable_package_validation True Validate against top 10k packages (PyPI for Python, npm for JavaScript)
auto_download_assets True Auto-download VM images from GitHub Releases

Environment variables: EXEC_SANDBOX_MAX_CONCURRENT_VMS, EXEC_SANDBOX_IMAGES_DIR, etc.

Memory Optimization

VMs include automatic memory optimization (no configuration required):

  • Compressed swap (zram) - ~25% more usable memory via lz4 compression
  • Memory reclamation (virtio-balloon) - 70-90% smaller snapshots

Execution Result

Field Type Description
stdout str Captured output (max 1MB)
stderr str Captured errors (max 100KB)
exit_code int Process exit code (0 = success)
execution_time_ms int Duration reported by VM
external_cpu_time_ms int CPU time measured by host
external_memory_peak_mb int Peak memory measured by host
timing.setup_ms int Resource setup (filesystem, limits, network)
timing.boot_ms int VM boot time
timing.execute_ms int Code execution
timing.total_ms int End-to-end time

Exceptions

Exception Description
SandboxError Base exception
SandboxDependencyError Optional dependency missing (e.g., aioboto3 for S3)
VmError VM operation failed
VmTimeoutError Execution exceeded timeout
VmBootError VM failed to start
CommunicationError VM communication failed
SocketAuthError Socket peer authentication failed
GuestAgentError VM helper process returned error
PackageNotAllowedError Package not in allowlist
SnapshotError Snapshot operation failed
AssetError Asset download/verification error (base)
AssetDownloadError Asset download failed
AssetChecksumError Asset checksum verification failed
AssetNotFoundError Asset not found in registry/release

Pitfalls

# VMs are never reused - state doesn't persist
result1 = await scheduler.run("x = 42", language="python")
result2 = await scheduler.run("print(x)", language="python")  # NameError!
# Fix: single execution with all code
await scheduler.run("x = 42; print(x)", language="python")

# Pre-started VMs (warm pool) only work without packages
config = SchedulerConfig(warm_pool_size=1)
await scheduler.run(code="...", packages=["pandas"])  # Bypasses warm pool, fresh start (400ms)
await scheduler.run(code="...")                        # Uses warm pool (1-2ms)

# Pin package versions for caching
packages=["pandas==2.2.0"]  # Cacheable
packages=["pandas"]         # Cache miss every time

# Streaming callbacks must be fast (blocks async execution)
on_stdout=lambda chunk: time.sleep(1)        # Blocks!
on_stdout=lambda chunk: buffer.append(chunk)  # Fast

# Memory overhead: pre-started VMs use (max_concurrent_vms × 25%) × 2 languages × 256MB
# max_concurrent_vms=20 → 5 VMs/lang × 2 × 256MB = 2.5GB for warm pool alone

# Memory can exceed configured limit due to compressed swap
default_memory_mb=256  # Code can actually use ~280-320MB thanks to compression
# Don't rely on memory limits for security - use timeouts for runaway allocations

# Network without domain restrictions is risky
allow_network=True                              # Full internet access
allow_network=True, allowed_domains=["api.example.com"]  # Controlled

Limits

Resource Limit
Max code size 1MB
Max stdout 1MB
Max stderr 100KB
Max packages 50
Max env vars 100
Execution timeout 1-300s
VM memory 128-2048MB
Max concurrent VMs 1-100

Security Architecture

Layer Technology Protection
1 Hardware virtualization (KVM/HVF) CPU isolation enforced by hardware
2 Unprivileged QEMU No root privileges, minimal exposure
3 System call filtering (seccomp) Blocks unauthorized OS calls
4 Resource limits (cgroups v2) Memory, CPU, process limits
5 Process isolation (namespaces) Separate process, network, filesystem views
6 Security policies (AppArmor/SELinux) When available
7 Socket authentication (SO_PEERCRED/LOCAL_PEERCRED) Verifies QEMU process identity

Guarantees:

  • VMs are never reused - fresh VM per run(), destroyed immediately after
  • Network disabled by default - requires explicit allow_network=True
  • Domain allowlisting - only specified domains accessible when network enabled
  • Package validation - only top 10k Python/JavaScript packages allowed by default

Requirements

Requirement Supported
Python 3.12, 3.13, 3.14 (including free-threaded)
Linux x64, arm64
macOS x64, arm64
QEMU 8.0+
Hardware acceleration KVM (Linux) or HVF (macOS) recommended, 10-50x faster

Verify hardware acceleration is available:

ls /dev/kvm              # Linux
sysctl kern.hv_support   # macOS

Without hardware acceleration, QEMU uses software emulation (TCG), which is 10-50x slower.

Linux Setup (Optional Security Hardening)

For enhanced security on Linux, exec-sandbox can run QEMU as an unprivileged qemu-vm user. This isolates the VM process from your user account.

# Create qemu-vm system user
sudo useradd --system --no-create-home --shell /usr/sbin/nologin qemu-vm

# Add qemu-vm to kvm group (for hardware acceleration)
sudo usermod -aG kvm qemu-vm

# Add your user to qemu-vm group (for socket access)
sudo usermod -aG qemu-vm $USER

# Re-login or activate group membership
newgrp qemu-vm

Why is this needed? When qemu-vm user exists, exec-sandbox runs QEMU as that user for process isolation. The host needs to connect to QEMU's Unix sockets (0660 permissions), which requires group membership. This follows the libvirt security model.

If qemu-vm user doesn't exist, exec-sandbox runs QEMU as your user (no additional setup required, but less isolated).

VM Images

Pre-built images from GitHub Releases:

Image Runtime Package Manager Size Description
python-3.14-base Python 3.14 uv ~140MB Full Python environment with C extension support
node-1.3-base Bun 1.3 bun ~57MB Fast JavaScript/TypeScript runtime with Node.js compatibility
raw-base None None ~15MB Shell scripts and custom runtimes

All images are based on Alpine Linux 3.21 (Linux 6.12 LTS, musl libc) and include common tools for AI agent workflows.

Common Tools (all images)

Tool Purpose
git Version control, clone repositories
curl HTTP requests, download files
jq JSON processing
bash Shell scripting
coreutils Standard Unix utilities (ls, cp, mv, etc.)
tar, gzip, unzip Archive extraction
file File type detection

Python Image

Component Version Notes
Python 3.14 python-build-standalone (musl)
uv 0.9+ 10-100x faster than pip (docs)
gcc, musl-dev Alpine For C extensions (numpy, pandas, etc.)

Usage notes:

  • Use uv pip install instead of pip install (pip not included)
  • Python 3.14 includes t-strings, deferred annotations, free-threading support

JavaScript Image

Component Version Notes
Bun 1.3 Runtime, bundler, package manager (docs)

Usage notes:

  • Bun is a Node.js-compatible runtime (not Node.js itself)
  • Built-in TypeScript/JSX support, no transpilation needed
  • Use bun install for packages, bun run for scripts
  • Near-complete Node.js API compatibility

Raw Image

Minimal Alpine Linux with common tools only. Use for:

  • Shell script execution (language="raw")
  • Custom runtime installation
  • Lightweight workloads

Build from source:

./scripts/build-images.sh
# Output: ./images/dist/python-3.14-base.qcow2, ./images/dist/node-1.3-base.qcow2, ./images/dist/raw-base.qcow2

Security

Contributing

Contributions welcome! Please open an issue first to discuss changes.

make install      # Setup environment
make test         # Run tests
make lint         # Format and lint

License

Apache-2.0

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

exec_sandbox-0.5.3.tar.gz (566.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

exec_sandbox-0.5.3-py3-none-any.whl (272.1 kB view details)

Uploaded Python 3

File details

Details for the file exec_sandbox-0.5.3.tar.gz.

File metadata

  • Download URL: exec_sandbox-0.5.3.tar.gz
  • Upload date:
  • Size: 566.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for exec_sandbox-0.5.3.tar.gz
Algorithm Hash digest
SHA256 f0e4afa89c99653302846b33160930f0d9f41e285fffd993fe93f57544c6954d
MD5 580a30ed70cf0d9050431f349664db9a
BLAKE2b-256 18e72a904bc6ca096333321b474640f6be16c10f9600e7ec3de77cca34f6a2b2

See more details on using hashes here.

Provenance

The following attestation bundles were made for exec_sandbox-0.5.3.tar.gz:

Publisher: release.yml on dualeai/exec-sandbox

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file exec_sandbox-0.5.3-py3-none-any.whl.

File metadata

  • Download URL: exec_sandbox-0.5.3-py3-none-any.whl
  • Upload date:
  • Size: 272.1 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for exec_sandbox-0.5.3-py3-none-any.whl
Algorithm Hash digest
SHA256 05674911918d60d35d4da12fc00b9af44a887a42460136432539c6513d839a8c
MD5 1f71e093b8b3beb0123580fadadc4aca
BLAKE2b-256 d23934f63e6b5340b9cc418245cbd544265714402aaaf75f5203d1a7884c086f

See more details on using hashes here.

Provenance

The following attestation bundles were made for exec_sandbox-0.5.3-py3-none-any.whl:

Publisher: release.yml on dualeai/exec-sandbox

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page