Safety Rails
A host decides what a script can reach (two built-in privileges, plus named permissions for its own API) and what it can spend (time, memory, call depth, scan and render sizes). The model:
- A default engine is not a sandbox. It reads any file the process can read (
include,import, path queries), has no memory cap, a 5 second execution budget, and runs parallel loops on a pool of worker threads. - The host grants, the script only asks. A script reads these settings through
reflect::and changes none of them. - A refusal is a catchable script error, except a budget overrun and a second memory-cap denial in one execute, which are terminal.
- The rails bound script, not the process. Whatever the host binds reaches what its C++ reaches; process isolation is the operating system's job.
The untrusted-script recipe puts every setting together.
Filesystem access is a host choice
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 againjai::filesystem (short for jai::privilege::filesystem) is enabled by default. The other built-in privilege, jai::scoped_template, gates untrusted templates (section 6). With filesystem disabled:
| Operation | With filesystem disabled |
|---|---|
include, import, path queries (the lexical file_name included), execute_file, a template() file, explicit jaibite_load and jaibite::save | Refused with the catchable Insufficient privilege: filesystem. on both backends. Uncaught, it leaves execute() and reaches the host. |
| Automatic disk-cache reads, writes and metadata checks; the checker's dependency discovery on disk | Skipped. In-memory source still parses and checks. |
| Source strings, in-memory jaibite bytes, already-loaded script, host-supplied data | Usable. A file name supplied for diagnostics is a label, not permission to read it. |
A script asks with reflect::is_enabled(requirements...), true only when every requirement is enabled (sample below). It takes built-in flags such as jai::filesystem, permission-name strings or a mixture, and at least one argument. An unknown name is false (and is not registered); invalid bits or argument types raise an error.
Changing a privilege. The host enables, disables or registers privileges while the engine is idle or on its execution thread, a C++ callback included. Later gated operations see the change and work already started completes. An active parallel region refuses the change; changes from another thread are unsupported.
Gating your own functions. A registration takes an optional requirement. The check lives inside the callable, so an alias or a call through reflection does not bypass it:
eng->add_function("read_asset", readAsset, jai::filesystem);
eng->add_variadic_function("write_assets", writeAssets, jai::filesystem);dynamic_binder::method(name, callable, requirement) takes the same argument. A binding without one enforces what it needs itself. The standalone runner gates its file, directory, file-archive, download and audio-file start calls this way.
What filesystem does not cover. It is not a network or audio permission: the spider:: HTTP client registers its own named permission, network, disabled until the host enables it. It cannot revoke a stream or C++ object the host already exposed. The runner's download progress queries stay available, except Download.received() on the POSIX curl-executable fallback, which polls the output file and so requires filesystem.
What filesystem access reaches
Enabled, include and import open any file the process can read: an absolute path or a .. step leaves the include paths. A file that fails to parse or check is quoted line by line in an error the script can catch, and a successful load writes a .cache folder beside the source. To narrow that, confine the engine to chosen folders:
eng->add_include_path("scripts");
eng->confine_paths({"scripts", "mods"}); // canonical folders; empty (the default) confines nothingConfined, include, import, resolve_path and file_data open only files whose canonical path (symbolic links followed) lies under one of the folders. A resolution step that finds the file outside them is skipped; when none 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
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 refusedenable, disable, is_enabled, require_privilege and the requirement of a free, variadic or bound-method registration each take a flag, one name, or an initializer list of names that must all pass.
Registration, unknown names, the missing-permission text, several engines and the cost of a check: named permissions.
A name intercepts nothing by itself: attach it to each callable it protects, or check it inside the host function. Scripts query names and flags alike; on an engine with no savegame.write registered:
print(reflect::is_enabled("filesystem"));
print(reflect::is_enabled(jai::filesystem, "savegame.write"));true
false1. Execution budget: terminal, uncatchable by script
eng->execution_budget(1.0 / 60); // wall-clock seconds per host entry
eng->execution_budget(); // read back; 0 disables; DEFAULT: 5.0 secondsA script that exceeds the budget raises an execution_budget_exceeded error that is terminal: every script catch is skipped (nested ones, those inside functions, lambdas and coroutines, and an outer script's catch around a reentrant execute() a host callback made), so the failure always reaches the host. (A catchable timeout would let a retry loop keep execute() from returning.)
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<int>("2 + 2") << "\n"; // the engine survives; each execute gets a fresh deadlineScript execution budget exceeded - raise engine::execution_budget or break up the work
4- Host entry. An
execute(), a coroutinenext(), or a script function the host calls (throughas_function()) while no script is running. Each arms a fresh deadline from the budget set at that moment. - Granularity. The budget is checked at loop back-edges and call entries, and the clock is sampled every 1024 checks. A C++ binding is never interrupted mid-call, so one slow host call can overshoot the deadline before the next check.
- What the clock counts. The script's work. The time a first
parallel_forspends starting the worker pool (seconds on a loaded, oversubscribed host) moves the deadline later, and a host that does slow work for a script does the same witheng->extend_execution_deadline(duration).
A clock-free budget for tests and replays. execution_step_budget(n) allows at most n budget checks (one per 1024 back-edges and call entries) per host entry, then raises the same terminal error naming execution_step_budget. The count is deterministic for a program and backend, where a wall clock depends on machine load. Zero (the default) turns it off; with both bounds set, the first to run out ends the entry:
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); // offScript execution budget exceeded - raise engine::execution_step_budget or break up the work
32. Memory cap: deny-in-place, one catchable raise, then terminal
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 fastThe cap is a high-water mark: growth is charged where heavy values grow, and frees are not credited back. An allocation that would cross the cap is denied in place (the object never grows) and raises:
- The first raise in an execute is an ordinary catchable error, and the count restarts from zero, so the script can drop its caches and carry on:
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"- A second raise in the same execute is terminal, exactly like a budget overrun.
- Each execute starts with a fresh allowance and raise count; a reentrant execute shares the outer call's. The number in the text is the cap, in bytes.
Charged: 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; and the copies a parallel region makes for its workers.
The cap is not an allocator hook: slot-stored locals are not charged, and one enormous operation can overshoot before the next check. It limits the blast radius, not exact bytes; for hard limits use OS job or cgroup machinery around the host.
3. Checked integer overflow (compile-time policy)
The build picks one policy, and the other costs nothing at run time. JAISCRIPT_CHECKED_OVERFLOW (the default) makes overflowing + - *, unary -, ++/-- and INT64_MIN / -1 raise a catchable error such as Integer overflow in '+', and the overflowing store never happens (Operators). JAISCRIPT_WRAP_ON_OVERFLOW wraps silently in two's complement. Either way integer arithmetic is never undefined behavior, on both backends, in counted loops and in constant folding (which declines to fold rather than wrap). eng->throw_on_overflow() reports the policy: true in the default build.
4. Recursion and parse-depth caps
Only script calls have a counted cap. Everything else that nests is bounded by the native stack, which each walk measures as it goes, or by memory. These errors are catchable, the static listing's aside.
Every bound and its error text: depth limits. The cap a host sets:
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 takes 1 through
INT_MAXand a built-in backend type, and refuses a change during execution or an active parallel region. Parallel workers copy the caps at region entry. Raising a cap never disables the native-stack guard. - 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 budget is sampled at every call, so a runaway recursion meets the memory or time limit first. Script-to-script calls reach the cap on any stack; constructors and C++ callbacks recurse natively.
- The interpreter recurses natively, so on a small thread the native-stack error comes first: a 1 MB thread (the standalone runner's, a default Windows main thread) holds about 150 to 200 levels, and a larger stack holds proportionally more.
- Tail calls. On the VM a bare self tail call (
return f(args)outside anytry) reuses its frame and adds no depth; a runaway tail loop ends at the execution budget, likewhile (true). The interpreter does not do this. - Source nesting in practice. A 1 MB stack in Release loads and runs about 1,300 nested array literals or parentheses, 950 nested map literals, 750 nested calls, 350 nested lambdas, 650 nested
ifblocks, 1,400else ifarms, or a 2,000-term chain of+over variables or of&&. A Debug build reaches between a fifth and a half as deep.
5. Binary scan limit
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 over at most the remaining bytes and this limit. A scan that ends without one 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 field form text(n) reads exactly n bytes and needs no bound.
6. Templates: output limits, the scoped_template privilege and its folders
eng->template_output_limit(4 * 1024 * 1024); // the most bytes one template() render writes
// DEFAULT: 10 MiB (10485760); 0 = no limit
eng->scoped_template_output_limit(256 * 1024); // the most bytes one scoped_template or scoped_template_text render writes
// DEFAULT: 1 MiB (1048576); 0 = no limit
auto page = eng->template_output_limit();
auto untrusted = eng->scoped_template_output_limit();A render (HTML Templates) counts the bytes it writes: every splice, and a nested template's text each time it is spliced. A scoped render also counts a built-in method that would make a string longer than the limit. Passing a limit raises a catchable error naming it: template()'s output passed its 4194304-byte limit: the host raises it with engine::template_output_limit, or a scoped template's output passed its 262144-byte limit: the host raises it with engine::scoped_template_output_limit.
The limits change under the privilege rules, and a render already started keeps the limit it started with. A render also runs under the execution budget and the memory cap. Scripts read the limits with reflect::template_output_limit() and reflect::scoped_template_output_limit().
What a template reaches. A template() file is code the program compiles, as an import is, so it needs the filesystem privilege and reaches what the program reaches. A scoped template reaches a fixed set: args (its own copy of the map), the built-in methods of built-in values, and the engine's string::, array:: and map:: functions, which act on copies. Its x => expr lambdas follow the same rule. No global, other function, host binding or class code runs: a call is refused with a scoped template calls only built-in methods and the string::, array:: and map:: functions: 'leak(...)' is not allowed (the reference lists the other refusals).
The scoped_template privilege and its folders
eng->disable(jai::filesystem); // scripts read no file ...
eng->set_scoped_template_dirs({"themes", "template"}); // ... but untrusted templates load from these folders
eng->add_scoped_template_dir("/srv/app/shared_templates");
eng->remove_scoped_template_dir("themes");
const std::vector<std::string>& dirs = eng->scoped_template_dirs(); // DEFAULT: {"template"}
eng->disable(jai::scoped_template); // and with this, no untrusted template renders at allscoped_template and scoped_template_text run on their own privilege, jai::scoped_template (the name "scoped_template"), enabled by default, with the API and change rules of filesystem. Disabled, both raise Insufficient privilege: scoped_template. where they render. They need no other privilege: with filesystem off, a script still renders, and so reads, the files in the template folders.
A scoped_template(path, map) file is read only from the host's template folders, with or without filesystem enabled. Its path is checked before any file opens, and each refusal has its own catchable text (template errors):
- a
..segment anywhere, even one that stays inside; - an absolute path, a drive (
C:), a UNC name (//server/share), a leading/or\; - a file whose canonical path, links and junctions resolved, leaves its folder;
- a path no folder holds.
- Folders are tried in list order; the first file that exists and lies inside its folder is the template.
- A relative folder resolves as an import's file does (the ladder an import walks): the calling script's folder, then the include paths, then the working directory. The default
{"template"}is thetemplatefolder there, so ajai_spiderapp reads its owntemplate/. - A folder that does not exist holds nothing, and is no error.
- Scripts read the list with
reflect::scoped_template_dirs(). The host changes it under the privilege rules.
Putting it together: running untrusted script
auto eng = jai::engine::make();
eng->disable(jai::filesystem); // no include, import or path query: source arrives in memory
eng->disable(jai::scoped_template); // no template files either (or keep it and choose the folders)
eng->execution_budget(0.005); // 5 ms per execute
eng->memory_cap(16 * 1024 * 1024); // 16 MB high-water
eng->parallel_thread_count(1); // parallel loops stay on the calling thread
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; });What the lines close, and what stays open:
- Files. With both privileges off the script opens no file. With
scoped_templateleft on, it can render, and so read, any file underscoped_template_dirs()(thetemplatefolder by default). - Threads. At its default (0),
parallel_thread_countlets a parallel loop use every performance core. The budget and the cap apply per worker, and each worker may spend the whole memory allowance left when the region starts. A count of 1 keeps loops on the calling thread; results never depend on the count. - Waiting. Under a plain
execute(),sleep(seconds)blocks the host thread, and the budget is next checked when it returns. A host that must not block replaces the engine's sleep provider (eng->time().source) or starts the script as a suspendable run. - Names. Strict checking rejects an undeclared name before anything runs, with a
jai::static_check_errornaming the line (Static Checking). It is a diagnostic aid, not a privilege boundary:externdefers a name to run time and grants nothing. - The host's API. A host type enters an engine only through an explicit
bind_registrarordynamic_bindercall (the binder chapter's three doors). Engines isolate script environments, not the process or shared native objects: review every callback, stream and C++ object you expose. - Hostile data. The serializers never execute type or property names arriving in a payload, and reject oversized array and map length prefixes instead of pre-allocating (Serialization). A signal property's saved script-text receivers are code that runs when the signal fires, so an object carrying them loads from a trusted source or into an engine under these rails (saved receivers are script).
Which errors a script can catch: a privilege refusal, checked overflow, the recursion and nesting limits, the scan and template limits, and the first memory-cap denial of an execute. A budget overrun and a second memory-cap denial are terminal.