Skip to content

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.

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.

A hand-written tokenizer parses the script text and rejects a fixed list of forms:

define define-syntax define-values
set! 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_sql

This 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.

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.

  • A fresh engine per run. Engine::clone shares 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-precision cannot 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.