Recursive AI

Run untrusted JavaScript with limits

All oneecma recipes

Run JavaScript you don’t trust from C# with OneEcma: a fresh engine per script, a step budget, an allocation budget, a wall-clock deadline, a call-depth limit and cancellation, each ending in a report that says what stopped the run.

C#
using RecursiveAI.OneEcma;
using RecursiveAI.OneEcma.Diagnostics;
 
// One engine per untrusted script, with every limit set.
static ScriptEngine NewSandbox() => new(new ScriptEngineOptions
{
    Limits = new EngineLimits
    {
        Fuel = 5_000_000,                      // steps: calls, loop back-edges, RegExp backtracking
        MaxAllocatedBytes = 16 << 20,          // 16 MiB requested over the run
        WallClockMs = 1_000,                   // one second
        MaxCallDepth = 500,                    // a catchable RangeError past this
        MaxStringLength = 1 << 20,             // longer strings throw RangeError
    },
});
 
static string Run(string source, string name, CancellationToken token = default)
{
    var engine = NewSandbox();
    try
    {
        return "ok: " + engine.ToDisplayString(engine.Evaluate(source, name, token));
    }
    catch (JsLimitException e)                 // a limit ended the run; script could not catch it
    {
        return $"limit {e.Kind}:\n" + DiagnosticRenderer.Render(e.Report!);
    }
    catch (JsException e)                      // the script threw
    {
        return "error:\n" + DiagnosticRenderer.Render(e.Report!);
    }
}
 
Console.WriteLine(Run("Array.from({ length: 5 }, (_, i) => i * i).join(',')", "ok.js"));
Console.WriteLine(Run("for (;;) {}", "spin.js"));
Console.WriteLine(Run("const parts = [];\nwhile (true) parts.push('x'.repeat(1000));", "hog.js"));
Console.WriteLine(Run("/(a+)+$/.test('a'.repeat(40) + 'b')", "redos.js"));
Console.WriteLine(Run("function down(n) { return down(n + 1); }\ntry { down(0); } catch (e) { `caught ${e.name}: ${e.message}` }", "deep.js"));
Console.WriteLine(Run("typeof require + ' ' + typeof process + ' ' + typeof fetch + ' ' + typeof globalThis.console", "reach.js"));
Text
ok: 0,1,4,9,16
limit Fuel:
error[Limit]: Execution exceeded the step limit of 5000000 steps
 --> spin.js:1:1
  |
1 | for (;;) {}
  | ^^^^^^^^^^^
  |
  = stack: <top-level> (spin.js:1)
 
limit Memory:
error[Limit]: Execution exceeded the allocation limit of 16777216 bytes
 --> hog.js:2:25
  |
2 | while (true) parts.push('x'.repeat(1000));
  |                         ^^^^^^^^^^
  |
  = stack: <top-level> (hog.js:2)
 
limit Fuel:
error[Limit]: Execution exceeded the step limit of 5000000 steps
 --> redos.js:1:1
  |
1 | /(a+)+$/.test('a'.repeat(40) + 'b')
  | ^^^^^^^^^^^^^
  |
  = stack: <top-level> (redos.js:1)
 
ok: caught RangeError: Maximum call stack size exceeded
ok: undefined undefined undefined undefined

The catastrophic regular expression is stopped by fuel like any loop, because OneEcma’s own RegExp engine charges every backtracking step. The last line shows what a script can reach on its own: no require, process, fetch or console. It has the language and its built-ins, and whatever you put on the global object.

A deadline and cancellation

Fuel counts work; a wall-clock limit puts a deadline on the run as a whole. Cancellation works on every entry point that computes:

C#
var timed = new ScriptEngine(new ScriptEngineOptions { Limits = new EngineLimits { WallClockMs = 200 } });
try
{
    timed.Evaluate("const end = Date.now() + 5000;\nwhile (Date.now() < end) {}", "clock.js");
}
catch (JsLimitException e)
{
    Console.WriteLine($"limit {e.Kind}:\n" + DiagnosticRenderer.Render(e.Report!));
}
 
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(50));
var slow = new ScriptEngine();               // no limits but the token
try
{
    slow.Evaluate("let i = 0; while (true) i++;", "cancel.js", cts.Token);
}
catch (OperationCanceledException e)       // cancellation is an OperationCanceledException
{
    var limit = (JsLimitException)e.InnerException!;
    Console.WriteLine($"cancelled: {limit.Kind}, {e.Message}, report: {limit.Report is not null}");
}
Text
limit WallClock:
error[Limit]: Execution exceeded the wall-clock limit of 200 ms
 --> clock.js:2:1
  |
2 | while (Date.now() < end) {}
  | ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |
  = stack: <top-level> (clock.js:2)
 
cancelled: Cancelled, Execution was cancelled, report: False

The limits

LimitDefaultWhen it’s hit
Fuel4,000,000,000 stepsJsLimitException, kind Fuel
MaxAllocatedBytes2 GiBJsLimitException, kind Memory. It counts bytes requested over the run, not live bytes.
WallClockMsnoneJsLimitException, kind WallClock
MaxCallDepth10,000a RangeError the script can catch
MaxNativeDepth120JsLimitException. It counts re-entries from built-ins into script, such as sort comparators, getters and proxy traps.
MaxStringLength536,870,888a RangeError, “Invalid string length”
a CancellationTokennoneOperationCanceledException wrapping a JsLimitException of kind Cancelled, with no report in 1.0.0

Good to know

  • The wall clock and the cancellation token are polled every 4,096 steps, so a stop lands within one such chunk of the deadline.
  • Parsing and execution use explicit stacks or depth counters, so deeply nested source can’t overflow the .NET stack.
  • A limit bounds the script, not your .NET code: the time and cost of a host function are yours to bound.
  • Use a fresh engine per untrusted script, as above. Creating one costs about 98 µs and 272 KB (benchmarks).
  • The full contract, including how built-ins are charged, is in Runtime, limits and the host.