Running Logic You Didn't Write: Stof's Security Model in 0.10.10
Stof is for running logic you didn't write: a rule a model drafted, a customer's settings, a workflow another service sent over. Your app runs it in-process, with no deploy, and stays in charge of what it can do.
Stof 0.10.10 adds the pieces that make that easy to rely on: sealing the logic you trust, digests and signatures to check it's what you shipped, a memory limit on every call, network access that stays off your private network, and stricter HTML. This post walks through the whole model, with examples you can run in the playground.
npm i @formata/stof@latest # TypeScript / JavaScript
pip install -U stof # Python
cargo add stof # Rust
The Short Version
- A document starts with nothing. It can read and change itself and use Stof's libraries. Your functions, the network, files, and environment variables are each something your app turns on.
- Every call has a time limit and a memory limit (memory is new), so a call that loops or keeps growing fails on its own, and your app carries on.
- Network access is to the hosts you list. "Any host" means any public host, never your machine or private network (new).
- HTML output is safe to put on a page, even when a model wrote the view (stricter in 0.10.10).
- You can seal the logic you trust so it can be called but not changed, and check a digest or signature first (new).
- Attributes can change at runtime (new), and sealing keeps them fixed where it matters.
Security and the Sandbox is the reference, with the Python and Rust names for everything here.
What a Sandbox Has to Answer
Running someone else's logic comes down to a few questions, and each section below is Stof's answer to one:
- What can it reach? Files, environment variables, the network, your own services.
- How long and how much can it run? Time, and memory.
- Where can it connect? Which hosts, and never your private network by accident.
- Is its output safe to show? An HTML view, rendered on your page.
- Can it change the parts you rely on? Your engine, a shared helper, a rule a contract fixed.
- Is it the logic you shipped? After storage, or a trip between services.
1. Start with Nothing
A new document can see itself and nothing else:
| By default | To allow it | |
|---|---|---|
| Its own fields, functions, and objects | Yes | |
Stof's libraries (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']) |
| Files and environment variables | No | allow_system() in Python and Rust (JavaScript has neither) |
| Other documents | No | Each document is its own sandbox |
A document can't add to this list. Calling a library your app hasn't allowed fails with an error that says so:
'Http::fetch' isn't available: this host hasn't given documents network access
The best way to give a document power is a function, not access. Instead of opening the network so a document can issue refunds, hand it a refund function with your limits inside:
doc.lib('App', 'refund', (orderId: string, amount: number) => {
if (amount > 100) throw new Error('refunds over $100 need a person');
return payments.refund(orderId, amount);
});
The document can call App::refund('ord_1', 20), and that's all it can do with your payments system. (Try it.)
2. Limits on Every Call
Every call has a time limit, and now a memory limit too. A time limit stops a loop, but a call can allocate faster than any sensible time limit, like a string that doubles each time around:
fn grow() { let s = 'aaaaaaaaaaaaaaaa'; loop { s = s + s; } }
With 0.10.10, that call stops once it has grown memory by 256 MB (or whatever you set):
doc.setMaxExecutionTime(500); // milliseconds (2 minutes by default)
doc.setMaxMemory(64 * 1024 * 1024); // bytes a call can grow memory by (256 MB by default)
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 call fails, a catch inside the document can't keep it going, and the document is ready for the next call. Set both limits to what your calls need: a pricing check needs milliseconds and a few megabytes. (Try it.)
In JavaScript there's a second layer: if the engine itself ever fails mid-call, the call rejects like any other error. That document is marked doc.crashed and refuses further calls, and your other documents keep working.
3. The Network
List the hosts a document can reach, and it can reach only those. A request anywhere else fails before it's sent, and so does a redirect to one:
doc.allowHttp(['api.example.com', 'hooks.slack.com']);
doc.allowHttp() with no list means any public host. Your machine and private network are left out: localhost, 10.x, 172.16.x to 172.31.x, 192.168.x, their IPv6 equivalents, names like *.internal and *.local, and 169.254.x, where cloud providers serve instance credentials.
Rust and Python check every address a name resolves to, so a public name pointing at a private address is refused too. JavaScript's fetch can't see what a name resolves to, so when that matters, list the hosts. When a document should reach a private host, list it, or call doc.allowPrivateNetwork().
4. Output Your Users See
If you render a view a document produced, the html export is safe to put on the page:
#[tag('div')]
view: {
#[tag('p')] note: '<img src=x onerror=alert(1)>'
#[tag('script')] code: 'stealCookies()'
#[tag('a')]
upgrade: {
#[attr] href: 'javascript:alert(1)'
#[attr] onclick: 'alert(1)'
#[text] label: 'Upgrade'
}
}
<div><p><img src=x onerror=alert(1)></p><a>Upgrade</a></div>
Text is escaped, elements that run code are never written, on* attributes are dropped, and URLs must be relative or http, https, mailto, or tel. style, action, and formaction are written only when your app allows them, since CSS can cover a page and a form can post anywhere. If you render specific components, allow exactly those, and a document can't widen the list:
doc.allowHtmlTags(['div', 'p', 'ui-card', 'ui-metric']);
doc.allowHtmlAttributes(['style']);
(Try it.)
5. Seal What You Trust
A document can change itself, functions included. That's what lets an agent add a rule or a customer override a price. Some parts should stay exactly as you wrote them, though: your engine, a shared helper, a rule a contract fixed. Seal those:
doc.seal('<Engine>'); // a type, by name
doc.seal('policy.rules'); // or an object, by path
Sealed functions can still be called, but not added, removed, renamed, replaced, or moved, by the document or by your app. The same goes for the sealed objects themselves, their attributes (which decide what runs and what's readonly), and the fields that hold functions. No other type can take a sealed type's name. A change that tries fails, and nothing changes:
fn agent_rule() {
<Engine>.price = (units: int): int => 0;
}
sealed: 'price' is sealed and can't be replaced
at root.agent_rule (defined at 1:1)
Fields still change, since that's where state lives; seal with { fields: true } to freeze them too. A document can seal parts of itself (seal(self.rules) in an #[init] function, say), and only your app can unseal. Sealing costs nothing per call: the checks happen where a document changes, not where it runs. (Try it.)
Sealing doesn't stop a document from building on what you sealed. It can still add its own types, its own functions, and subtypes that override a sealed type's functions and call the original:
#[type]
Engine Discounted: {
fn price(units: int) -> int {
if (units > 100) return self.price<Engine>(units) / 2;
self.price<Engine>(units)
}
}
Everything built on Engine keeps the Engine you shipped.
One thing to know: 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.
6. Check It's the Logic You Shipped
A seal protects logic from the moment you seal it. Before that, you want to know it's the right logic. doc.digest(path) is a SHA-256 over an object's functions and structure, the same whether the document was parsed from text or loaded as binary, so it identifies exactly what you're about to trust. Seal with the digest you expect, and the seal fails (sealing nothing) if it doesn't match:
doc.seal('<Engine>', { digest: ENGINE_DIGEST });
(Try it.)
Or sign the digest when you build, and seal only what your key signed. This is how logic moves between services. Seals aren't written into a document's exports, so a document sent as bstf arrives unsealed and can't claim to be sealed; the receiving service checks it and seals it itself:
// service A: build, sign, send
const signature = StofDoc.sign(doc.digest('rules'), SECRET_KEY);
await fetch(SERVICE_B, {
method: 'POST',
body: doc.blobify('bstf'),
headers: { 'x-rules-signature': signature },
});
// service B: seal only what A signed (throws otherwise)
const doc = await StofDoc.new(body, 'bstf');
doc.seal('rules', { publicKey: SERVICE_A_PUBLIC_KEY, signature });
Signatures are Ed25519, and work the same in JavaScript, Python, and Rust.
7. Attributes You Can Change
Attributes decide a lot in Stof: what runs (#[main], an #[on_order] handler), what's #[readonly] or #[private], how a value is exported. Until now only the source could set them. Now a document can, while it runs:
self.remove_attribute('on_order', 'add_tax'); // this handler stops running
(self.add_discount).set_attribute('on_order'); // and this one starts
self.order.set_attribute('readonly', null, 'total'); // and this field is locked
Because attributes are behavior, a sealed object's attributes never change, even when its fields may. (Changing attributes.)
Your App's Part
Stof keeps a document inside the lines your app draws. Drawing them well is the other half:
- The functions you hand a document run with your app's access.
App::refundis as safe as its checks, so keep these functions narrow and put the limits inside them. - A document's data is visible to its own code. Keep API keys and other secrets in your app, behind a function.
- A call can use up to its time limit. In a server running many customers' documents, set tight limits, and consider worker threads so one slow call can't hold up the others.
- In Rust, the memory limit counts everything with Stof's allocator (
stof::runtime::memory::StofAlloc). The JavaScript and Python packages install it for you.
A Checklist
- List hosts with
allowHttp([...]). - Set the time and memory limits to what your calls need.
- Give documents narrow functions, not access.
- Render HTML with
allowHtmlTagsset to the elements you use. - Seal your engine and shared logic after loading them, and check a digest or signature when they come from somewhere else.
- Keep secrets in your app.
Upgrading
Most documents run unchanged. The defaults that moved:
| Change | Before | Now | What to do |
|---|---|---|---|
allowHttp() with no list | Any host | Any public host | List private hosts, or call allowPrivateNetwork() |
| Memory | No limit | 256 MB of growth per call | setMaxMemory(bytes), or null for no limit |
HTML style, action, formaction | Written | Written only when allowed | allowHtmlAttributes([...]) |
| HTML URLs | Any scheme but javascript:, vbscript:, and non-image data: | Relative, http, https, mailto, tel, and data: images |
Also in this release:
- Python: library functions take any number of arguments (it was at most 9), and values Python can't hash (a set of lists, a map keyed by a list) come back instead of raising.
- The same source compiles to the same instructions, which is what makes digests stable.
- CI builds and tests the JavaScript and Python packages on every change, not only the Rust crate.
- The npm package ships the Stof skill for AI coding agents, in
node_modules/@formata/stof/skill/stof. - The playground has a Security section: sealing, digests, limits, and safe HTML.
- 0.10.11 followed the same day with one fix:
exit(..)on a process that had already finished could panic.
The changelog has every detail. Questions or ideas? Find us on Discord or GitHub.
