Sandboxed Execution
Run untrusted code safely with Docker containers and WASM isolation.
Overview
The @cogitator-ai/sandbox package provides isolated execution for agent-generated code. It ships three backends -- Docker containers, WASM via Extism, and a native fallback -- managed through SandboxManager.
pnpm add @cogitator-ai/sandboxdockerode (Docker) and @extism/extism (WASM) are optional dependencies, loaded lazily; when one is missing or its backend is unreachable, the manager simply has fewer executors.
SandboxManager
Initializes the available executors and routes each request to the one its type names; see Fallback for what happens when that executor is missing. Every call returns a SandboxResult — { success: true, data } with stdout, stderr, exitCode, timedOut and duration, or { success: false, error }.
import { SandboxManager } from '@cogitator-ai/sandbox';
const sandbox = new SandboxManager({
allowNativeFallback: false,
pool: { maxSize: 10, idleTimeoutMs: 60_000 },
defaults: {
timeout: 30_000,
resources: { memory: '256MB', cpus: 0.5, pidsLimit: 100 },
network: { mode: 'none' },
},
});
await sandbox.initialize();
const result = await sandbox.execute(
{ command: ['python3', '-c', 'print("hello from sandbox")'] },
{ type: 'docker', image: 'python:3.12-slim' }
);
if (result.success) {
console.log(result.data.stdout, result.data.exitCode);
} else {
console.error(result.error);
}
await sandbox.shutdown();Sandboxed Tools
Give Cogitator a sandbox config and declare sandbox on a tool to run it in isolation. The runtime creates one SandboxManager on first use and shuts it down in cog.close():
import { Cogitator, tool } from '@cogitator-ai/core';
import { z } from 'zod';
const cog = new Cogitator({
sandbox: { defaults: { timeout: 30_000, resources: { memory: '256MB' } } },
});
const shell = tool({
name: 'run_command',
description: 'Run a shell command in an isolated container',
parameters: z.object({ command: z.string() }),
execute: async ({ command }) => ({ command }),
sandbox: { type: 'docker', image: 'alpine:3.19' },
timeout: 15_000,
});For a docker tool, the runtime does not call execute: it runs sh -c <args.command> in the container (with args.cwd and args.env when present) and returns { stdout, stderr, exitCode, timedOut, duration, command }. For a wasm tool, the arguments are passed as JSON on the module's input and its stdout is parsed as JSON when possible. The runtime passes sandbox.allowNativeFallback on: when Docker is unavailable — or @cogitator-ai/sandbox is not installed or fails to start — a docker tool runs its execute function unsandboxed on the host with a warning, unless allowNativeFallback: false turns the call into a tool error. A wasm tool whose WASM executor is unavailable fails; it runs its own execute function only when @cogitator-ai/sandbox is missing or fails to start. See Custom Tools for the tool side.
DockerSandboxExecutor
Runs commands inside isolated Docker containers. Containers are created with all capabilities dropped, no-new-privileges enforced, and network disabled by default. Missing images are pulled on first use; without image the executor uses alpine:3.19, and commands run in /workspace unless cwd / workdir say otherwise.
import { DockerSandboxExecutor } from '@cogitator-ai/sandbox';
const executor = new DockerSandboxExecutor({
docker: { socketPath: '/var/run/docker.sock' },
pool: { maxSize: 5, idleTimeoutMs: 60_000 },
});
await executor.connect();
const result = await executor.execute(
{ command: ['node', '-e', 'console.log(2 + 2)'], timeout: 10_000 },
{
type: 'docker',
image: 'node:22-alpine',
resources: { memory: '128MB', cpus: 0.5, pidsLimit: 50 },
network: { mode: 'none' },
}
);
await executor.disconnect();Security Defaults
| Setting | Value | Purpose |
|---|---|---|
CapDrop | ALL | Drop all Linux capabilities |
SecurityOpt | no-new-privileges | Prevent privilege escalation |
NetworkMode | none | No network access |
PidsLimit | 100 | Prevent fork bombs |
resources.memory accepts strings like '256MB' or '1GB' (parseMemory()), cpus is converted to Docker NanoCPUs (cpusToNanoCpus()), and cpuShares, mounts ({ source, target, readOnly }), user and env are passed through. The default timeout is 30 seconds.
Commands are argv arrays executed without a shell (use ['sh', '-c', '...'] for shell syntax). Output is demultiplexed frame by frame and capped at 50 000 bytes per stream, timed-out containers are destroyed, network.dns is applied, and network.allowedHosts is rejected because Docker cannot enforce an egress allow-list. Containers carry the ai.cogitator.sandbox=true label.
Finding the Daemon
Without docker.socketPath or docker.host, the executor looks for the daemon the way the docker CLI does and uses the first candidate that answers a ping:
DOCKER_HOST, when set (passed to Dockerode, nothing else is tried)- The endpoint of the current Docker context (
DOCKER_CONTEXT, elsecurrentContextin$DOCKER_CONFIG/config.json, default~/.docker) - The sockets that exist among
/var/run/docker.sock(Docker Engine),~/.docker/run/docker.sock(Docker Desktop),~/.orbstack/run/docker.sock(OrbStack),~/.colima/default/docker.sockand~/.colima/docker.sock(Colima),~/.rd/docker.sock(Rancher Desktop) and$XDG_RUNTIME_DIR/docker.sock(rootless Docker)
When none of them exists, Dockerode's defaults are tried. An explicit socketPath or host/port is the only candidate.
WasmSandboxExecutor
Runs code inside WASM modules via Extism. Every plugin runs in its own worker thread, so timeouts terminate runaway modules (the result has timedOut: true and exit code 124); concurrent executions use separate plugin instances.
import { WasmSandboxExecutor } from '@cogitator-ai/sandbox';
const executor = new WasmSandboxExecutor({ wasm: { cacheSize: 10, memoryPages: 512 } });
await executor.connect();
const result = await executor.execute(
{ command: [], stdin: JSON.stringify({ code: 'return 2 + 2' }) },
{
type: 'wasm',
wasmModule: 'https://example.com/sandbox.wasm',
wasmFunction: 'run',
wasi: true,
}
);
await executor.disconnect();The module's export named by wasmFunction (or wasm.functionName, default run) is called with stdin as input — or, without stdin, with { command, cwd, env } as JSON. When it returns a JSON object with stdout, stderr and exitCode those fields are used; any other output becomes stdout. wasm.wasmModule sets a default module for requests that do not name one.
Modules load from http(s) URLs, file paths, or package paths. A path that is absolute or starts with . is read relative to the working directory. Anything else is first tried as a file relative to the working directory, then resolved like an import made by your application, for example @cogitator-ai/wasm-tools/wasm/calc.wasm: from the working directory, then from the entry script's directory, then from @cogitator-ai/sandbox itself (covering NODE_PATH and hoisted installs). That works with pnpm's strict layout too. An absolute path from a helper such as getWasmPath('calc') from @cogitator-ai/wasm-tools needs no resolution at all.
Idle plugins are pooled per module, WASI flag and allowed hosts (up to cacheSize, default 10). network.allowedHosts feeds Extism's HTTP allow-list; network.mode: 'none' clears it. memoryPages (default 256 = 16 MB) caps the module's own memory: its memory section gets that maximum before the module loads, so memory.grow past it fails inside the module, and a module that needs more to start is refused. Extism's memory for plugin input, output and vars gets the same limit. Modules from a URL are fetched by the executor so the cap applies to them too.
ContainerPool
Keeps Docker containers ready for executions. Containers stay alive with sleep infinity and commands run via docker exec. By default every execution gets a container nothing ran in before: a released container is destroyed and a fresh one with the same settings is started in its place, so the next execution finds a warm spare instead of waiting for startup. Nothing an execution leaves behind — files, processes — is visible to the next run or user.
import Docker from 'dockerode';
import { ContainerPool } from '@cogitator-ai/sandbox';
const pool = new ContainerPool(new Docker(), {
maxSize: 10,
idleTimeoutMs: 120_000,
});
const container = await pool.acquire('python:3.12-slim', {
memory: 256 * 1024 * 1024,
cpus: 1,
networkMode: 'none',
});
await pool.release(container);
await pool.release(container, { corrupted: true });memory is in bytes here. Idle containers are cleaned up after idleTimeoutMs; destroyAll() removes every container and closes the pool. maxSize caps the idle and warming containers, not the active ones: when it is reached, no spare is started. Defaults: maxSize 5, idleTimeoutMs 60 000, reuseContainers false. A container only serves requests with identical image, resources, network mode, DNS, mounts and user.
reuseContainers: true (pool.reuseContainers on the manager or executor) returns a released container to the pool as it is, the older behaviour: faster, but whatever one execution leaves in the container is visible to the next, including other runs and users. Releasing with corrupted: true always destroys the container.
Fallback
SandboxManager falls back in one case only:
- Docker requested, Docker unavailable → the command runs on the host through the native executor, with no isolation, and the first such call logs a warning.
allowNativeFallback: falserefuses instead with{ success: false, error }. The option defaults totrue. - WASM requested, WASM unavailable →
{ success: false, error }asking you to install@extism/extism. Nothing else can run a WASM module. - Native requested → always available, no isolation.
Manager defaults apply to fallback executions too, and initialization is shared by concurrent callers. The native executor runs a single-element command through the shell and longer commands with exact argv, pipes stdin, inherits only PATH, HOME, temp-dir, locale and Windows system variables from the host, and kills the whole process group on timeout. Check availability first when untrusted code must never run on the host:
const hasDocker = await sandbox.isDockerAvailable();
const hasWasm = await sandbox.isWasmAvailable();