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:
- Capabilities are enumerated, never inherited. No ambient
fetch,process, orrequire, and deliberately noDate.now/Math.random— both are covert channels. A model that knows this writes different code; one that does not writes code that fails. 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 aCompartmentin an un-hardened realm is not a boundary at all. The bootstrap hardens, verifies, and refuses to evaluate otherwise.- 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.
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.
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. |