The libturi embedding API provides a sandboxed evaluation environment for Turmeric code -- REPL widgets, plug-in scripts, user-supplied formulas -- inside a C host process with I/O, FFI, and unsafe memory operations denied.
What the sandbox enforces. Every capability is enforced -- each native function has a row in one classification table and the native dispatch refuses a call the environment has no capability for -- so a sandboxed script cannot open a file, spawn a process, or read the environment, however it spells the call. Memory safety is enforced too: a native takes a collection / string / iterator / continuation handle as a bare integer, and in a restricted env a per-env handle-provenance registry refuses any handle that was not minted as the kind it is used as -- whether it reaches a native or one of the interpreter's own re-tags (an erasing ascription to
cstror a struct, a field read, a call). So(vec-get 4096 0),(:: 4096 cstr), a kind-confused replay and a use-after-free are refused instead of reading or writing an arbitrary address, while a genuinely built collection still round-trips. This was S-5 in the security audit plan, resolved 2026-10-07; the Security Guide states the promise and its limits. It is an in-process boundary: for code from someone you do not trust, also run the embedding in a separate, unprivileged process.
See eval-api.md for the full C embedding API reference.
#include <turi/eval.h>
int main(void) {
turi_init(false);
TuriEnv *env = turi_env_new_sandboxed();
TuriValue v = turi_eval(env, "(+ 1 2)");
/* v.tag == TURI_INT, v.as_int == 3 */
/* I/O is blocked */
TuriValue bad = turi_eval(env, "(println 42)");
/* bad.tag == TURI_ERROR */
turi_env_free(env);
return 0;
}
turi_env_new_sandboxed() returns an environment identical to turi_env_new()
except that the operations listed below are disabled. Arithmetic, closures,
recursion, structs, algebraic effects, and pure stdlib functions all work
without restriction.
The println family returns TURI_ERROR in a sandboxed environment, and so
does every native function whose row in the
classification table names a capability the
environment does not hold: process/spawn-raw (under process/spawn), io-fopen-write, r7rs-unlink__,
r7rs-getenv__, the raw-descriptor read-async/write-async, and the rest.
The check is made once, where every native call is dispatched, so a native
reached by name, through turi_call, or from a higher-order native is refused
the same way:
eval: 'process/spawn-raw' requires capability proc, which this environment does not hold
(load "path") is refused alongside (import ...).
dlopen, dlsym, and dlclose are blocked. A sandboxed script cannot load
native shared libraries. extern-c declarations need TURI_CAP_FFI too,
including the ones the interpreter implements itself (printf, puts,
getenv, exit, strlen, free), which additionally need the capability of
what they do.
Any expression of the form (`c ... `) is rejected at evaluation
time with TURI_ERROR.
(async ...) is blocked. Sandboxed code cannot spawn fibers or use the
cooperative scheduler.
A sandboxed environment caps total evaluation work so an infinite loop cannot hang the host.
The step-fuel counter decrements once per AST node evaluated. When it reaches
zero, turi_eval returns TURI_ERROR immediately.
/* Default sandboxed fuel: TURI_DEFAULT_SANDBOX_FUEL (10,000,000 steps).
* Override after creating the environment: */
turi_env_set_fuel(env, 500000); /* tighter limit for short formulas */
turi_env_set_fuel(env, 0); /* 0 = unlimited (not recommended) */
Fuel checking is skipped entirely in unrestricted environments
(step_fuel_limit == 0), so there is no performance cost to non-sandboxed code.
There is no recursion-depth guard: interpreter recursion is heap-bounded, so
deep recursion cannot overflow the host's C stack. turi_env_set_max_depth
is retained as a no-op for API/ABI compatibility -- bound total work with
turi_env_set_fuel instead.
TURI_DEFAULT_SANDBOX_FUEL is #defined in eval.h and can be overridden at
compile time.
When you need a mix of restrictions -- for example, async is safe for your use
case but filesystem I/O is not -- use the capability API instead of the boolean
sandboxed flag.
typedef uint32_t TuriCaps;
#define TURI_CAP_IO (1u << 0) /* stdout/stdin, raw-fd read/write, pipes */
#define TURI_CAP_FFI (1u << 1) /* dlopen/dlsym/dlclose, extern-c */
#define TURI_CAP_INLINE_C (1u << 2) /* inline-C expressions */
#define TURI_CAP_ASYNC (1u << 3) /* (async ...) and the scheduler natives */
#define TURI_CAP_UNSAFE (1u << 4) /* raw-malloc, ptr-deref, ... */
#define TURI_CAP_IMPORT (1u << 5) /* (import ...) and (load ...) */
#define TURI_CAP_FS (1u << 6) /* open/create/remove/stat by path */
#define TURI_CAP_PROC (1u << 7) /* spawn, wait, exit the host process */
#define TURI_CAP_ENV (1u << 8) /* getenv / environ */
#define TURI_CAP_EVERY /* every bit above */
#define TURI_CAP_ALL (~(TuriCaps)0)
#define TURI_CAP_NONE ((TuriCaps)0)
turi_env_new() grants TURI_CAP_ALL. turi_env_new_sandboxed() starts with
TURI_CAP_NONE. Use turi_env_allow and turi_env_deny to adjust:
/* Sandbox that permits async but not I/O or FFI. */
TuriEnv *env = turi_env_new_sandboxed();
turi_env_allow(env, TURI_CAP_ASYNC);
TuriValue ok = turi_eval(env, "(async (fn [] :int 42))"); /* allowed */
TuriValue bad = turi_eval(env, "(println 1)"); /* TURI_ERROR */
/* Unrestricted env with the outside world revoked -- useful inside a plugin
* host that wants the script to print but touch nothing else. */
TuriEnv *env = turi_env_new();
turi_env_deny(env, TURI_CAP_FS | TURI_CAP_PROC | TURI_CAP_ENV | TURI_CAP_FFI);
A native that needs more than one capability runs only when the environment holds all of them.
Query whether a capability is active (turi_env_has_cap answers true when
any of the bits passed is held, so query one bit at a time):
if (!turi_env_has_cap(env, TURI_CAP_IO)) {
/* safe to expose to user input */
}
/* Grant a capability to a sandboxed environment. */
void turi_env_allow(TuriEnv *env, TuriCaps cap);
/* Revoke a capability from any environment. */
void turi_env_deny(TuriEnv *env, TuriCaps cap);
/* Query whether a capability is currently granted. */
bool turi_env_has_cap(TuriEnv *env, TuriCaps cap);
/* Adjust the step-fuel limit. */
void turi_env_set_fuel(TuriEnv *env, uint64_t steps);
/* Retained as a no-op for API/ABI compatibility (the recursion-depth
* guard is retired; interpreter recursion is heap-bounded). */
void turi_env_set_max_depth(TuriEnv *env, uint32_t depth);
(import ...) and Pre-Loading Stdlib(import ...) triggers filesystem reads. In a sandboxed environment the
TURI_CAP_IMPORT bit is unset, so any (import ...) expression returns
TURI_ERROR without touching the filesystem.
If sandboxed scripts need stdlib functions, pre-load them in an unrestricted
environment and copy the bindings via turi_env_set before sandboxing:
TuriEnv *boot = turi_env_new();
turi_eval_file(boot, "stdlib/list.tur");
TuriValue list_map = turi_env_get(boot, "list/map");
TuriEnv *sandbox = turi_env_new_sandboxed();
turi_env_set(sandbox, "list/map", list_map); /* inject binding */
/* Sandboxed code can now call list/map without importing anything. */
turi_eval(sandbox, "(list/map (fn [x :int] :int (* x 2)) ...)");
turi_env_free(boot);
Do not grant TURI_CAP_IMPORT in a sandboxed environment unless you fully
control the filesystem paths reachable from the script.
Sandboxed environments accept turi_env_register_native calls. Your native
functions run with the same privileges as the host process; the sandbox only
constrains what Turmeric code can call. Validate arguments carefully:
static TuriValue safe_sqrt(TuriEnv *env, TuriValue *args,
uint32_t n, void *ud) {
(void)ud;
if (n != 1 || args[0].tag != TURI_FLOAT)
return turi_error("sqrt: expected one float");
double x = args[0].as_float;
if (x < 0.0) return turi_error("sqrt: negative argument");
return turi_float(sqrt(x));
}
turi_env_register_native(sandbox, "safe-sqrt", safe_sqrt, NULL);
A native registered under a name of your own carries no capability
requirement: you exposed it, so it runs. A native registered under a name the
classification table already lists takes that
row's requirement, so re-registering io-fopen-read does not quietly give a
sandbox file access. When you want to say which it is, use the explicit form:
/* Gate your own native behind a capability ... */
turi_env_register_native_caps(env, "host/read-config", read_config, NULL,
TURI_CAP_FS);
/* ... or deliberately expose one under a classified builtin name. */
turi_env_register_native_caps(env, "r7rs-getenv__", fake_getenv, NULL,
TURI_CAP_NONE);
Only expose native functions you are willing to let untrusted code call.
If your native hands Turmeric a pointer as a bare integer and takes it back
later, check it on the way back in: the handle table only knows the built-in
natives, so sandboxed code can pass yours any integer. The registry the
built-ins use is public (src/turi/eval.h) -- record the pointer when you hand
it out and check it when it returns; both calls do nothing in an unrestricted
env:
turi_prov_register(env, TURI_HK_GENERIC, conn); /* handing it out */
if (!turi_prov_check(env, TURI_HK_GENERIC, (void *)(intptr_t)args[0].as_int))
return turi_error("conn: not a live connection"); /* taking it back */
Every blocked operation returns a TURI_ERROR value rather than aborting the
process. Always check the return value:
TuriValue v = turi_eval(env, user_input);
if (turi_is_error(v)) {
fprintf(stderr, "sandbox error: %s\n", turi_error_message(v));
/* recover, report, or discard */
}
Step-fuel exhaustion also surfaces as TURI_ERROR:
sandbox error: eval: step fuel exhausted
So does a panic. An environment without TURI_CAP_PROC may not end your
process, so a panic nothing in the script catches -- (panic ...), an
out-of-bounds vec-get, a failed contract -- stops the script and comes back
from turi_eval or turi_call as an error, and the environment stays
usable:
sandbox error: panic: vec index out of bounds
A catch-unwind inside the script still catches first. An environment that
holds TURI_CAP_PROC keeps the compiled program's behaviour: print the panic
and exit.
#include <stdio.h>
#include <turi/eval.h>
int main(void) {
turi_init(false);
TuriEnv *env = turi_env_new_sandboxed();
turi_env_set_fuel(env, 1000000);
/* Expose a safe native. */
turi_env_register_native(env, "safe-sqrt", safe_sqrt, NULL);
/* User-supplied expressions evaluated in isolation. */
const char *formulas[] = {
"(defn hyp [a :float b :float] :float"
" (safe-sqrt (+ (* a a) (* b b))))",
"(hyp 3.0 4.0)",
"(hyp 5.0 12.0)",
"(println-float 1.0)", /* blocked -- no IO cap */
};
for (int i = 0; i < 4; i++) {
TuriValue v = turi_eval(env, formulas[i]);
if (turi_is_error(v)) {
fprintf(stderr, "[error] %s\n", turi_error_message(v));
} else {
char buf[64];
turi_value_repr(buf, sizeof(buf), v);
printf("%s\n", buf);
}
}
turi_env_free(env);
return 0;
}
Expected output:
#<fn hyp>
5.0
13.0
[error] eval: 'println-float' requires capability io, which this environment does not hold
| Capability | TURI_CAP_* bit |
Blocked by default | What it covers |
|---|---|---|---|
| I/O | TURI_CAP_IO |
yes | println, stdout writers, read-async/write-async on raw descriptors, pipes, the reactor, the stdio ports |
| Filesystem | TURI_CAP_FS |
yes | opening, creating, removing and testing files by path |
| Process | TURI_CAP_PROC |
yes | process/spawn-raw, process/wait-raw (under process/spawn / process/wait / process/run), exiting the host process |
| Environment | TURI_CAP_ENV |
yes | getenv, environ |
| FFI | TURI_CAP_FFI |
yes | dlopen, dlsym, dlclose, every extern-c, spice reload |
| Inline-C | TURI_CAP_INLINE_C |
yes | (`c ... `) expressions |
| Async | TURI_CAP_ASYNC |
yes | (async ...) forms and the scheduler natives |
| Unsafe memory | TURI_CAP_UNSAFE |
yes | raw-malloc, ptr-deref, ptr-write, raw-memset, the raw-address natives |
| Import | TURI_CAP_IMPORT |
yes | (import ...) and (load ...) |
All nine capabilities are denied when you call turi_env_new_sandboxed().
Use turi_env_allow to selectively re-enable any subset.
Every native function a new environment holds has one row in
src/turi/native_caps.c naming the capabilities a caller must hold. The table
is the source of truth; the sandbox test (tests/turi/sandbox-eval.c) fails if
a native a fresh environment holds has no row, or if any row with a
requirement can be called from a sandboxed environment.
A native is classified by what its caller can make it do. One that writes
stderr only on its own panic path is still pure; one that takes a raw
descriptor is I/O even though it opens nothing, because descriptors 0, 1 and 2
need no open. About six hundred natives are pure; these are the rest:
| Class | Natives |
|---|---|
proc |
process/spawn-raw, process/wait-raw, process/child-of-raw, r7rs-exit__ |
fs |
fs/tmpfile, fs/tmpfile-path, fs/tmpfile-fd, fs/tmpfile-free, io-fopen-read, io-fopen-write, io-fread-chunk, io-fwrite-chunk, io-fclose, io-remove, write-temp-file, json/decode-file!, r7rs-io-open__, r7rs-file-exists-c__, r7rs-unlink__ |
fs, io |
random-access-bench |
env |
r7rs-getenv__, r7rs-getenv-set?__, r7rs-environ-count__, r7rs-environ-name__, r7rs-environ-value__ |
io |
println-float, show-string-fputs, bt-print, doc-print, run-ring, run-nbody, read-async, write-async, async-pipe-init, r7rs-io-std__, reactor-new, tur_reactor_new, tur_reactor_poll, break |
async |
sleep-async, with-timeout, async-all2, await-val, async-race, cancel-task, task-cancelled? |
ffi |
reload |
unsafe |
box, unbox, io-alloc, io-free, io-buf-new, io-buf-free, int-val, alloc-int, alloc-key, alloc-str, flat-new, flat-get, flat-set, array-get, array-set |
| every capability | r7rs-eval-c-eval__, r7rs-eval-c-load__ -- they evaluate text in the process-global R7RS eval environment, which holds every capability -- and the rest of the r7rs-eval-c-*__ family (-apply__, -push-*__, -answer-*__, -frame-*__, -result-*__), which act on that same environment |
unsafe is given to the natives whose only purpose is to allocate, free or
dereference a raw address with no typed wrapper. It is not given to the
collection and string natives, although they take handles as bare integers
too; a sandbox without vectors and maps would be useless. That gap is not a
capability's to close -- it is closed by the handle provenance registry (S-5):
in a restricted env each handle argument is checked against the handles minted
as the kind its position names, so a forged integer -- or a string literal, a
float or a struct where a vector is expected -- is refused while a real handle
round-trips. The per-native handle-kind column lives beside this table in
src/turi/native_caps.c (k_handle_rows[]), and the sandbox test sweeps every
capability-free native with forged arguments so a native nobody classified is
found. The
S-5 report
has the model and the limits: String-keyed maps and sets are refused in a
restricted env, scratch promotion is off there, and re-entrant call/cc is
escape-only outside a top-level form.
The operations that are not native functions are checked where they are
evaluated: the println builtins and the raw-memory builtins in the builtin
dispatch, dlopen and friends and extern-c on the FFI path, inline C, the
(async ...) form, and import/load in the elaborator.
-X feature flags