Crux Daemon · 6. WASM extensions

Where an external tool runs on your infrastructure, a WASM extension runs inside the daemon process, inside a wasmtime sandbox with fuel, memory and wall-clock limits. No network, no filesystem, no syscalls, only the host functions the daemon exports.

6.0 In plain English

WebAssembly is a compilation target: you write code in a language you already use, compile it to a portable binary, and something else runs that binary without needing your toolchain. Here, that something else is the daemon itself. Your code executes in the daemon's own process rather than in a service of yours that the daemon calls over the network.

Running someone else's code inside your process is normally a very bad idea, so the whole of this chapter is about the box it runs in. A sandbox is close to a padded room with a serving hatch. The code inside can compute, and it can pass things through the hatch to functions the daemon chose to expose, and that is the entirety of what it can reach. There is no network, no filesystem and no system calls. Three separate limits stop a module hanging the daemon by accident or on purpose: fuel caps how much work it may do, a memory ceiling caps how much it may allocate, and a wall-clock limit stops it running past its time.

Why bother, when an external tool is simpler? Latency and operational weight. An in-process call avoids a network round trip entirely, and it means the operator has nothing extra to deploy, monitor or keep online. That is the whole trade: you accept a much more restricted environment in exchange for being inside the process. If your extension needs the network or the disk, it is an external tool and §6.8 will tell you so.

You will come here when you have decided you need real logic rather than a declarative pack, and you can live inside the sandbox. Read §6.8 first if you are still choosing. Note that the daemon must be built with the feature enabled at compile time, so this is not a surface you can turn on in a running deployment.

The thing people get wrong is assuming that pinning a module by hash is a one-off check performed at install. It is not. The hash is re-verified on every single dispatch, which means swapping the file on disk after installation does not quietly change what runs; it makes the call fail instead. That is deliberate, and it is the property that makes the pin worth having at all.

6.1 Build the daemon with the feature

The whole path is behind a Cargo feature:

wasm-extensions = ["dep:wasmtime"]

(crates/corecruxd/Cargo.toml:21)

cargo build --release -p corecruxd --features wasm-extensions

Without it, the invoke route answers 501: wasm extensions require building corecruxd with --features wasm-extensions (http/extensions.rs:986).

With the feature on but engine construction failed at startup, the route answers 503 wasm engine init failed at startup; restart the daemon and check logs (http/extensions.rs:926). The engine is built once into AppState (main.rs:802, http/mod.rs:394) and shared across all extensions, the wasmtime-recommended pattern.

6.2 The manifest

{
  "schema": "crux.integration.v1",
  "id": "ext.example.summarise",
  "name": "Summarise",
  "version": "0.1.0",
  "publisher_passport_fpr": "p_community_alice",
  "summary": "Summarises a note in-process.",
  "entry": { "kind": "wasm", "path": "tools/summarise.json" },
  "capabilities": ["facts:read", "facts:write"],
  "safety": { "sandbox": "wasm", "max_runtime_ms": 0, "max_output_bytes": 0 },
  "wasm_module_url": "https://example.com/summarise-0.1.0.wasm",
  "wasm_module_sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
  "tools": [
    { "name": "ext.example.summarise.note",
      "description": "Summarise a note.",
      "input_schema": { "type": "object" } }
  ]
}

Validation rules specific to wasm (lib.rs:534):

RuleError text
tools[] non-empty, each with name + descriptionfield 'tools' is required
No auth_shared_secret_id on any toolinvalid identifier 'wasm tools must not set tools[].auth_shared_secret_id; use crux::get_secret_decrypted in-module instead'
external_tool_endpoint unsetinvalid identifier 'external_tool_endpoint must be unset for entry.kind=wasm'
wasm_module_sha256 present, 64 lowercase hexfield 'wasm_module_sha256' is required / invalid identifier 'wasm_module_sha256 must be 64 lowercase hex chars, got N chars'
Exactly one of path or URLinvalid identifier 'wasm_module_path and wasm_module_url are mutually exclusive' / field 'wasm_module_path or wasm_module_url is required'
Path is relative, no ..invalid identifier 'wasm_module_path must be relative to <data_dir>/extensions/{id}/, got '<p>''
URL is HTTPSinvalid identifier 'wasm_module_url must be an https:// URL, got '<u>''

Note the auth_shared_secret_id rule points at crux::get_secret_decrypted, which is currently a stub, see §6.6.

6.3 Hash pinning: the module is content-addressed twice

At install (http/extensions.rs:142):

  1. The URL form triggers a download, capped at 16 MiB (WASM_MODULE_DOWNLOAD_LIMIT_BYTES, wasm_dispatcher.rs:235) with a 30-second global timeout.
  2. SHA-256 is verified before any byte is written to the destination, so a mismatch leaves no partial file (wasm_dispatcher.rs:295).
  3. On success the bytes land at <data_dir>/extensions/<id>/extension.wasm via tmp + rename, and the manifest is rewritten to the path form with the URL dropped (http/extensions.rs:163). Once cached, the daemon never re-fetches. A new module version means uninstall and reinstall.

Install-time failure statuses:

ConditionStatusDetail
Hash mismatch409downloaded module sha256 mismatch: manifest=<a>, downloaded=<b>
Over the cap413wasm module exceeds the 16777216-byte cap
Upstream non-2xx502module URL returned status <n>

At every dispatch (wasm_dispatcher.rs:119): the on-disk bytes are read and re-hashed. A mismatch is 409 module sha256 mismatch: manifest=<a>, on-disk=<b>. Someone swapping the cached file cannot get it executed.

If a persisted record still carries a URL (the install flow was bypassed by writing the fact directly), dispatch refuses rather than downloading mid-call: wasm_module_url is set but never resolved to a cached path; install path may have skipped the M6.4 download step (wasm_dispatcher.rs:44).

6.4 The module contract

Your module exports:

extension_call(req_ptr: i32, req_len: i32, resp_ptr: i32, resp_cap: i32) -> i32
memory                      // standard linear memory, required
_initialize()               // optional, called once after instantiation

(wasm_host.rs:32, wasm_host.rs:462)

Return value: bytes written into the response buffer, or -1 if resp_cap was too small (surfaced as response buffer overflow). Other negative values are reserved and produce module returned reserved code <n> (wasm_host.rs:514).

Request written at req_ptr, the same JSON shape as the external-tool path, deliberately, so templates can share struct definitions (wasm_host.rs:363):

{ "tool": "...", "args": {}, "calling_passport_id": "p_alice", "request_id": "req-..." }

Response you write at resp_ptr, UTF-8 JSON (wasm_host.rs:372):

{ "result": { }, "fact_writes": [] }

Invalid UTF-8 gives response is not valid utf-8; invalid JSON gives response is not valid JSON: <err>.

fact_writes[] in the WASM response is ignored

The type accepts the field for envelope symmetry, but dispatch_wasm_via_http discards the parsed response and returns only the outcome (wasm_dispatcher.rs:156). A WASM module writes facts by calling crux::store_fact during the call, not by returning them. Do not design around the returned array.

Memory layout

The host lays request and response out flat in linear memory: request at offset 0, response 8-byte-aligned just past it, resp_cap capped at 64 KiB (wasm_host.rs:497). Memory is grown by whole 64 KiB pages if needed. The module is not asked to allocate, the comment notes this keeps test fixtures tiny (wasm_host.rs:488). Design for a response under 64 KiB.

Each call compiles and instantiates a fresh instance, which is dropped at the end. State does not persist between calls (wasm_host.rs:415).

6.5 The host ABI

All imports are in the crux module. Signatures from wasm_host.rs:592 onward.

ImportSignatureBehaviour
now_unix_ms() -> i64 (u64)Wall-clock milliseconds (wasm_host.rs:601)
log(level_ptr, level_len, msg_ptr, msg_len)Appends a HostLogEntry {level, message, at_unix_ms} to per-call state, returned in the outcome (wasm_host.rs:608, wasm_host.rs:163)
current_passport_json(ptr, cap) -> i32Writes the bound passport as JSON; bytes written, or -1 if cap too small (wasm_host.rs:629)
read_fact(entity_ptr, entity_len, key_ptr, key_len, resp_ptr, resp_cap) -> i32Grant-scoped single fact read (wasm_host.rs:665)
store_fact(entity_ptr, entity_len, key_ptr, key_len, value_ptr, value_len, confidence_thousandths, resp_ptr, resp_cap) -> i32Grant-scoped write (wasm_host.rs:714)
query_facts(prefix_ptr, prefix_len, query_ptr, query_len, top_k, resp_ptr, resp_cap) -> i32Grant-scoped prefix query (wasm_host.rs:777)
get_secret_decrypted(id_ptr, id_len, resp_ptr, resp_cap) -> i32Stub. Always returns -6 (wasm_host.rs:858)
emit_receipt(...) -> i32Stub. Always returns -6 (wasm_host.rs:868)

Confidence crosses the boundary as i32 thousandths clamped to 0..=1000, so the ABI stays float-free; 1000 means 1.0 (wasm_host.rs:743).

Return codes: a stable, do-not-renumber contract

(wasm_host.rs:253, re-exported as wasm_host::rc)

CodeNameMeaning
-1NOT_FOUNDNo such fact
-2NO_GRANTNo grant bound to this call
-3SCOPE_VIOLATIONEntity or prefix outside the grant
-4BUFFER_TOO_SMALLYour resp_cap was too small
-5FACT_STORE_UNAVAILABLEThis build does not wire fact ops
-6NOT_IMPLEMENTEDThe two stubs above
-7..-9reservedFuture scoped errors
-10HOST_INTERNALHost-side failure
-11BAD_INPUTPointer or length invalid, or non-UTF-8
-12SERIALISE_ERRHost could not serialise the reply

A non-negative return is the byte count written into your buffer.

Grant scoping inside the ABI

  • read_fact and store_fact check entity_matches_any_prefix against allowed_prefixes_read / allowed_prefixes_write; failure is -3 (wasm_host.rs:281, wasm_host.rs:748).
  • query_facts requires a prefix argument that is at least as specific as a granted prefix (query_prefix.starts_with(granted)). The empty string never satisfies this, so a module cannot enumerate the store (wasm_host.rs:289).
  • Results are filtered again after fetch, in case the underlying store is loose about prefixes (wasm_host.rs:828).
  • Every write still passes fact_privacy::enforce_global before storage (wasm_dispatcher.rs:198), and lands under tenant_hash: "default".
  • With no grant bound, every fact host function returns -2 (wasm_host.rs:147).

6.6 Resource limits

WasmConfig::from_env() (wasm_host.rs:87), read per call:

Env varDefaultMeaning
CORECRUXD_WASM_FUEL_DEFAULT1000000Fuel budget, roughly instructions. About 10 ms of integer work
CORECRUXD_WASM_MEMORY_BYTES_DEFAULT16000000Linear-memory ceiling
CORECRUXD_WASM_WALL_MS_DEFAULT1000Wall clock, enforced by epoch interruption
CORECRUXD_WASM_EPOCH_TICK_MS10Epoch tick, the worst-case resolution of the wall-clock trap

The manifest's safety.max_runtime_ms and max_output_bytes are not applied on the WASM path; they only tighten the external-tool path. WASM limits come from the daemon environment alone.

Traps are classified into typed errors (wasm_host.rs:542) and mapped to HTTP (http/extensions.rs:957):

ErrorDisplayHTTP
FuelExhaustedEXT_WASM_FUEL_EXHAUSTED408
DeadlineExceededEXT_WASM_DEADLINE_EXCEEDED408
OutOfMemoryEXT_WASM_OOM507
ModuleFileMissing-404 wasm module file missing at '<path>'
Sha256Mismatch-409
anything elseEXT_WASM_TRAP: <detail> and friends502

Fuel and wall clock are enforced independently. A tight loop exhausts fuel; a long host-call-free spin hits the epoch deadline.

6.7 Invoking

Identical route and body to the external-tool path, the handler branches on entry.kind (http/extensions.rs:739):

curl -s -X POST \
  http://127.0.0.1:14800/v1/extensions/ext.example.summarise/tools/ext.example.summarise.note/invoke \
  -H "Authorization: Bearer $CRUX_AGENT_TOKEN" \
  -H "X-Corecrux-Passport-Id: p_alice" \
  -H 'Content-Type: application/json' \
  -d '{"args": {"doc_id": "note-1"}}'

Response is a WasmDispatchOutcome (wasm_host.rs:350), same spirit as the external-tool outcome, different telemetry:

{
  "result": { "summary": "..." },
  "elapsed_ms": 12,
  "fuel_consumed": 84210,
  "log": [{ "level": "info", "message": "chunked 4 sections", "at_unix_ms": 1753440000000 }],
  "request_id": "req-1753440000000000000"
}

There are no accepted_fact_writes / dropped_fact_writes counters on this path, writes happened synchronously through the host ABI, and their per-call outcome is whatever store_fact returned to your module.

6.8 Choosing between external tool and WASM

External toolWASM
Languageanythinganything targeting wasm32
Network accessyours, unrestrictednone
Latencynetwork round tripin-process
Failure blast radiusyour servicesandboxed, fuel/memory/epoch-capped
Fact accessproposed writes, filtered post-hocdirect, grant-scoped, synchronous
Secretsintended via shared secret (not yet wired)intended via get_secret_decrypted (stub)
Operator build requirementnone--features wasm-extensions
Availability todayfully wiredwired, feature-gated, two ABI stubs

Start with an external tool. Move to WASM when the round trip or the operator's egress policy makes it worth the constraints.

Ground truth