# Sandboxed Code Execution (AIG-634)

aistack ships a pluggable sandbox layer so the `coder` agent — and any other
component that needs to run model-generated code — can do so in isolation
instead of on the host machine.

Three backends are bundled:

| Provider  | Where it runs        | Cost      | When to use |
|-----------|----------------------|-----------|-------------|
| `docker`  | Local Docker engine  | Free      | Default for self-hosted dev / CI |
| `e2b`     | E2B managed cloud    | Metered   | No local Docker, want managed isolation |
| `daytona` | Self-hosted Daytona  | Self-host | Already running Daytona for dev environments |
| `none`    | Disabled (refuses)   | —         | Default — opt-in by switching this |

All adapters implement the same interface:

```ts
sandbox.run(code, { language, timeout, env, files, network }) → { stdout, stderr, exitCode, timedOut, durationMs, provider }
```

## Setup

### 1. Pick a provider in `aistack.config.json`

```jsonc
{
  "sandbox": {
    "provider": "docker",     // 'none' | 'docker' | 'e2b' | 'daytona'
    "timeout": 30000,          // wall-clock ms (default 30s)
    "memoryMb": 512,           // Docker only
    "cpus": 1,                 // Docker only
    "pidsLimit": 100,          // Docker only
    "network": false           // false = --network=none (default)
  }
}
```

### 2. (E2B) Install the SDK and set the API key

```bash
npm install --save-optional @e2b/code-interpreter
export E2B_API_KEY=...
```

```jsonc
{ "sandbox": { "provider": "e2b" } }
```

### 3. (Daytona) Point at your server

```jsonc
{
  "sandbox": {
    "provider": "daytona",
    "daytonaApiUrl": "https://daytona.internal.example.com/api"
  }
}
```

```bash
export DAYTONA_API_KEY=...
```

## Using it from code

```ts
import { createSandbox, getConfig } from '@blackms/aistack';

const sandbox = createSandbox(getConfig().sandbox);

const result = await sandbox.run('print("hello")', {
  language: 'python',
  timeout: 5_000,
});

console.log(result.exitCode, result.stdout);
```

The coder agent's system prompt (`src/agents/definitions/coder.ts`) tells the
model the sandbox is available when configured. The agent decides per-task
whether to use it — execution is **opt-in** so existing workflows keep working.

## Security Model

Code generated by an LLM is **untrusted input**. This section enumerates the
threats we defend against and the mitigations applied by each adapter. It is
the source of truth — any contributor changing `src/sandbox/*.ts` MUST keep
this section in sync.

### Threat model (who attacks what)

- **Attacker**: a malicious or compromised LLM emits code intended to exfil
  data, persist on the host, escape the container, or exhaust resources.
- **Asset**: the host machine running aistack — its filesystem, network
  reachability (private networks, cloud metadata), env vars (API keys), and
  uptime.
- **Trust boundary**: the call into `sandbox.run(code, ...)`. Anything before
  is trusted, anything after is untrusted.

### Mitigations (Docker adapter)

| # | Attack vector                          | Mitigation in `DockerSandbox` |
|---|----------------------------------------|--------------------------------|
| 1 | Command injection via `code`           | Code is passed via **stdin** to the interpreter (`python3 -`, `node -`, `sh -s`). It is **never** concatenated into a shell command on the host. |
| 2 | Container escape (privileges)          | `--cap-drop=ALL`, `--security-opt no-new-privileges`, `--user 65534:65534` (nobody), no `--privileged`, no `--cap-add`. |
| 3 | Resource exhaustion (CPU / RAM / fork) | `--memory=<m>m`, `--memory-swap=<m>m` (no swap escape), `--cpus=<c>`, `--pids-limit=<n>`, plus a hard wall-clock timeout that SIGKILLs the CLI. |
| 4 | Secret leak (host env vars)            | Env is **allowlist-only**. Caller passes `opts.env`; host env is never forwarded. Env var names are regex-validated to prevent metacharacter injection into `-e`. `--env-file` is forbidden. |
| 5 | Filesystem escape via volume mount     | No `-v` / `--volume` flag is ever emitted. Only `--tmpfs /tmp` and `--tmpfs /sandbox`, each `size=64m` and (`/tmp`) `noexec,nosuid`. Rootfs is `--read-only`. |
| 6 | Network exfiltration                   | Default `--network=none`. Caller may opt-in with `opts.network=true` → `--network=bridge`. **`--network=host` is never emitted.** |
| 7 | Container persistence                  | `--rm` always set; container is reaped at exit. |

The argv builder (`buildDockerArgs`) is pure and is asserted by
`tests/unit/sandbox/security.test.ts` against all the "NEVER" rules above —
that test must pass on every PR touching the Docker adapter.

### Mitigations (managed providers)

E2B and Daytona delegate isolation to the provider's runtime
(Firecracker micro-VMs for E2B, container/devcontainer runtime for Daytona).
The aistack adapters are responsible for:

- Reading API keys **only** from explicit config or env vars
  (`E2B_API_KEY`, `DAYTONA_API_KEY`). Never hard-coded, never logged.
- Forwarding only the caller's `opts.env` allowlist — host env is never
  passed through implicitly.
- Applying `opts.timeout` as a hard wall-clock (AbortController for Daytona,
  `timeoutMs` for E2B) and reaping the remote sandbox in a `finally` block.
- Surfacing missing SDK / API key as `SandboxUnavailableError` rather than
  silently doing nothing.
- Never including the API key in error messages (asserted by
  `tests/unit/sandbox/daytona.test.ts`).

### Things we explicitly do NOT do

- `--privileged` Docker containers
- `--cap-add` of any capability
- `--device` mounts
- `--pid=host`, `--network=host`, `--ipc=host`
- Host-path `--volume` mounts (only `tmpfs`)
- `--env-file` or implicit host env forwarding

If you need any of the above, write a separate adapter — do not loosen the
defaults of the bundled ones.

## When to pick which provider

- **Docker** — default for self-hosted dev and CI. Free, well-understood,
  the security flags above are enforced by the argv builder.
- **E2B** — pick when you don't run Docker locally (e.g. lightweight laptop
  setups, restricted CI), or when you specifically want Firecracker-grade
  isolation. Bring your own `E2B_API_KEY`.
- **Daytona** — pick when your organisation already runs Daytona for dev
  environments and you want sandbox runs to live alongside them.
- **none** — the default. Sandboxing is opt-in; if a caller invokes
  `sandbox.run(...)` while disabled, it throws `SandboxPolicyError` so you
  notice instead of silently running unsandboxed.

## Troubleshooting

- **`SandboxUnavailableError: docker CLI not found`** — install Docker
  Desktop or the Docker engine, or switch provider to `e2b` / `daytona`.
- **`SandboxUnavailableError: E2B_API_KEY not set`** — export the env var
  or set `sandbox.e2bApiKey` (env is preferred, do not commit keys).
- **Tests skipping** — `docker.test.ts` skips when no daemon is reachable,
  `e2b.test.ts` skips when `E2B_API_KEY` is unset. This is intentional.
- **`pip install` fails in a Python sandbox** — by default `network: false`.
  Enable network on the specific run: `sandbox.run(code, { language: 'python', network: true })`.
