---
url: 'https://adk.nht.io/batteries/sandbox/javascript.md'
description: >-
  Evaluate JavaScript in an SES Compartment with explicit globals, limits, and
  cancellation residuals.
---

# JavaScript evaluator

## LLM summary — evaluate\_javascript

* [`createEvaluateJavascriptTool`](https://adk.nht.io/api/@nhtio/adk/batteries/sandbox/functions/createEvaluateJavascriptTool) evaluates source in an SES guest with explicit `globals`, `modules`, `limits`, and `hostcallQuotas`.
* There is no ambient `fetch`, `process`, `require`, `Date.now`, or `Math.random` unless the embedder injects capabilities.
* Module names are allow-listed in the guest; imported module graphs are not policed.
* Gates are mandatory here too. A gate is a suspension, not a callback decoration; an undecided headless call hangs.

Sometimes an agent has to *run* the code rather than reason about it — a CI reviewer checking what a
function actually does, a snippet whose behaviour is the answer. That code is untrusted by
construction: the model wrote it, and the model read your repository first.

This is the **JS boundary**, and it answers exactly one question: *what can this code reach?* Not what
the process can reach — that is [SRT's question](./run-command.md), and the two are not
interchangeable.

::: danger SES is not an OS sandbox
The evaluator is **in-process code**. SES removes ambient authority from the guest realm; it does not
stop a syscall, and it never sees one. If the snippet's job involves hostile native work, put it behind
SRT **and** a child process. Composing the two is supported and is the point of shipping them apart.
:::

Three consequences follow:

1. **Capabilities are enumerated, never inherited.** No ambient `fetch`, `process`, or `require`, and
   deliberately no `Date.now`/`Math.random` — both are covert channels. A model that knows this writes
   different code; one that does not writes code that fails.
2. **`lockdown()` runs in the guest, not the host.** A worker or child has its own intrinsics, so
   hardening your realm does nothing for it, and a `Compartment` in an un-hardened realm is not a
   boundary at all. The bootstrap hardens, verifies, and refuses to evaluate otherwise.
3. **The module allow-list checks the specifier, not the graph.** Injecting a module grants the guest
   whatever *that module* can reach, transitively. Import graphs are not policed, because doing so
   honestly would mean loading them. Inject pure, computation-only libraries.

## Assemble the tool

The configuration below uses the actual `EvaluateJavascriptConfig` shape. `globals` values are host
functions plus a cancellation classification; `modules` is a specifier-to-value map. Limits and
hostcall quotas are separate budgets and are validated before the guest starts.

```ts
import { createEvaluateJavascriptTool } from '@nhtio/adk/batteries/sandbox'
import type { EvaluateJavascriptConfig } from '@nhtio/adk/batteries/sandbox/js'

const config: EvaluateJavascriptConfig = {
  gate: async (_ctx, call) => {
    if (call.tool !== 'evaluate_javascript') throw new Error('unexpected tool')
    if (!await approvalService.ask(call.args)) throw new Error('declined')
  },
  globals: {
    readConfig: {
      fn: async (args, signal) => fetchConfig(String(args[0]), signal),
      cancellation: 'killable',
    },
  },
  modules: { 'app:constants': { version: 1, mode: 'test' } },
  limits: {
    maxLogEventBytes: 4096,
    maxLogEvents: 100,
    logDrainMs: 100,
    codecMaxDepth: 16,
    codecMaxNodes: 2000,
    maxHostcallBytes: 64_000,
    maxTerminalPayloadBytes: 500_000,
  },
  hostcallQuotas: {
    hostcallTimeoutMs: 2_000,
    maxHostcallsPerEvaluation: 20,
    maxConcurrentHostcalls: 2,
  },
}
const evaluate = createEvaluateJavascriptTool(config)
// Register `evaluate` in the same tool registry as the shell and workspace tools.
```

The model supplies `source` and optional `timeout_seconds` (default 30). The handler returns a JSON
representation of the settled guest outcome. A timeout becomes `E_SES_EVALUATION_TIMEOUT` internally
and is narrated as a sandbox failure. `cooperative` means the guest can choose to cooperate; it is
not a kill switch. `killable` and the runtime boundary are the meaningful containment choices.

## Capability discipline

Every injected global is a deliberate hole in the boundary. That is the mechanism working as intended —
a capability the guest genuinely needs has to come from somewhere — but the shape of the hole matters,
and the convenient shape is the wrong one.

**Do not inject `process`, a filesystem object, or a general-purpose `fetch` to make a demo work.** An
unrestricted host function hands the guest the host process's authority through a name the model can
see. A narrow wrapper validates its arguments, applies the same policy a tool would, and accepts the
abort signal.

**Classify cancellation honestly**, because the three classes make genuinely different promises:
`killable` records and *kills* its child; `cooperative` is best-effort — an `AbortSignal` is a
**request**, and a handler that ignores it outlives the guest; `atomic` must finish, and the runner will
not recycle the guest until it does. There is no default, deliberately: guessing `cooperative` for a
capability that spawns a child is exactly the wrong guess.

On the availability side, the honest split:

| Failure mode | Containable? |
| --- | --- |
| CPU / infinite loop | **Yes** — deadline + real termination, in both environments |
| Memory, on Node | **Contained, not prevented** — size the heap so an OOM is a dead child, not a dead agent |
| Memory, in a browser | **No.** A timer cannot preempt an allocation that exhausts the heap first, and a worker OOM can take the page down with it |

There is **no browser no-op shim for the OS boundary**, deliberately: a shim that degrades to nothing
produces code which *reads* as sandboxed and enforces nothing, which is worse than a build error. In a
browser, this boundary is SES and egress is the embedder's CSP — see
[Browser deployment](./index.md#browser-deployment).

## Troubleshooting: what this failure means

| Symptom | Cause |
| --- | --- |
| Evaluation times out | The guest exceeded `timeout_seconds`; a cooperative callback is not an OS kill. Reduce work or move it behind process isolation. |
| Missing `fetch`, `process`, or `require` | Expected: these are not ambient capabilities. Inject a deliberately narrow global if the policy allows it. |
| Module import succeeds but reaches something unexpected | The requested specifier was allowed, but its import graph is not policed. Treat modules as trusted assembly. |
| Hung turn before evaluation | The mandatory gate had no decider. A gate suspends the turn; headless callers must supply a decision or reject. |
| `E_READER_NOT_DESCRIBABLE` on a later `encode()` | Unrelated to SES: a staged `Media` was retained without `save_media`. |
