WASM security
How Lumis loads parser WebAssembly, what the sandbox enforces, and how parser bytes are checked.
Every Lumis runtime except the Rust crate runs Tree-sitter parsers as WebAssembly. Two inputs matter: the source code you highlight, and the parser that reads it.
Highlighting source code you didn't write is safe. A parser is a dependency: the sandbox limits what its code can do, and Lumis checks the bytes it downloads.
Where parsers run
| Runtime | Engine | A parser runs in |
|---|---|---|
| Node with the native addon, Elixir, CLI | Wasmtime 36, through Tree-sitter's Wasm store | a Wasmtime sandbox with a fixed import list |
| Browsers, Bun, Deno, Node without the addon | web-tree-sitter 0.26 (Emscripten) | the JavaScript engine's WebAssembly sandbox |
| Rust | none | your binary. Parsers are C compiled in, like any other C dependency |
lumis4j lives in a separate repository and isn't covered here.
The source you highlight
Lumis assumes it's hostile.
- A parser only decides which byte ranges get which name from Lumis's fixed scope list. The formatter escapes the text, so neither the document nor the parser can put markup into HTML output.
- The budget caps each render with a time limit and a match limit. Running out returns the document plain or partly highlighted, never an error.
- Elixir renders on dirty CPU schedulers, so a slow render doesn't block the BEAM's normal schedulers.
What the Wasmtime sandbox enforces
Tree-sitter links each parser into a Wasm store it owns, on an engine Lumis creates with Wasmtime's default configuration.
- Imports are an allowlist. A parser can import
24 libc functions
(
malloc,memcpy,strcmp,towlowerand similar), the lexer callbacks, and a few abort and no-op stubs. Any other import fails the load. Nothing on that list touches files, sockets, clocks or processes. - Memory is capped. A store's linear memory stops at 128 MiB and a scanner's heap at 4 MiB. Wasmtime bounds-checks every memory access, keeps the call stack out of linear memory, and type-checks indirect calls (Wasmtime security).
- The host checks what crosses the boundary. Since tree-sitter#5569, every read of language data out of WASM memory is bounds-checked, and a scanner's serialized state can't overrun the host buffer.
- A trap only fails the current render. Tree-sitter flags the store and stops the parse, and Lumis returns an error for that render.
Wasmtime 36 is a long-term support release and gets security fixes for 24 months (release policy).
Where parser bytes come from
npm and Hex verify an installed parser against your lockfile, so Lumis loads it as installed. When Lumis downloads parser bytes itself (the CLI, a resolver, a browser fetching a parser asset), it checks them against the SHA-256 in the parser's language package (Rust, JavaScript) and checks its own cache again on every read. CI builds each parser once and publishes it to npm with provenance.
The compiled-module cache
Wasmtime compiles each parser to machine code and caches it under compiled/
in the data directory. It
loads those files back as code without checking that it wrote them (the same
caveat as
Module::deserialize),
so anyone who can write that directory can run code in your process. Give it
the permissions you give your application's code.
Content Security Policy
Lumis works under a policy without 'unsafe-eval'. It needs
'wasm-unsafe-eval'
to compile WebAssembly; without it, loading fails at Parser.init.
Content-Security-Policy: script-src 'self' 'wasm-unsafe-eval'Bundlers warn about two direct eval calls in web-tree-sitter. Emscripten
uses them only to load JavaScript embedded in a parser, and Tree-sitter parsers
carry none, so the policy can leave eval off.