# Sandboxed Execution

URL: https://cogitator.app/docs/deployment/sandbox

> 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`.

```bash
pnpm add @cogitator-ai/sandbox
```

`dockerode` (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](#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 }`.

```typescript
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()`:

```typescript
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](/docs/tools/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.

```typescript
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:

1. `DOCKER_HOST`, when set (passed to Dockerode, nothing else is tried)
2. The endpoint of the current Docker context (`DOCKER_CONTEXT`, else `currentContext` in `$DOCKER_CONFIG/config.json`, default `~/.docker`)
3. 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.sock` and `~/.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.

```typescript
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.

```typescript
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: false` refuses instead with `{ success: false, error }`. The option defaults to `true`.
* **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:

```typescript
const hasDocker = await sandbox.isDockerAvailable();
const hasWasm = await sandbox.isWasmAvailable();
```
