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 default | To allow it | |
|---|---|---|
| Itself (its fields, functions, and objects) | Yes | |
Library functions (Std, Str, List, Time, ...) | Yes | |
| Your app's functions | Only the ones you add | doc.lib('App', 'notify', fn) |
| The network | No | doc.allowHttp(['api.example.com']), or doc.allowHttp() for any public host |
| Your machine and private network | No | list the hosts, or doc.allowPrivateNetwork() |
| Files and environment variables | No | allow_system() (Python, Rust; the JavaScript package has none) |
| Other documents | No | each 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
| JavaScript | Python | Rust | |
|---|---|---|---|
| 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 too | doc.allowPrivateNetwork() | doc.allow_private_network() | graph.allow_private_network() |
| Files and environment | not available | doc.allow_system() | graph.allow_system() |
| Time limit | doc.setMaxExecutionTime(ms) | doc.set_max_execution_time(seconds) | graph.max_execution_time = Some(duration) |
| Memory limit | doc.setMaxMemory(bytes) | doc.set_max_memory(bytes) | graph.max_memory = Some(bytes) |
| HTML elements, attributes | doc.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, unseal | doc.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) |
| Digest | doc.digest(path) | doc.digest(path) | graph.digest_path(path, fields) |
| Sign, verify | StofDoc.sign, StofDoc.verify, StofDoc.publicKey | Doc.sign, Doc.verify, Doc.public_key | stof::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.