Data, safety and extensions
Sandboxes
Isolated Linux machines for agents to run commands and handle files in, on Docker or a hosted provider, with no network unless allowed and permission-gated tools.
mayura/sandbox gives an agent an isolated Linux machine: it runs commands, reads and writes files and, on some
providers, serves ports or shows a desktop. Docker on your own machine is built in (mayura/sandbox/docker); hosted
providers come as @mayurajs/sandbox-* packages. Every sandbox has a lifetime, and none reaches the network unless
you allow it.
import { createSandboxes } from 'mayura/sandbox';
import { dockerSandboxes } from 'mayura/sandbox/docker';
const sandboxes = createSandboxes(dockerSandboxes({ image: 'node:24-slim' }), {
maxSandboxes: 4,
maxLifetimeMs: 30 * 60_000,
});
const sandbox = await sandboxes.create({ lifetimeMs: 10 * 60_000 });
try {
await sandbox.writeFile('index.js', 'console.log(6 * 7)');
const result = await sandbox.exec(['node', 'index.js']);
console.log(result.exitCode, result.stdout); // 0 '42\n'
} finally {
await sandbox.release();
}This is not Code Mode, which runs one model-written JavaScript program against your tools. A sandbox is a whole machine for an agent to work in: installing packages, running tests, building things.
Limits
createSandboxes(provider, options) holds every sandbox from a provider to the same limits.
| Option | Notes |
|---|---|
maxSandboxes |
Required. The most sandboxes alive at once, counting those being created; more fail with LIMIT_EXCEEDED. |
maxLifetimeMs |
Required. The longest lifetime a sandbox may be given; at most the provider's. |
network |
The network kinds sandboxes may be given: ['none'] by default. See Network. |
execTimeoutMs |
How long a command runs when the caller does not say; 60 s by default. |
maxExecTimeoutMs |
The longest a caller may let a command run; 30 minutes by default. |
maxOutputBytes |
The most bytes of stdout, and of stderr, kept from one command; 1 MiB by default. More is dropped and truncated is set. |
maxFileBytes |
The largest file read or written, and the most standard input; 16 MiB by default. Files are held in memory. |
callTimeoutMs |
How long any other provider call may take; 120 s by default. |
labels |
Labels given to every sandbox, to find them in the provider's console. |
sandboxes.close() releases every sandbox still alive and refuses new ones.
A sandbox
sandboxes.create(options) takes lifetimeMs (required), and optionally network, env (variables every command
sees), ports, cpus, memoryMiB, image (the provider's image or template) and labels. The provider ends the
sandbox when its lifetime is over, if release() did not end it first; after that every call fails with a
SandboxError whose reason is gone.
| Method | Notes |
|---|---|
exec(command, options) |
Runs command, a list of arguments with the program first: ['npm', 'test'], or ['sh', '-c', 'npm install && npm test'] for a shell. Options: cwd, env, stdin (text or bytes), timeoutMs, signal. |
readFile(path) |
The file's bytes, or undefined when there is none. maxBytes refuses larger files. |
writeFile(path, data) |
Writes text or bytes, creating directories and replacing any file there. |
listFiles(path) |
The directory's entries (name, type, size, modified), or undefined when there is none. |
removeFile(path) |
Removes a file; a directory with what is in it needs recursive: true. |
url(port) |
The URL serving one of the ports the sandbox was created with. |
desktop |
On providers with a desktop: screenshot, click, move, scroll, type, key, size and viewUrl. |
release() |
Ends the sandbox. Releasing again does nothing. |
Paths are absolute, or relative to the sandbox's workdir. exec resolves with the exit code, stdout and stderr
as text, truncated and durationMs. A command still running at its timeout is stopped, with every process it
started, and resolves with timedOut: true and no exit code. Cancelling with signal stops it too, and fails with
CANCELLED.
import { createSandboxes } from 'mayura/sandbox';
import { dockerSandboxes } from 'mayura/sandbox/docker';
const sandboxes = createSandboxes(dockerSandboxes({ image: 'node:24-slim' }), { maxSandboxes: 1, maxLifetimeMs: 600_000 });
const sandbox = await sandboxes.create({ lifetimeMs: 600_000, env: { NODE_ENV: 'test' } });
const tests = await sandbox.exec(['npm', 'test'], { cwd: 'app', timeoutMs: 120_000 });
if (tests.timedOut) console.log('The tests took too long.');
else if (tests.exitCode !== 0) console.log(tests.stderr);
await sandbox.release();What each provider supports beyond commands and files is in sandbox.features: stdin, ports, desktop, and
the network kinds it can enforce. Asking for something a provider does not support fails with INVALID_INPUT
before anything is created.
Network
A sandbox reaches nothing by default. Its network is one of:
'none': no network at all. The default.'all': the internet, and servingports.{ allow: ['registry.npmjs.org', '*.github.com'] }: only these domains, on providers that filter by domain.
Each kind other than 'none' must be listed in the network option of createSandboxes; asking for one that is
not fails with PERMISSION_DENIED. So permission to reach the network is given once, where the sandboxes are set up,
never by whoever creates a sandbox.
A provider that cannot keep sandboxes off the network (Fly Machines, for one) leaves 'none' out of its
features.network. Its sandboxes are then created only with 'all', listed in createSandboxes and asked for;
the default fails with INVALID_INPUT instead of making a sandbox that is online when you expect it offline.
import { createSandboxes } from 'mayura/sandbox';
import { dockerSandboxes } from 'mayura/sandbox/docker';
const sandboxes = createSandboxes(dockerSandboxes({ image: 'node:24-slim' }), {
maxSandboxes: 2, maxLifetimeMs: 600_000, network: ['none', 'all'],
});
const server = await sandboxes.create({ lifetimeMs: 600_000, network: 'all', ports: [3000] });
await server.exec(['sh', '-c', 'setsid nohup npx serve -l 3000 . >/dev/null 2>&1 < /dev/null &']);
console.log(await server.url(3000));Start a process that must outlive its command, such as a server, with setsid as above: some providers (Daytona, for
one) end everything in a command's process group when the command returns.
Tools for agents
sandboxTools(sandbox, { name }) makes tools that let an agent work in a sandbox. Only reading is on by default;
each further power is enabled by its own option and needs its own permission.
| Tool | Option | Permission | Does |
|---|---|---|---|
<name>.read |
always | sandbox:<name>:read |
Reads a file: text as text, other bytes as base64, in parts. |
<name>.list |
always | sandbox:<name>:read |
Lists a directory. |
<name>.exec |
exec: true |
sandbox:<name>:exec |
Runs a shell command with sh -c. |
<name>.write, <name>.remove |
write: true |
sandbox:<name>:write |
Writes and removes files. |
<name>.url |
ports: true |
sandbox:<name>:ports |
Gives the URL of a port the sandbox serves. |
<name>.screenshot, .click, .scroll, .type, .key |
desktop: true |
sandbox:<name>:desktop |
Sees and uses the desktop. |
Other options: execTimeoutMs (5 minutes by default), execCostMicros (what one command costs against the run's
budget; 0 by default), maxOutputBytes (32 KiB of each stream returned to the model, the middle of longer output
left out), maxReadBytes and maxWriteBytes.
Most agents need a sandbox of their own for each run, so one run's files never reach another. sandboxPerRun
creates the run's sandbox the first time one of its tools needs it; release it when the run ends, from an
onFinally lifecycle hook:
import { defineHook } from 'mayura';
import { createSandboxes, sandboxPerRun, sandboxTools } from 'mayura/sandbox';
import { dockerSandboxes } from 'mayura/sandbox/docker';
const sandboxes = createSandboxes(dockerSandboxes({ image: 'node:24-slim' }), { maxSandboxes: 8, maxLifetimeMs: 3_600_000 });
const perRun = sandboxPerRun(sandboxes, ({ runId }) => ({ lifetimeMs: 3_600_000, labels: { run: runId } }));
const tools = sandboxTools(perRun.source, { name: 'workspace', exec: true, write: true });
const releaseSandbox = defineHook({
id: 'sandbox.release', version: '1', stage: 'onFinally',
handler: async (_event, context) => { await perRun.release(context.runId); },
});Grant the run tool:workspace.exec, sandbox:workspace:exec and the tool's effect (and the same for each other tool),
as for any tool. exec, write, remove and the desktop actions (click, scroll, type,
key) have write effects and need effect:write; read, list, url and screenshot need effect:read.
Docker
dockerSandboxes(options) from mayura/sandbox/docker runs each sandbox as a container on this machine. It runs on
Node, and needs Docker. Each container is locked down:
- no network unless
'all'was allowed and asked for, and then ports published on127.0.0.1only; - every Linux capability dropped, and no privilege escalation (
no-new-privileges); - a non-root user (
1000:1000by default); - the image's file system read-only, with an in-memory working directory and
/tmp; - bounded CPUs, memory (with no swap) and processes;
- removed when released, and when its lifetime is over.
Images are never pulled: a sandbox with an image that is not on the machine fails with INVALID_CONFIG. An image
needs a POSIX shell, sleep and the usual tools, from BusyBox or coreutils, as Alpine, Debian and most language
images have. Environment variables reach commands through a file inside the container, so their values are not in
the container's configuration or on the host's command lines.
| Option | Notes |
|---|---|
image |
Required. The image sandboxes run, such as alpine:3.22 or node:24-slim. |
engine |
'cli' (the default) runs the docker CLI; 'api' talks to the Docker Engine API on its local socket. |
docker |
The docker CLI's path; docker on the PATH by default. |
host |
For the API: unix:///var/run/docker.sock or npipe:////./pipe/docker_engine. DOCKER_HOST, or the usual sockets, by default. Remote hosts are refused. |
user |
Numeric uid:gid; 1000:1000 by default. |
workdir |
The working directory; /workspace by default. |
workspaceMiB, tmpMiB |
The sizes of the working directory (1,024 MiB) and /tmp (256 MiB). They count toward memory. |
cpus, memoryMiB, pids |
What each sandbox gets: 1 CPU, 1,024 MiB and 256 processes by default. A sandbox may ask for less. |
readOnlyRoot |
Keep the image's file system read-only; true by default. |
maxLifetimeMs |
The longest lifetime; 24 hours by default. |
Providers
| Package | Sandboxes | Network | Ports | Desktop |
|---|---|---|---|---|
mayura/sandbox/docker |
Docker containers on your machine | none, all | on 127.0.0.1 | no |
@mayurajs/sandbox-e2b |
E2B Firecracker microVMs from a template | none, all, domains (ports 80 and 443) | public URL | no |
@mayurajs/sandbox-e2b-desktop |
E2B sandboxes with an Xfce desktop, and a live view in the browser | none, all, domains (ports 80 and 443) | public URL | yes |
@mayurajs/sandbox-vercel |
Vercel Sandbox Firecracker microVMs; no standard input | none, all, domains | public URL (1024 and up) | no |
@mayurajs/sandbox-daytona |
Daytona sandboxes from a snapshot | none, all, domains | signed URL | with desktop: true |
@mayurajs/sandbox-fly |
Fly.io Machines in one of your apps | all only | no | no |
@mayurajs/sandbox-agentcore |
Amazon Bedrock AgentCore Code Interpreter sessions | as the interpreter was made | no | no |
@mayurajs/sandbox-modal |
Modal gVisor or VM sandboxes; Node, Deno and Bun | none, all, domains | tunnel URL | no |
@mayurajs/sandbox-railway |
Railway Linux VMs from Railway's default image | all only | Railway domain | no |
@mayurajs/sandbox-northflank |
Northflank microVM services; Node | all only | no | no |
@mayurajs/sandbox-codesandbox |
CodeSandbox private microVMs from a template; Node | all only | host-token URL | no |
@mayurajs/sandbox-cloudflare |
Cloudflare Sandboxes through your sandbox bridge Worker; any runtime | all only | no | no |
@mayurajs/sandbox-apple-container |
Apple container VMs on this Mac (macOS 26, Apple silicon); Node |
all; none as a host-only network you name | no | no |
Writing a provider
A provider implements SandboxProvider: its id, features, workdir and maxLifetimeMs, and create, which
resolves with a SandboxBackend. createSandboxes checks every path, size and option before calling the provider,
and everything the provider returns before the caller sees it. A provider maps failures to SandboxError (use
sandboxHttpFailure(status) for HTTP statuses) without the provider's own text, and stops a command and everything it
started when exec's signal aborts.
A provider whose API only runs commands can do the rest with sandboxScripts from mayura/sandbox: shell scripts that
read, list and remove files, and stop every process of a command by a tag in its environment (sandboxScripts.kill),
with parseSandboxListing for the listing.
A provider whose API runs only short commands and answers with text can get the whole contract from
commandSandboxBackend(id, transport, release): give it a transport whose run(command, { stdin, signal }) runs a
short command and resolves with its exit code and stdout. Commands then start in the background and are polled to
their end, output and files move as base64 in chunks (inside the command line when the transport takes no standard
input), and a timeout or cancellation stops every process a command started. Fly Machines and AgentCore are built on
it.
sandboxConformance from mayura/sandbox/testing runs the contract against a real sandbox:
import { describe, expect, it } from 'vitest';
import { createSandboxes } from 'mayura/sandbox';
import { dockerSandboxes } from 'mayura/sandbox/docker';
import { sandboxConformance } from 'mayura/sandbox/testing';
describe('my sandbox provider', async () => {
const sandboxes = createSandboxes(dockerSandboxes({ image: 'alpine:3.22' }), { maxSandboxes: 1, maxLifetimeMs: 600_000 });
const sandbox = await sandboxes.create({ lifetimeMs: 600_000 });
for (const test of sandboxConformance) it(test.name, async () => { expect(await test.run({ sandbox })).toBe('passed'); });
});