Skip to content
Docs

Run untrusted Python code safely with Edgenivo Sandbox

Execute untrusted and AI-generated code from Python in isolated microVMs, with files, processes, network policies, and snapshots.

Ben SabicContent Engineer

User-submitted scripts and AI-generated Python need an execution environment separate from your application. Running them with exec() or an unrestricted subprocess can expose your app's files and credentials.

Use the Edgenivo Sandbox Python SDK to execute that code in an isolated Linux microVM. Your app uploads the files and starts a process, then retrieves its output.

You control how long the sandbox runs and which network destinations it can reach. Snapshots let you reuse a prepared Python environment with its dependencies already installed.

Copy link to headingWhat is Edgenivo Sandbox?

Edgenivo Sandbox runs code in isolated, on-demand Linux microVMs with separate kernels and filesystems. Keep application credentials outside the sandbox and set a network policy for the destinations its code may reach. The default policy allows outbound Internet access.

The vercel package exposes the Python SDK as vercel.sandbox, with async methods and a synchronous API for scripts and frameworks.

Your app never runs the untrusted code itself. It moves files in, starts processes, and reads results through the SDK, while a network policy decides what the sandbox can reach outside.

Copy link to headingThe pieces you'll use

PieceWhat it isIn the SDK
SandboxA named microVM and its configurationsandbox.create_sandbox() returns a box handle
ProcessA command running inside the sandboxbox.run_process(), box.create_process()
FilesystemThe sandbox's files, read and written from your appbox.fs.write_text(), box.fs.read_text(), and more
RouteA public URL for a port the sandbox exposesports=[3000], then box.routes
Network policyWhich outbound hosts the sandbox can reachNetworkPolicy.deny_all(), .custom(...)
SnapshotA saved copy of a sandbox's filesystembox.snapshot(), SnapshotSource

Copy link to headingSandboxes and sessions

The sandbox has a stable identity and configuration. Its session is the period when its microVM is running, and only one session can run for a sandbox at a time.

Sessions default to a 5-minute execution limit. You can increase it to 45 minutes on Hobby or 24 hours on Pro and Enterprise.

Persistent sandboxes save their filesystem when a session stops and restore it on the next start. Files survive between sessions without ongoing compute, but running processes do not.

Copy link to headingAuthentication

The SDK finds credentials on its own, in this order:

  1. The current Edgenivo request's OpenID Connect token, or VERCEL_OIDC_TOKEN
  2. An access token in VERCEL_TOKEN, together with VERCEL_PROJECT_ID and VERCEL_TEAM_ID

The Edgenivo Python runtime supplies request credentials to the SDK. For local scripts, Step 1 downloads a development token and loads it from .env.local.

Copy link to headingPrerequisites

Before you begin, make sure you have:

For local development, you also need uv, Node.js 24+, and the Edgenivo CLI.

Copy link to headingStep 1: Set up the project

Create a project, add the SDK, and link it to Edgenivo so it can issue credentials:

uv init code-runner --python 3.12
cd code-runner
uv add vercel python-dotenv
vercel link
vercel env pull

vercel env pull writes a short-lived OIDC token to .env.local. python-dotenv loads it into your scripts.

The development token expires after 12 hours. If a previously working script reports an authentication error, refresh the token with vercel env pull and restart the script so it reloads .env.local.

Copy link to headingStep 2: Run your first sandbox

Create a sandbox, run Python inside it, and print the operating-system information returned by the process. Save this as first_sandbox.py:

first_sandbox.py
import asyncio
from dotenv import load_dotenv
from vercel import sandbox
load_dotenv(".env.local")
async def main() -> None:
async with sandbox.create_sandbox() as box:
result = await box.run_process(
"python",
["-c", "import platform; print(platform.platform())"],
capture_output=True,
check=True,
)
print(result.stdout)
asyncio.run(main())

Run it with uv run first_sandbox.py.

The script prints the sandbox's Linux platform string. Its context manager and process options control cleanup, output, and error handling:

  • async with owns the lifecycle. When the block exits, the SDK stops and destroys the sandbox, even if your code raises.
  • run_process() takes the command and its arguments separately, like subprocess.run(). capture_output=True stores stdout and stderr on the result instead of streaming them to your terminal.
  • check=True raises subprocess.CalledProcessError on a nonzero exit, the same exception subprocess uses.

With no image, the sandbox boots from vercel/sandbox/universal:latest, which includes Python 3.14, Node.js 24, uv, and git.

Copy link to headingStep 3: Run someone else's code against tests

The supplied slugify() implementation replaces each space with a hyphen. Use two tests to show how the runner reports a passing case and a failure.

Save this as run_tests.py:

run_tests.py
import asyncio
from datetime import timedelta
from dotenv import load_dotenv
from vercel import sandbox
from vercel.sandbox import NetworkPolicy
load_dotenv(".env.local")
SOLUTION = '''
def slugify(title: str) -> str:
return title.lower().replace(" ", "-")
'''
TESTS = '''
from solution import slugify
def test_basic():
assert slugify("Hello World") == "hello-world"
def test_extra_spaces():
assert slugify(" Hello World ") == "hello-world"
'''
async def main() -> None:
async with sandbox.create_sandbox(
execution_time_limit=timedelta(minutes=2),
) as box:
await box.run_process(
"python", ["-m", "pip", "install", "--user", "-q", "pytest"],
check=True,
)
await box.update_network_policy(NetworkPolicy.deny_all())
await box.fs.mkdir("exercise")
async with box.fs.batch(cwd="exercise") as batch:
batch.write_text("solution.py", SOLUTION)
batch.write_text("test_solution.py", TESTS)
result = await box.run_process(
"python", ["-m", "pytest", "-q"],
cwd="exercise",
capture_output=True,
kill_after=60,
)
print(f"Exit code: {result.returncode}")
print(result.stdout)
asyncio.run(main())

Run uv run run_tests.py. The basic case passes, but test_extra_spaces fails because repeated spaces produce repeated hyphens.

  • box.fs.batch() uploads both staged files when its block exits. Use box.fs.write_text() for a single file.
  • Omitting check=True lets the runner return a failing test result without raising CalledProcessError.
  • kill_after=60 sends SIGKILL if pytest runs for 60 seconds. The separate session limit also bounds setup and any remaining processes.

This fixed example captures all output. Step 6 adds a bounded output buffer for requests that contain arbitrary submissions.

Copy link to headingStep 4: Lock the sandbox down

The runner uses two time limits. kill_after limits the pytest process, while execution_time_limit bounds the whole running session, including setup. Choose a session limit that leaves enough time to install dependencies and collect the result.

Network access is separate from process isolation. Step 3 installs pytest while the network is available, then switches to NetworkPolicy.deny_all() before it writes or runs the submitted code.

If a workload needs an external API, allow only its required destination. For example:

from vercel.sandbox import NetworkPolicy
policy = NetworkPolicy.custom(allow={"api.github.com": ()})

Pass this policy as network_policy when creating the sandbox. This example permits the destination without adding credentials. The API access example below adds a credential outside the sandbox.

Copy link to headingStep 5: Start faster with a snapshot

Installing pytest for every submission adds setup time and requires network access. Install it once in a trusted sandbox and save a snapshot, then create test runners from that prepared filesystem. Save the setup script as build_snapshot.py:

build_snapshot.py
import asyncio
from dotenv import load_dotenv
from vercel import sandbox
load_dotenv(".env.local")
async def main() -> None:
box = await sandbox.create_sandbox(persistent=False)
try:
await box.run_process(
"python", ["-m", "pip", "install", "--user", "-q", "pytest"], check=True
)
snapshot = await box.snapshot(expiration=0) # 0 means it never expires
finally:
await box.destroy() # the snapshot outlives the sandbox
print(f"PYTEST_SNAPSHOT_ID={snapshot.id}")
asyncio.run(main())

Run uv run build_snapshot.py and keep the printed PYTEST_SNAPSHOT_ID. Taking the snapshot stops the session. The snapshot remains stored until you delete it because this example sets expiration=0.

The script uses await to create the sandbox and calls destroy() explicitly. This preserves the snapshot. Exiting async with sandbox.create_sandbox() also removes orphaned snapshots, which would remove the prepared snapshot before another sandbox uses it.

Use the saved ID as snapshot_id in the following excerpt inside an async function. The new runner starts with pytest installed and network access denied:

from datetime import timedelta
from vercel.sandbox import NetworkPolicy, SnapshotSource
async with sandbox.create_sandbox(
source=SnapshotSource(snapshot_id=snapshot_id),
network_policy=NetworkPolicy.deny_all(),
execution_time_limit=timedelta(minutes=2),
) as box:
... # pytest is already installed

For a workspace that needs to retain edits between runs, use a persistent sandbox instead of creating a fresh runner each time. The workspace example below shows how to retrieve it by name.

Copy link to headingStep 6: Serve it from FastAPI and deploy

Save the endpoint in main.py at the project root. It requires a shared bearer token for this demonstration, limits each submitted file to 100,000 characters, and retains at most 20,000 characters of combined output.

The client supplies both the solution and tests, so passed reports the process exit status. Submitted code can alter files or exit deliberately; trusted grading needs an independent evaluation design.

main.py
import io
import os
import secrets
import subprocess
from datetime import timedelta
import pydantic
from fastapi import Depends, FastAPI, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from vercel import sandbox
from vercel.sandbox import NetworkPolicy, SnapshotSource
app = FastAPI()
SNAPSHOT_ID = os.environ["PYTEST_SNAPSHOT_ID"]
RUNNER_TOKEN = os.environ["RUNNER_TOKEN"]
if not RUNNER_TOKEN:
raise RuntimeError("RUNNER_TOKEN must not be empty")
bearer = HTTPBearer()
def require_runner_token(
credentials: HTTPAuthorizationCredentials = Depends(bearer),
) -> None:
if not secrets.compare_digest(
credentials.credentials.encode(), RUNNER_TOKEN.encode()
):
raise HTTPException(status_code=401, detail="Invalid runner token")
class CappedOutput(io.StringIO):
def __init__(self, limit: int = 20_000) -> None:
super().__init__()
self.limit = limit
self.truncated = False
def write(self, value: str) -> int:
remaining = max(0, self.limit - self.tell())
super().write(value[:remaining])
self.truncated |= len(value) > remaining
return len(value)
class Submission(pydantic.BaseModel):
solution: str = pydantic.Field(max_length=100_000)
tests: str = pydantic.Field(max_length=100_000)
class Result(pydantic.BaseModel):
passed: bool
output: str
truncated: bool
@app.post("/api/check", dependencies=[Depends(require_runner_token)])
async def check(submission: Submission) -> Result:
output = CappedOutput()
async with sandbox.create_sandbox(
source=SnapshotSource(snapshot_id=SNAPSHOT_ID),
network_policy=NetworkPolicy.deny_all(),
execution_time_limit=timedelta(minutes=2),
) as box:
await box.fs.mkdir("exercise")
async with box.fs.batch(cwd="exercise") as batch:
batch.write_text("solution.py", submission.solution)
batch.write_text("test_solution.py", submission.tests)
result = await box.run_process(
"python", ["-m", "pytest", "-q"],
cwd="exercise",
stdout=output,
stderr=subprocess.STDOUT,
kill_after=60,
)
return Result(
passed=result.returncode == 0,
output=output.getvalue(),
truncated=output.truncated,
)

The context manager destroys the sandbox after the run. The capped text buffer limits output retained by this app; it does not prevent the sandbox from generating logs or eliminate the cost of receiving them. Add per-user authorization and rate limits before offering the runner to multiple users.

Add FastAPI, then save the snapshot ID and a randomly generated RUNNER_TOKEN in the project's Development, Preview, and Production environments. Enter the values at the CLI prompts:

uv add fastapi
vercel env add PYTEST_SNAPSHOT_ID
vercel env add RUNNER_TOKEN development
vercel env add RUNNER_TOKEN preview
vercel env add RUNNER_TOKEN production
vercel pull --environment=development
vercel dev

Use a different runner token for production. Keep it on trusted clients and servers, rather than embedding it in browser JavaScript. vercel dev uses the linked project's development environment.

In another terminal, set RUNNER_TOKEN to the development value and send a request:

curl -X POST http://localhost:3000/api/check \
-H "Authorization: Bearer $RUNNER_TOKEN" \
-H "Content-Type: application/json" \
-d '{"solution": "def add(a, b):\n return a + b\n", "tests": "from solution import add\n\ndef test_add():\n assert add(2, 3) == 5\n"}'

Expect passed: true and truncated: false, with the pytest result in output. Requests without a valid token are rejected before they create a sandbox.

Confirm the Edgenivo Function's maximum duration leaves time for sandbox startup, the 60-second test run, and cleanup. Then commit uv.lock and deploy:

vercel deploy # preview
vercel deploy --prod # production

For Sandbox access, the Edgenivo Python runtime passes each request's OIDC token to the SDK automatically. This is separate from RUNNER_TOKEN, which controls who may call your code-runner endpoint.

To see what your app has running, sandbox.query_sandboxes() lists sandboxes with their status. Pass tags={...} when you create them, and you can filter by tag later.

Copy link to headingUse cases

Use caseBuilding blocksExample
Code checking and gradingfs.batch, run_process, deny_all, snapshotsSteps 3 to 6
Running code an AI model wroteAn AI SDK tool wrapping create_sandboxBelow
Live previews of apps and branchesGitSource, ports, create_process, routesBelow
Per-user development workspacesget_or_create_sandbox, persistent=TrueBelow
Calling APIs without exposing keysNetworkPolicy.custom with a header transformBelow
Long jobs with live outputcreate_process and its stdout streamBelow

Copy link to headingRun code an AI model wrote

Install the AI SDK for Python with uv add ai.

This tool reuses CappedOutput in main.py:

main.py
import ai
@ai.tool
async def run_python(code: str) -> str:
"""Run Python in an isolated sandbox and return bounded process output."""
if len(code) > 100_000:
return "Code exceeds the 100,000-character limit."
output = CappedOutput()
async with sandbox.create_sandbox(
network_policy=NetworkPolicy.deny_all(),
execution_time_limit=timedelta(minutes=2),
) as box:
await box.fs.write_text("main.py", code)
result = await box.run_process(
"python", ["main.py"],
stdout=output,
stderr=subprocess.STDOUT,
kill_after=30,
)
suffix = "\n[Output truncated]" if output.truncated else ""
return f"Exit code: {result.returncode}\n{output.getvalue()}{suffix}"

Pass the tool to ai.Agent(tools=[run_python]). Python exceptions printed by the sandbox are returned with the process output. Authentication, provisioning, and transport errors still need error handling in the application that runs the agent.

Copy link to headingPreview an app from a Git branch

Clone a repo into the sandbox, start its dev server in the background, and hand someone the URL:

from datetime import timedelta
from vercel.sandbox import GitSource
box = await sandbox.create_sandbox(
source=GitSource(url="https://github.com/vercel/sandbox-example-next.git", depth=1),
ports=[3000],
execution_time_limit=timedelta(minutes=30),
)
repo = "sandbox-example-next" # cloned into a folder named after the repo
await box.run_process("npm", ["install"], cwd=repo, check=True)
server = await box.create_process("npm", ["run", "dev"], cwd=repo)
preview_url = next(route.url for route in box.routes if route.port == 3000)

GitSource clones into a folder named after the repository, so the process uses that folder as cwd. create_process() returns a process handle before the command exits; inspect its output and confirm the URL responds before sharing the preview.

Run this excerpt in an async function. Clean up with await box.destroy(delete_orphan_snapshots=True) when the preview is no longer needed, or use stop() to retain it for reuse.

Copy link to headingGive each user a workspace that persists

Choose user_id from your server's authenticated user record, then derive a stable sandbox name. The name identifies the workspace; your app must still enforce who may use it. Run the following inside an async function:

from vercel.sandbox import SnapshotRetention
box, created = await sandbox.get_or_create_sandbox(
name=f"workspace-{user_id}",
persistent=True,
snapshot_retention=SnapshotRetention(count=1),
)
try:
if created:
await box.fs.write_text("notes.md", "# Workspace notes\n")
notes = await box.fs.read_text("notes.md")
finally:
await box.stop()

Stopping the session saves its filesystem. Retrieving the named sandbox resumes it by default. Coordinate concurrent requests for the same workspace so one request does not stop another request's work, and make any required initialization safe to retry after a failure.

SnapshotRetention(count=1) limits the number of retained snapshots. Storage can still grow as workspace files grow.

For data shared between sandboxes, mount a Edgenivo Drive.

Copy link to headingLet the sandbox call an API without seeing the key

The firewall can inject an authorization header after a request leaves the sandbox. Keep the token in your application environment and give it only the GitHub permissions the task needs. Run this excerpt inside an async function:

import os
from vercel.sandbox import NetworkPolicy, NetworkPolicyRule, NetworkPolicyTransform
policy = NetworkPolicy.custom(
allow={
"api.github.com": [
NetworkPolicyRule(
transform=[
NetworkPolicyTransform(
headers={
"Host": "api.github.com",
"Authorization": f"Bearer {os.environ['GITHUB_TOKEN']}",
}
)
]
)
]
}
)
async with sandbox.create_sandbox(network_policy=policy) as box:
result = await box.run_process(
"curl", ["--fail", "https://api.github.com/user"],
capture_output=True,
check=True,
)

The sandbox can reach the allowed API and use the injected token's permissions even though it cannot read the token itself. Domain access does not restrict the process to a particular API operation. Use a scoped token or an application proxy when the workload must be limited to specific actions.

Copy link to headingStream output from long jobs

Use create_process() to consume output while a command runs. Merge stderr into stdout when both streams belong in the same log display:

import subprocess
process = await box.create_process(
"python", ["-m", "pytest", "-v"],
cwd="exercise",
stderr=subprocess.STDOUT,
kill_after=60,
)
assert process.stdout is not None
async for line in process.stdout:
print(line, end="")
exit_code = await process.wait()

This excerpt assumes the exercise folder and its tests already exist. It suits known test output. For arbitrary code, use the bounded writer from Step 6 because line-based reading can buffer a long line before yielding it.

Copy link to headingMake it durable

For a process that runs code, tests it, and waits for review, use the Python Workflow SDK to coordinate the stages. Put external operations in workflow steps and make their side effects safe to retry. Retrying a step can create another sandbox or repeat a command, so persist the identifiers or results needed to recover safely.

The notebook app combines Sandbox, the AI SDK for Python, and Workflows.

Copy link to headingWhen to use something else

  • Run trusted application code directly in a Edgenivo Function when it does not need a separate execution environment.
  • Use Edgenivo Daemons for continuously running workers, or Edgenivo Queues for message-triggered work.
  • Use sandbox connect when you want an interactive terminal from the command line.

Copy link to headingNext steps

Related documentation

More Edgenivo Sandbox guides