Safety Rails​

JaiScript has built-in file loading and path queries, and filesystem access is enabled by default. For scripts that should work only with data supplied in memory, the host can disable that privilege. The remaining boundary is the API the host exposes: native functions, objects and output streams can deliberately reach outside the script environment. This chapter covers that boundary and five execution rails; it does not promise an operating-system sandbox.

Filesystem access is a host choice​

C++
auto eng = jai::engine::make();
eng->disable(jai::filesystem);
bool allowed = eng->is_enabled(jai::filesystem);  // false
eng->execute("2 + 2");                          // in-memory code still works
// eng->enable(jai::filesystem);                 // only the host can grant it again

jai::filesystem is the shorthand for jai::privilege::filesystem. Filesystem is the one built-in privilege; hosts can also define named permissions for their own APIs. Disabling filesystem has three effects:

OperationWith filesystem disabled
include, import, path queries, execute_file, explicit jaibite_load and jaibite::saveRefused. Even the lexical file_name query requires the privilege.
Automatic disk-cache reads, writes and metadata checks; checker dependency discovery on diskSkipped. In-memory source can still be parsed and checked without probing files.
Source strings, in-memory jaibite bytes, already-loaded script and host-supplied dataStill usable. A filename supplied for diagnostics is a label, not permission to read it.

A refused operation raises the catchable script error Insufficient privilege: filesystem. on both backends. If uncaught, the error leaves execute() and reaches the host. A script can query permission, but cannot enable it:

jaiscript
print(reflect::is_enabled(jai::filesystem));
output · default engine
true

reflect::is_enabled(requirements...) accepts built-in integer flags and permission-name strings, including a mixture, and requires at least one argument. It returns true only when all supplied requirements are enabled. Unknown names return false without registering or interning them; invalid bits or argument types raise an error. The host may register, enable or disable privileges while the engine is idle or on its execution thread, including from a C++ callback: future gated operations see the change, while work already started completes. Concurrent cross-thread changes are unsupported. An active parallel region refuses changes and keeps its privileges fixed until workers join. This is revocation for subsequent operations, not cancellation; privilege checks add no atomics or locks.

The standalone runner attaches the same gate to its file, directory, file-archive, download and audio-file start calls. For your own native functions, declare the requirement when registering the callable:

C++
eng->add_function("read_asset", readAsset, jai::filesystem);
eng->add_variadic_function("write_assets", writeAssets, jai::filesystem);

dynamic_binder::method(name, callable, requirement) accepts the same optional requirement. The check lives inside the callable, so saving a function or method alias or calling it through reflection does not bypass it. A native binding without a requirement remains the host's responsibility: it must enforce any needed check itself. The spider:: HTTP client registers its own named permission, network, disabled until the host enables it. Filesystem is not a general network or audio permission, and cannot revoke access through a stream or C++ object the host has already exposed. The runner's download progress queries stay available, except Download.received() on the POSIX curl-executable fallback: that implementation polls the output file and therefore also requires filesystem access.

What filesystem access reaches​

With filesystem enabled, include and import open any file the process can read: an absolute path or a .. step leaves the include paths behind, a file that fails to parse or check is quoted line by line in the error a script can catch, and a successful load writes a .cache folder beside the source. A host that runs scripts it does not trust either disables filesystem or confines the resolution ladder to chosen folders:

C++
eng->add_include_path("scripts");
eng->confine_paths({"scripts", "mods"});   // canonical folders; empty (the default) confines nothing

Confined, include, import, resolve_path and file_data open only files whose canonical path (symbolic links followed) lies under one of the folders. A rung of the ladder that finds the file outside them is skipped; when no rung finds it inside, the reach raises the catchable Path outside the confined roots: <path as written>, and the file is never read. No .cache folder is written beside a source outside the folders (a jaibite_cache_directory still receives every cache). The host's own execute_file runs whatever file it names.

Named permissions for your APIs​

C++
auto eng = jai::engine::make();
eng->register_privilege("savegame.write");                // disabled initially
eng->add_function("save_game", saveGame,
    {"filesystem", "savegame.write"});                    // BOTH are required
eng->enable("savegame.write");                            // filesystem is on by default
bool allowed = eng->is_enabled({"filesystem", "savegame.write"});  // true
auto checked = eng->require_privilege("savegame.write");  // checked_result<void>
eng->disable("savegame.write");                           // later calls are refused

register_privilege(name) explicitly registers a custom name and returns nothing. A new name starts disabled; registering it again leaves its grant unchanged. Registering "filesystem" refers to the built-in flag and also leaves its current grant unchanged. Use a name string for one requirement or an initializer list of names for several: all must pass. These forms work with enable, disable, is_enabled, require_privilege, and the optional requirement on free, variadic or bound-method registration. The jai::filesystem flag forms work the same way.

Permission names are reusable across engines, but registration and grants are independent in each engine. Querying an unknown name returns false; enabling, disabling, requiring, or attaching an unknown name to a callable rejects it instead of auto-registering it. Register custom names before binding functions that require them. Names live in the engine's string interner, and binding resolves requirements once into engine-local slot IDs: invocation checks do not look up names or allocate. Registration and binding may allocate. There is no extra global registry or synchronization.

A registered but disabled permission produces Insufficient privilege: savegame.write. in this example. Multiple requirements report a missing built-in first, then the first missing custom permission in registration order, independent of the supplied list order. Defining a name does not intercept any API automatically: attach it to each callable it protects or check it inside the host implementation.

Scripts query custom permissions by name. On a fresh engine with no host-defined savegame.write permission:

jaiscript
print(reflect::is_enabled("filesystem"));
print(reflect::is_enabled(jai::filesystem, "savegame.write"));
output
true
false

1. Execution budget: terminal, uncatchable by script​

C++
eng->execution_budget(1.0 / 60);   // wall-clock seconds per host entry
eng->execution_budget();           // read back; 0 disables; DEFAULT: 5.0 seconds

A script exceeding the budget raises an execution_budget_exceeded error that is terminal: every script catch is skipped: plain try/catch, nested catches, catches inside functions, lambdas, coroutines, and even the outer script's catch around a reentrant execute (host callback calling execute() mid-script). The failure always reaches the host; a script can never swallow its own timeout. The clock counts the script's work, not the engine's own setup: the seconds a first parallel_for spends starting the worker pool (a loaded, oversubscribed host can take seconds) move the deadline later, and a host that does slow work for a script can do the same with eng->extend_execution_deadline(duration).

C++
eng->execution_budget(0.05);
try {
    eng->execute("var caught = 0; try { while (true) { } } catch (e) { caught = 1; } caught;");
} catch (const std::exception& e) {
    std::cout << e.what() << "\n";          // the script's catch never saw it
}
std::cout << eng->execute("2 + 2").as_int() << "\n";   // the engine survives; each execute gets a fresh deadline
output
Script execution budget exceeded - raise engine::execution_budget or break up the work
4

Why terminal: a catchable timeout is a denial-of-service kit: a catch-and-retry loop would keep execute() from ever returning. The whole matrix (nested catches, catches inside functions, lambdas, and coroutines, reentrant execute boundaries, per-entry re-arming, the 5-second default) is covered by the test suite on both backends.

A host entry is an execute() or a coroutine next() call, or a script function the host calls directly (a function value it holds, called through as_function()) while no script is running. Each one arms a fresh deadline from the budget set at that moment, so a host can lower the budget just for the calls it wants bounded.

Granularity: checked at loop back-edges and call entry, clock sampled every 1024 ticks, with no measurable hot-path cost, and straight-line C++ bindings are never interrupted mid-call (engine.hpp doc comment). Corollary: a single very slow host call can overshoot the deadline before the next check.

A clock-free budget for tests and replays. A wall-clock deadline depends on machine load, so a gate that must pass or fail the same way everywhere counts instead: execution_step_budget(n) allows at most n of those budget checks (one per 1024 loop back-edges and call entries) per host entry, then raises the same terminal budget error, naming execution_step_budget as the knob to raise. The count is deterministic for a given program and backend. Zero (the default) turns it off; both bounds can be set at once, and whichever runs out first ends the entry:

C++
eng->execution_step_budget(4);       // about 4 * 1024 back-edges and calls per execute
try {
    eng->execute("int n = 0; try { while (true) { n++; } } catch (e) { n = -1; } n;");
} catch (const std::exception& e) {
    std::cout << e.what() << "\n";
}
std::cout << eng->execute("int k = 0; for (int i = 0; i < 3; i++) { k++; } k;").as_int() << "\n";   // each entry starts afresh
eng->execution_step_budget(0);       // off
output
Script execution budget exceeded - raise engine::execution_step_budget or break up the work
3

2. Memory cap: deny-in-place, one catchable raise, then terminal​

C++
eng->memory_cap(256 * 1024);   // approximate bytes per top-level execute()/next()
                                // 0 disables; DEFAULT: 0 (unlimited)
                                // 256 KB is small on purpose so the demo below trips it fast

Accounting is a high-water mark charged at the heavy-value chokepoints: container growth (push, insert, concat/append), string growth (concatenation, repeat, the pads, append, replace, join, and their string:: forms), the pieces split, lines and utf8_chars make, a binary::writer's buffer, a script to_json's indentation, a started coroutine's state (and its stack's growth on the VM), clone, environment growth. Frees are not credited back (engine.hpp, memory_cap). An allocation that would cross the cap is denied in place (the object never grows) and raises:

  • First raise per execute: an ordinary catchable script error, and the counter re-arms to zero so the script can free caches and continue working. This is the legit recovery path:
jaiscript · under the 256 KB cap above
var a = [];
try { while (true) { a.push("xxxxxxxxxx"); } }
catch (err) { a = []; }     // err == "Script memory cap exceeded (262144 bytes) -
                            //         raise engine::memory_cap or break up the work"

The number in the text is the cap you set, in bytes.

  • Second raise in the same execute: terminal, exactly like a budget overrun. Every catch is skipped and execute() fails to the host: first raise caught by script, second raise thrown to C++.
  • Fresh execute = fresh allowance and raise count; reentrant executes share the outer call's allowance.

The matrix (first-raise catchable, second-raise terminal, map-growth and environment-growth bombs, per-execute reset, zero-is-unlimited) is covered by the test suite, and the differential fuzz harness runs every generated program under a 64 MB cap.

What the cap is not: an allocator hook. Frees aren't credited back, slot-stored locals aren't charged, and a single enormous operation can overshoot before the next check fires. It denies at the chokepoints it instruments. Treat it as a blast-radius limiter, not a byte-exact quota; if you need hard process-level limits, use OS job/cgroup machinery around the host.

3. Checked integer overflow (compile-time policy)​

Chosen at build time, applied via if constexpr so the losing policy costs zero at runtime (detail/integer_ops.hpp):

  • JAISCRIPT_CHECKED_OVERFLOW (the default: "safe by default"): overflowing + - *, unary -, and INT64_MIN / -1 raise a catchable runtime error.
  • JAISCRIPT_WRAP_ON_OVERFLOW: silent two's-complement wraparound (max speed).

Either way arithmetic is never undefined behavior (computed in unsigned). The policy is applied consistently everywhere: interpreter fast paths, VM opcodes, counted-loop updates, ++/--, and even the parser's constant folder (which declines to fold rather than wrap). Hosts can introspect the build's policy:

C++
eng->throw_on_overflow();     // true in the default build
jaiscript
var x = 9223372036854775807;
try { x = x + 1; } catch (e) { print(e); }
print(x);                          // untouched: the overflowing store never happened
output
Integer overflow in '+'
9223372036854775807

4. Recursion and parse-depth caps​

  • Script call depth: each engine owns separate caps, defaulting to 256 active calls on the interpreter and 250,000 on the VM. The VM's cap is a backstop: its frames are heap records of about 1 to 3 KB a level in Release, charged to the memory cap every 10,000 levels, and the execution budget is sampled at every call, so a runaway recursion stops at the memory or time limit first. Both use the catchable text Maximum recursion depth exceeded - possible infinite recursion. Configure the caps between executions with the API below; parallel workers snapshot them at region entry.
  • Native-stack headroom: an independent guard raises its own catchable text, Native stack exhausted before the call-depth cap: the host thread's stack bounds this recursion, when less than 256 KB of the stack remains at a call, before a call-depth cap is reached. The stack is the host thread's, or an engine::start run's own fiber stack while it runs; Windows, Linux, Android and macOS report its bounds, and a stack the OS calls unlimited counts as 1 GB. Increasing a cap does not disable this guard or make native recursion safe on a smaller stack.
  • Source nesting: no count of levels. The parser, the checker, the VM compiler and the interpreter's evaluation measure the native stack as they recurse over source, and nesting past it is one error at the deepest point reached: nesting too deep for the host thread's stack in the static listing when the parser or the checker reaches the margin, else the catchable Nesting too deep for the host thread's stack when the code runs. Each walk stops with 192 KB of the stack left; on a platform that cannot report its stack's bounds it may take 1 MB below where it started, so a host there that loads scripts on a thread with a smaller stack gives that thread more. On a 1 MB stack in Release about 1,300 nested array literals or parentheses, 950 nested map literals, 750 nested calls, 350 nested lambdas, 650 nested if blocks, 1,400 else if arms, or a 2,000-term chain of + over variables or of && load and run; a Debug build reaches between a fifth and a half as deep.
  • Saved-program nesting: no count of levels either. The .jaibite writer and reader measure the stack as the parser does and stop with the same margin, so a program that loads from source also saves and loads as a bite on the same thread; past the stack they raise jaibite: AST nesting too deep or jaibite: type nesting too deep.
  • Nested data: no depth limit. JSON parsing, from_json/from_binary, to_json/to_binary and a deep copy walk a value on work lists, so only memory bounds how deep it nests. A host record type whose own serialize/save/load/load_and_construct recurses natively is the exception: its save or load raises Record nesting depth exceeds the host thread's stack at the stack's margin, or Maximum record nesting depth (1024) exceeded (JAI_MAX_HOST_RECORD_DEPTH, archive_impl.hpp) where the stack cannot be asked.
C++
eng->max_call_depth(jai::backend_type::interpreter, 256);
eng->max_call_depth(jai::backend_type::vm, 10000);
auto vmLimit = eng->max_call_depth(jai::backend_type::vm);

The setter accepts values from 1 through INT_MAX and refuses changes during execution or an active parallel region. Both overloads require a built-in backend type. An uncaught recursion error reads as its text alone. The interpreter recurses natively and keeps 256 KB of the thread's stack in reserve, so on a small host thread its recursion ends first with Native stack exhausted before the call-depth cap: the host thread's stack bounds this recursion, also catchable. A 1 MB thread (the standalone runner's, a default Windows main thread) holds about 150 to 200 interpreter levels, depending on the function; a host thread with a larger stack raises that bound in proportion. The VM's script calls reach its cap on any stack.

On the VM, a bare self tail call (return f(args) outside any try) reuses its frame and runs in constant space without accumulating call depth. A runaway tail loop is stopped by the execution budget, just like while (true). The interpreter does not perform this tail-call optimization. Ordinary VM script-to-script calls use in-loop frames, but constructors and C++ callbacks can still recurse natively.

5. Binary scan limit​

C++
eng->binary_scan_limit(64 * 1024);   // the longest NUL scan one binary::reader read may run
                                      // DEFAULT: 1 MiB (1048576); there is no unlimited setting
auto limit = eng->binary_scan_limit();

A binary::reader's text() reads a C string, so it scans for a NUL. The scan covers at most the remaining bytes and this limit; scripts cannot change it. A scan that reaches the limit without a NUL raises the catchable Cannot read null-terminated text at position 0: no NUL terminator within 65536 bytes (the engine's binary scan limit; raise it from C++ with engine->binary_scan_limit) and leaves the cursor where it was. The default is far above any string field a real format holds, and a full miss is one memchr over 1 MiB, well under a millisecond. The field form text(n) reads exactly n bytes, a width the data format sets, and needs no scan bound.

Putting it together: running untrusted script​

C++
auto eng = jai::engine::make();
eng->disable(jai::filesystem);          // execute source supplied in memory
eng->execution_budget(0.005);            // 5 ms per execute
eng->memory_cap(16 * 1024 * 1024);       // 16 MB high-water
eng->binary_scan_limit(4096);            // C-string reads of binary data stop after 4 KB
auto myLogStream = std::make_shared<std::ostringstream>();
eng->set_output_stream(myLogStream);     // print speaks only into this stream (a shared_ptr<std::ostream>)
// static checking is already strict — nothing to turn on
// register ONLY the API surface the script should have
eng->add_function("move_to", [](jai::script_int x, jai::script_int y) { return x + y; });

Strict checking (the default) catches ordinary undeclared names before execution, with a jai::static_check_error naming the line (Static Checking). It is a diagnostic aid, not a privilege boundary: extern can defer name resolution until runtime, but neither it nor a dynamic import grants filesystem access. With that privilege disabled, the checker skips disk dependency discovery and the runtime still checks attempted file operations.

Budget exhaustion is terminal; the first memory-cap denial is catchable and a second in the same execution is terminal. Filesystem denial, checked overflow and recursion limits are catchable errors. Separate engines isolate their script environments, not the process or shared native objects. Review every callback, stream and C++ object you expose; use an OS-level boundary when you need process isolation. A host type enters an engine through an explicit bind_registrar or dynamic_binder call (the binder chapter's three doors). The serializers are hardened separately against hostile data: type and property names arriving in payloads are never executed, and oversized array/map length prefixes are rejected instead of pre-allocated (see Serialization). A signal property's saved script-text receivers are the exception that is code: they run when their signal fires, so an object carrying them loads from a trusted source or into an engine under these rails (saved receivers are script).