---
title: "Run untrusted JavaScript with limits"
url: https://recursiveai.org/oneecma/cookbook/sandbox/
description: "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…"
version: "1.0.0"
---

# Run untrusted JavaScript with limits

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.

```csharp
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:

```csharp
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

| Limit | Default | When it’s hit |
|---|---|---|
| `Fuel` | 4,000,000,000 steps | `JsLimitException`, kind `Fuel` |
| `MaxAllocatedBytes` | 2 GiB | `JsLimitException`, kind `Memory`. It counts bytes requested over the run, not live bytes. |
| `WallClockMs` | none | `JsLimitException`, kind `WallClock` |
| `MaxCallDepth` | 10,000 | a `RangeError` the script can catch |
| `MaxNativeDepth` | 120 | `JsLimitException`. It counts re-entries from built-ins into script, such as sort comparators, getters and proxy traps. |
| `MaxStringLength` | 536,870,888 | a `RangeError`, “Invalid string length” |
| a `CancellationToken` | none | `OperationCanceledException` 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](https://recursiveai.org/oneecma/docs/benchmarks/)).
- The full contract, including how built-ins are charged, is in [Runtime, limits and the host](https://recursiveai.org/oneecma/docs/runtime/).
