Sandbox and execution isolation
Every Steel table script runs inside a fail-closed sandbox. The language reference describes what scripts may say; this page describes how the server vets a script before it is stored and how execution is isolated.
Vetting a script
Section titled “Vetting a script”Two independent checks run when a script is saved. Runtime execution then trusts the stored script, so rows are not re-parsed or re-validated on every write.
Deny-list first pass
Section titled “Deny-list first pass”A hand-written tokenizer parses the script text and rejects a fixed list of forms:
define define-syntax define-valuesset! set-car! set-cdr! set-box! box-set!vector-set! mutable-vector-set! string-set! bytes-set!hash-set! hash-remove!eval load require steel_query_sqlThis is a cheap first pass, not the authority: a parser that is not Steel’s can disagree with Steel about what a program says, and a deny-list only stops what somebody thought to add.
Allow-list on the expanded AST
Section titled “Allow-list on the expanded AST”The authoritative check expands the script with Steel’s own engine and accepts only constructs that are explicitly enumerated:
| Group | Examples |
|---|---|
| Server-registered functions | steel_get_column, steel_related_aggregate, steel_related_count_rows, steel_related_exists, has-var?, quantity-add, quantity-subtract, the money-* family |
| Decimal functions | the decimal-* family from steel-decimal, set-precision, clear-precision, get-precision |
| Pure builtins | number, string, and immutable-collection readers and builders |
| Higher-order functions | map, filter, foldl, fold, reduce, apply, for-each, range, list-sort |
| Pure special forms | %plain-let, if, begin, and, or, void |
Bindings, set!, macros, require, and return are rejected structurally rather than by name — this is what stops struct, which the deny-list misses. Names bound by lambda and let are tracked lexically and allowed. Anything unrecognized fails closed: a form nobody enumerated is rejected, not executed.
Execution isolation
Section titled “Execution isolation”- A fresh engine per run.
Engine::cloneshares global Steel state, so engines are rebuilt instead of cloned. - An engine pool of at most 16 engines, each retired after 10,000 runs. After every run the pool compares the engine’s global bindings to its baseline and discards any engine a script contaminated — a defense in depth behind the allow-list.
- The request’s row context lives in a thread-local that is set for the run and restored when it ends, even on panic.
- Decimal precision is cleared before and after every run, so
set-precisioncannot leak into another request. - Scripts run on the blocking pool, off async runtime workers.
- Admin bulk imports use a disposable per-request engine cache instead of the global pool.
The sandbox does not impose a wall-clock or instruction budget: isolation is structural (an allow-list plus fresh, uncontaminated engine state), not a time limit.
The quantity-add and quantity-subtract functions push into the per-profile quantity ledger — see Quantity ledger.