Skip to main content

Security and the Sandbox

Stof is built to run logic you didn't write: a rule a model wrote, a customer's settings, a document from another service. So a document starts with nothing. It can read and change itself and call the functions your app hands it, and everything else is something your app turns on, in every language.

This page is what a document can reach, the limits on every call, and how to lock the parts you trust. The examples are TypeScript; the table at the end has the Python and Rust names. For a walkthrough with examples, see Running Logic You Didn't Write, and the playground's Security examples run each one in your browser, starting with sealing.

What a Document Can Reach​

By defaultTo allow it
Itself (its fields, functions, and objects)Yes
Library functions (Std, Str, List, Time, ...)Yes
Your app's functionsOnly the ones you adddoc.lib('App', 'notify', fn)
The networkNodoc.allowHttp(['api.example.com']), or doc.allowHttp() for any public host
Your machine and private networkNolist the hosts, or doc.allowPrivateNetwork()
Files and environment variablesNoallow_system() (Python, Rust; the JavaScript package has none)
Other documentsNoeach document is its own sandbox

A document can't add to this list. Library functions it doesn't have, like Http before you allow it, fail with an error that says the host hasn't allowed them. The stof command-line tool allows everything, since it runs your own files.

Limits on Every Call​

Each call has a time limit and a memory limit, so a document that loops forever or keeps growing fails its call instead of taking your app down:

doc.setMaxExecutionTime(500); // milliseconds (2 minutes by default), or null for no limit
doc.setMaxMemory(64 * 1024 * 1024); // bytes a call may grow memory by (256 MB by default), or null
memory limit exceeded: a call can use up to 64 MB more memory (the host's max memory)
at root.grow (defined at 1:1)

The document keeps working after either error, and a catch inside the document can't keep the call going. Memory a call keeps (a field it sets, say) counts against that call only.

In JavaScript, if the engine itself ever fails mid-call (with the memory limit turned off, for example), the call rejects instead of ending your process. That document is then marked doc.crashed and every later call on it throws, so create a new one; your other documents keep working.

The Network​

doc.allowHttp(['api.example.com']) lets a document reach only those hosts; a request anywhere else fails before it's sent, and so does a redirect to one. An entry is a host (any port) or a host and port, like localhost:8080.

doc.allowHttp() with no list means any public host. Your machine and private networks stay off limits: localhost, 10.x, 172.16.x to 172.31.x, 192.168.x, 169.254.x (where cloud providers serve instance credentials), their IPv6 equivalents, and names like *.internal and *.local. The checks run on every redirect too, and in Rust and Python on every address a name resolves to. JavaScript can't see what a name resolves to, so when that matters, list the hosts. To allow the private network anyway, list those hosts or call doc.allowPrivateNetwork().

HTML Output​

An html export is safe to put on a page, even when a model wrote the view: text and attributes are escaped, elements that run code (script, iframe, ...) are never written, on* attributes are dropped, and URLs must be relative or http, https, mailto, or tel. style, action, and formaction (which can cover the page or post a form somewhere else) are written only when you allow them, and you can limit the elements to exactly the components you render:

doc.allowHtmlTags(['div', 'ui-card', 'ui-metric']);
doc.allowHtmlAttributes(['style']);

See Formats.

Sealing What You Trust​

A document can change itself, including its own functions. That's what lets an agent add a rule or a customer override a price. But some parts shouldn't change: your engine's logic, a shared helper, the rules a contract fixed. Seal them, and they can still be called, but not changed:

doc.seal('<Engine>'); // a type, by name
doc.seal('policy.rules'); // or an object, by path

From then on, in the sealed object and everything under it, functions can't be added, removed, renamed, replaced, or moved, by the document's own code or by your app. The same goes for an object's type, for fields that hold a function or a sealed object, for attributes (which decide what runs and what's readonly), and for the sealed objects themselves, and no other type can take a sealed type's name. A change that tries fails with an error, and nothing changes:

sealed: 'price' is sealed and can't be replaced
at root.agent_rule (defined at 1:1)

Sealed code runs as before, including its arrow functions and calls, and its fields can still change (that's where state lives). To freeze those too, seal with { fields: true }.

A document can seal parts of itself too, with seal: seal(self.rules), say, in an #[init] function, so rules added later can't change them. It's one-way: only your app can doc.unseal(path); a document can't.

Stof finds a function by name when it's called, so a sealed function that calls into an unsealed part of the document runs whatever is there. Seal everything a call depends on.

Checking It's the Logic You Shipped​

doc.digest(path) is a SHA-256 over a part's functions and structure (with fields, its values too). It's the same every time for the same document, whether it was parsed from text or loaded as binary (bstf), so it identifies exactly the logic you're about to trust. Check it when you seal, and the seal fails (sealing nothing) if it doesn't match:

doc.seal('<Engine>', { digest: 'a3f1...' });

Or sign the digest when you build the document, ship the public key with your app, and seal only what that key signed:

// at build time (the secret key is 32 random bytes, Ex. `openssl rand -base64 32`)
const signature = StofDoc.sign(doc.digest('<Engine>'), SECRET_KEY);

// in your app
doc.seal('<Engine>', { publicKey: ENGINE_PUBLIC_KEY, signature });

Signatures are Ed25519. StofDoc.verify(digest, publicKey, signature) checks one on its own, and StofDoc.publicKey(secretKey) gives the public key for a secret key.

By Language​

JavaScriptPythonRust
Network (some hosts)doc.allowHttp(hosts)doc.allow_http_hosts(hosts)graph.allow_http_hosts(hosts)
Network (any public host)doc.allowHttp()doc.allow_http()graph.allow_http()
Private network toodoc.allowPrivateNetwork()doc.allow_private_network()graph.allow_private_network()
Files and environmentnot availabledoc.allow_system()graph.allow_system()
Time limitdoc.setMaxExecutionTime(ms)doc.set_max_execution_time(seconds)graph.max_execution_time = Some(duration)
Memory limitdoc.setMaxMemory(bytes)doc.set_max_memory(bytes)graph.max_memory = Some(bytes)
HTML elements, attributesdoc.allowHtmlTags(tags), doc.allowHtmlAttributes(attrs)doc.allow_html_tags(tags), doc.allow_html_attributes(attrs)graph.allow_html_tags(tags), graph.allow_html_attributes(attrs)
Seal, unsealdoc.seal(path, { fields, digest, publicKey, signature }), doc.unseal(path)doc.seal(path, fields=False, digest=None, public_key=None, signature=None), doc.unseal(path)graph.seal_path(path, fields, digest), graph.seal_signed(path, fields, key, signature), graph.unseal_path(path)
Digestdoc.digest(path)doc.digest(path)graph.digest_path(path, fields)
Sign, verifyStofDoc.sign, StofDoc.verify, StofDoc.publicKeyDoc.sign, Doc.verify, Doc.public_keystof::model::seal::{sign, verify, public_key}

In Rust, the memory limit counts everything a call allocates when you use Stof's allocator (the JavaScript and Python packages already do): #[global_allocator] static A: stof::runtime::memory::StofAlloc = stof::runtime::memory::StofAlloc;. Without it, only single steps that would make a value too big ('a' * n, split, join, parse, ...) are refused.