Skip to content
4 min read · 818 words

JavaScript evaluator

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, and the two are not interchangeable.

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 modeContainable?
CPU / infinite loopYes — deadline + real termination, in both environments
Memory, on NodeContained, not prevented — size the heap so an OOM is a dead child, not a dead agent
Memory, in a browserNo. 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.

Troubleshooting: what this failure means

SymptomCause
Evaluation times outThe guest exceeded timeout_seconds; a cooperative callback is not an OS kill. Reduce work or move it behind process isolation.
Missing fetch, process, or requireExpected: these are not ambient capabilities. Inject a deliberately narrow global if the policy allows it.
Module import succeeds but reaches something unexpectedThe requested specifier was allowed, but its import graph is not policed. Treat modules as trusted assembly.
Hung turn before evaluationThe 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.