Safe Eval

Sooner or later an application wants to run code it did not write: a formula a user typed, a pricing rule an administrator saved, a snippet a language model produced. JavaScript's eval and new Function will run it, with the full authority of your program: it can read your variables, call your servers, and loop until the page dies. So nobody sensible uses them for this.

Eval and SafeFunction run that code in a sandbox instead, and make three promises:

The sandbox is the same fuel-metered VM that runs AJS agents, exposed as the two calls JavaScript already has: eval() becomes Eval, new Function() becomes SafeFunction. The code that calls it is not guaranteed to halt; the code inside it is.

Three words used throughout

The code calls Your capability It receives
httpFetch({ url, method, … }) capabilities.fetch(url, init) the URL, and the request (below)
llmPredict({ prompt }) capabilities.llm.predict(prompt) the prompt string

Eval — run code once

Eval runs a snippet in the sandbox and returns what it produced, with the fuel it used. The code sees only the context you hand it, as variables.

import { Eval, SafeFunction } from 'tjs-lang/eval'

const { result, fuelUsed } = await Eval({ code: 'a + b', context: { a: 1, b: 2 } })
console.log(result) // → 3
console.log(fuelUsed > 0) // → true

The code is AJS: a JavaScript subset, so most expressions and statements read as you expect, with AJS's rules. The ones a JavaScript author meets first: no for loops (use while with a counter), no new, no class, no throw, arrow functions only as callback arguments (items.filter(x => x > 1) works, a bare x => x does not), assignment only to a plain variable (not obj.key = …), and operators that refuse to turn an array into a string.

One rule decides what Eval returns. If the code contains a return and is not a single expression, it runs as a block of statements and gives back what it returns. Otherwise it is read as one expression, and its value is the result. So a block of statements without a return is read as an expression, fails to parse, and comes back as an error:

const summed = await Eval({
  code: 'let total = a + b\nreturn total * 2',
  context: { a: 1, b: 2 },
})
console.log(summed.result) // → 6

const forgot = await Eval({
  code: 'let total = a + b\ntotal * 2',
  context: { a: 1, b: 2 },
})
console.log(forgot.result) // → undefined
console.log(forgot.error.message) // → The keyword 'let' is reserved at <source>:2:0

The line number in that message is one past the line you wrote, because Eval wraps your code in a function before parsing it. A block whose only return sits inside an if that is not taken runs to the end and returns undefined, with no error.

The context is copied in, never shared. The code can change what it was given, but it is changing its own copy, so the value you passed is untouched:

const tags = ['sale']
const tagged = await Eval({
  code: "tags.push('clearance')\nreturn tags",
  context: { tags },
})
console.log(tagged.result) // → ["sale","clearance"]
console.log(tags) // → ["sale"]

Two details about context keys. A key never replaces a builtin: an entry called Math or NaN is ignored. And only keys the code uses as names are copied in, so a large context costs only what the code reads, and a string literal in the code can never pick up a context value by having the same text. A let or const the code declares shadows a context key of the same name.

SafeFunction — compile once, call many times

SafeFunction turns a function body into a callable. Its parameters are names, and each call returns the same shape as Eval. SafeFunction itself is async, so await it to get the function.

const add = await SafeFunction({ params: ['a', 'b'], body: 'return a + b' })
console.log((await add(1, 2)).result) // → 3
console.log((await add(20, 22)).result) // → 42

The source is transpiled once, when you create the function. The budget belongs to each call, not to the function: every call starts with the full fuel it was given, so one call that runs out does not starve the next.

const countTo = await SafeFunction({
  params: ['n'],
  body: 'let i = 0\nwhile (i < n) {\n  i = i + 1\n}\nreturn i',
  fuel: 50,
})
console.log((await countTo(5)).result) // → 5
console.log((await countTo(5000)).error.message) // → Out of Fuel
console.log((await countTo(5)).result) // → 5

A missing return behaves differently in the two. The body of a SafeFunction is a function body, so without a return it gives undefined and no error, as a JavaScript function would. The same code in Eval is a parse error. Code moved from one to the other can therefore fail silently. If undefined is never a valid answer for your function, treat error === undefined && result === undefined as a failure.

const double = await SafeFunction({ params: ['n'], body: 'n * 2' })
const doubled = await double(3)
console.log(doubled.result, doubled.error) // → undefined undefined

Creating a SafeFunction can throw, unlike everything else on this page. A body that does not parse, or is over the source size limit, makes SafeFunction(...) reject, before any of the code has run. Once created, each call returns its errors as values, like Eval. So when the body comes from someone else, wrap the creation in try.

That makes SafeFunction the right shape for logic someone else writes and you run many times, such as a pricing rule an administrator typed into a settings page:

const ruleSource = 'if (qty >= 10) {\n  return price * qty * 0.9\n}\nreturn price * qty'

let priceRule
try {
  priceRule = await SafeFunction({ params: ['price', 'qty'], body: ruleSource, fuel: 100 })
} catch (e) {
  console.log('This rule has an error: ' + e.message) // show it to the rule's author
}
console.log((await priceRule(4, 10)).result) // → 36
console.log((await priceRule(4, 2)).result) // → 8

The rule is text you can store, version and show to the person who wrote it. It can compute, but it cannot read a file, call a server or loop forever.

Options and results

Option Eval SafeFunction Default Meaning
code yes (required) The snippet to run.
context yes {} Values the code sees as variables, copied in.
body yes (required) The function body.
params yes [] Parameter names; each call's arguments are copied in under these names.
fuel yes yes (per call) 1000 The work budget (see Three words).
timeoutMs yes yes (per call) 4 minutes Wall-clock limit (see Nothing runs forever).
capabilities yes yes none fetch, llm, and any others the code may use.
fetchPolicy yes yes none { domains, methods, headers } for httpFetch (see below).
maxSourceBytes yes yes 8192 Source size cap, checked before transpiling; 0 disables it.
argsMaxBytes yes yes 4 MB Cap on the context or call arguments copied in.

Every run returns { result, fuelUsed, error }. When the run succeeds, error is undefined. When it fails, error is { message }, a single string field.

Check error before you use result. After a failure result is not undefined: it can be the VM's internal error object, whose cause carries a host stack trace with your file paths. Never forward result to a client without checking error first.

Nothing runs forever

A run that spends its fuel is stopped, and the failure comes back as a value:

const runaway = await Eval({
  code: 'let i = 0\nwhile (true) { i = i + 1 }\nreturn i',
  fuel: 100,
})
console.log(runaway.error.message) // → Out of Fuel

Fuel is not the only stop. Waiting on IO burns no fuel, so a wall-clock timeoutMs ends a run that is merely waiting, and cancels the request it was waiting on:

const slow = await Eval({
  code: "return httpFetch({ url: 'https://api.example.com/products' })",
  capabilities: {
    fetch: () => new Promise((resolve) => setTimeout(() => resolve([]), 500)),
  },
  timeoutMs: 50,
})
console.log(slow.error.message.startsWith('Execution timeout after 50ms')) // → true

The default timeout is 4 minutes: twice the 2-minute limit on a single model call. That suits a background job. When a person is waiting on the answer, pass your own timeoutMs.

Memory is bounded separately from work. The code can hold at most 64 MB live at once; past that the run fails with Heap limit exceeded: guest state holds ~96MB, limit 64MB…. Eval and SafeFunction cannot change this limit; AgentVM's maxHeapBytes can.

Source is bounded before any of this starts. Transpiling happens before fuel and the timeout apply, so source over 8 KB is refused unread, as an error like any other:

const longText = `'${'a'.repeat(9000)}'.length`
const tooLong = await Eval({ code: longText })
console.log(tooLong.error.message.includes('over the 8192-byte limit')) // → true

const trusted = await Eval({ code: longText, maxSourceBytes: 16 * 1024 })
console.log(trusted.result) // → 9000

Raise maxSourceBytes only for source you trust, such as code from your own repository.

Errors are values

Every failure inside the sandbox comes back in error: running out of fuel or time, a missing capability, a forbidden property, a bug in the code. A call to Eval, or to a SafeFunction you have already created, never throws, and the code inside cannot make it throw.

const nosy = await Eval({ code: 'cfg.constructor', context: { cfg: {} } })
console.log(nosy.error.message) // → Security Error: Access to 'constructor' is forbidden

const noModel = await Eval({ code: "return llmPredict({ prompt: 'hi' })" })
console.log(noModel.error.message) // → Capability 'llm.predict' missing

The code can catch some failures with try/catch and fall back. Others end the run, even inside a try, so that the code cannot loop on them:

Failure Caught by the code's try?
A capability throws (the network is down, a 500) yes
A domain, method or header the fetch policy does not allow yes
A missing capability yes
A forbidden property (constructor, __proto__) yes
The heap limit yes
Out of fuel no, the run ends
The timeout no, the run ends
A capability returns something that is not plain data no, the run ends

The last row is the one to watch: a capability that throws is the world failing, and the code may handle it; a capability that returns a Response or a class instance is a bug in your host code, and the run stops (see below).

const offline = await Eval({
  code: "try {\n  return httpFetch({ url: 'https://api.example.com/products' })\n} catch (e) {\n  return 'fallback: ' + e\n}",
  capabilities: {
    fetch: async () => {
      throw new Error('network down')
    },
  },
})
console.log(offline.result) // → fallback: network down

In the catch, e is the error's message, a string, not an Error. This differs from JavaScript: e.message is undefined. Use e itself.

Nothing reaches out unless you allow it

The sandbox has no network, no file system and no host globals. What it does have is a small set of builtins: Math, JSON, and the usual methods on strings, arrays and objects. console calls are accepted, but their output goes to the VM's trace, which Eval and SafeFunction do not return; to see a value, return it. There is no atom for reading files at all.

There are two ways to grant network access, and the code uses httpFetch in both:

  1. Inject a fetch capability. The code gets your function, with whatever rules you put in it.
  2. Set fetchPolicy.domains with no fetch capability. That turns on the VM's built-in fetch, for those domains only. It calls the host's fetch, follows no redirects (a redirect comes back to the code as data), and sends no cookies or HTTP auth. The allowlist is the whole destination check, so list only public hosts: a private or loopback address you list (127.0.0.1, say) is fetched.

With neither, httpFetch fails: Fetch blocked: no allowedFetchDomains configured….

What your fetch capability receives and returns

Before your function is called, the VM admits the request's shape: http: or https: only, GET and HEAD unless the policy allows more, and only these request headers: accept, accept-language, authorization, cache-control, content-language, content-type, if-match, if-modified-since, if-none-match, if-unmodified-since, range, user-agent, x-api-key, x-request-id, x-correlation-id and x-requested-with. The list is closed because every proxy invents another routing header, and a header the code may set is one it can use to redirect a request.

Your function is then called as fetch(url, init), where init is { method, headers, body, signal }. The code chose method, headers and body; body is the code's value as data, so JSON.stringify it yourself. Pass signal on, so a timed-out run cancels the request. headers also carries an X-Agent-Depth header (see the known gap).

Your function owns everything the VM cannot judge:

Return the response body as plain data, never the Response itself. If the code needs the status, return { ok, status, body }, built field by field.

In the example below, upstream stands in for the real fetch so that it runs anywhere; it answers with two products, a widget at 40 and a gadget at 250.

// Stand-in for `fetch`. In your app, call fetch(url, options) here.
const upstream = async (url, options) =>
  new Response(JSON.stringify([{ name: 'widget', price: 40 }, { name: 'gadget', price: 250 }]))

const allowed = ['api.example.com']
const safeFetch = async (url, init) => {
  const { protocol, hostname } = new URL(url)
  if (protocol !== 'https:' || !allowed.includes(hostname)) {
    throw new Error(`Domain not allowed: ${hostname}`)
  }
  const { 'X-Agent-Depth': _depth, ...headers } = init.headers // not an agent endpoint
  const res = await upstream(url, {
    method: init.method,
    headers,
    body: init.body === undefined ? undefined : JSON.stringify(init.body),
    signal: init.signal,
    redirect: 'error',
    credentials: 'omit',
  })
  return res.json() // the BODY, not the Response
}

const cheap = await Eval({
  code: `
    let products = httpFetch({ url: 'https://api.example.com/products' })
    return products.filter(x => x.price < budget)
  `,
  context: { budget: 100 },
  capabilities: { fetch: safeFetch },
})
console.log(JSON.stringify(cheap.result)) // → [{"name":"widget","price":40}]

const blocked = await Eval({
  code: "return httpFetch({ url: 'https://evil.example.net/steal' })",
  capabilities: { fetch: safeFetch },
})
console.log(blocked.error.message) // → Domain not allowed: evil.example.net

Stating the policy as data

fetchPolicy states the rules as data, and the VM enforces them before your fetch is called. domains is an allowlist; methods and headers add to the defaults above, for example fetchPolicy: { methods: ['POST'], headers: ['x-tenant'] }. The code never sees the policy.

Use fetchPolicy for declarative allowlists, and your own wrapper for anything else (rate limits, logging, rewriting). Using both is defence in depth.

const neverCalled = async () => [] // the VM refuses both requests before this runs

const policed = await Eval({
  code: "return httpFetch({ url: 'https://other.example.org/data' })",
  capabilities: { fetch: neverCalled },
  fetchPolicy: { domains: ['api.example.com'] },
})
console.log(policed.error.message) // → Fetch blocked: domain not in allowlist. Allowed: api.example.com

const posting = await Eval({
  code: "return httpFetch({ url: 'https://api.example.com/products', method: 'POST', body: { name: 'x' } })",
  capabilities: { fetch: neverCalled },
})
console.log(posting.error.message.includes("method 'POST' is not allowed")) // → true

A capability that returns a Response ends the run

A capability is your code, and the boundary holds it to the same rule as the guest: what crosses is plain data. A capability that returns something JSON cannot express, here the Response itself, is a bug in the host. It ends the run even inside the code's try, so the code cannot keep retrying it:

const leaky = await Eval({
  code: "try {\n  let r = httpFetch({ url: 'https://api.example.com/products' })\n  return 'got it'\n} catch (e) {\n  return 'caught'\n}",
  capabilities: { fetch: async () => new Response('[]') }, // returns the Response: a host bug
})
const refusal = "Capability boundary rejected the return of 'httpFetch': capability return contains an instance of Response"
console.log(leaky.error.message.startsWith(refusal)) // → true

Neither 'got it' nor 'caught' comes back: the run stopped at the httpFetch.

Host objects stay outside

Context values cross the same boundary. Plain data (objects, arrays, strings, numbers, booleans, null, and Date) is copied in. A class instance is refused rather than thinned, because its getters and private fields would silently read as undefined on the other side:

class Account {
  constructor(balance) {
    this.balance = balance
  }
}
const live = await Eval({ code: 'account.balance', context: { account: new Account(10) } })
console.log(live.error.message.includes('instance of Account')) // → true

const copied = await Eval({ code: 'account.balance', context: { account: { balance: 10 } } })
console.log(copied.result) // → 10

Pass the fields the code needs, by name. The same goes for what a capability returns. Do not spread a host object to "make it plain": a Response keeps ok, status and headers on its prototype, not on itself, so { ...res } is {}. It crosses the boundary cleanly and delivers nothing. Build the object field by field:

const withStatus = async (url, init) => {
  const res = new Response('{"error":"no such product"}', { status: 404 }) // stand-in for fetch
  return { ok: res.ok, status: res.status, body: await res.json() }
}
console.log({ ...new Response('') }) // → {}

const lookup = await Eval({
  code: "let r = httpFetch({ url: 'https://api.example.com/products/9' })\nif (!r.ok) {\n  return 'HTTP ' + r.status\n}\nreturn r.body",
  capabilities: { fetch: withStatus },
})
console.log(lookup.result) // → HTTP 404

A model is a capability too

llmPredict({ prompt }) calls the llm.predict(prompt) function you inject, with the prompt string, and hands the code whatever plain data you return. The code can use a model without knowing which one, where it runs, or what key it uses:

const answered = await Eval({
  code: "return llmPredict({ prompt: 'Summarize: ' + text })",
  context: { text: 'Fuel bounds work.' },
  capabilities: {
    llm: { predict: async (prompt) => `(a summary of ${prompt.length} characters)` },
  },
})
console.log(answered.result) // → (a summary of 28 characters)
console.log(answered.fuelUsed >= 100) // → true

A model call costs 100 fuel, whatever it costs you. Fuel limits how many calls fit in a run only indirectly. To cap the number of model calls directly, use AgentVM with quotas: { llmPredict: 3 } (next section).

When to reach for AgentVM instead

Eval and SafeFunction are the two-line entry points: source in, { result, fuelUsed, error } out. Use AgentVM directly (see the AJS guide) when you need more:

Here an agent is allowed three model calls and asked for four:

import { transpile } from 'tjs-lang/lang'
import { AgentVM } from 'tjs-lang/vm-ast'

const { ast } = transpile(`
function summarize({ texts: ['a'] }) {
  let out = []
  let i = 0
  while (i < texts.length) {
    let summary = llmPredict({ prompt: 'Summarize: ' + texts[i] })
    out.push(summary)
    i = i + 1
  }
  return { out }
}`)

const vm = new AgentVM()
const run = await vm.run(ast, { texts: ['a', 'b', 'c', 'd'] }, {
  fuel: 1000,
  timeoutMs: 10_000,
  quotas: { llmPredict: 3 },
  capabilities: { llm: { predict: async (prompt) => prompt.toUpperCase() } },
})
console.log(run.error.message) // → Quota exceeded for 'llmPredict': 3 calls allowed

What the sandbox guarantees, and what it does not

Here is exactly what is promised, with where on this page each promise is shown running.

The picture

Untrusted code reaches the world only through a capability you granted. Each request is checked; a granted one runs your wrapper (such as safeFetch), and a loop runs only while fuel lasts.

Diagram: untrusted code's calls (fetch, a file read, a loop, console) each pass a check. A granted fetch runs your wrapper, a loop continues while fuel is left and halts when it is not, and anything not granted is blocked.