CoDNA Request access
Contents

Docs Decisions

0007 · QuickJS on wazero for user automations

Status: Accepted · 2026-09-22

Context

Users need continuous behaviour — "whenever a document is tagged runbook, share it with ops" — plus scheduled jobs. The scripts are written by AI agents on request and run inside CoDNA, on behalf of whoever owns them. They are untrusted code in the security sense even though their author is an authenticated colleague.

Decision

Scripts are JavaScript, executed by QuickJS compiled to WebAssembly and run under wazero (fastschema/qjs), which keeps the build CGO-free and the single-binary story intact.

Isolation is layered:

Layer Provides
wazero No host syscalls. No network stack to reach. No filesystem — WASI is not wired in.
QuickJS Heap cap via JS_SetMemoryLimit; bounded stack.
wazero Wall clock, by closing the module when the run's context is cancelled. See the correction below.
CoDNA host The context object is the entire surface. No fetch, require, process, Date.now, or Math.random.

Scripts propose actions; the host validates each against the automation owner's permissions and applies the survivors in one transaction.

Consequences

  • A script cannot exceed its author. It has no way to, regardless of what it contains.
  • Reads (codna.find) run ACL-filtered as the owner, so a scheduled script cannot walk the knowledge base.
  • Withholding Date.now and Math.random makes runs reproducible from their automation_runs record.
  • Rejected actions are recorded with reasons, so a silently failing script is discoverable.
  • fastschema/qjs is v0.0.6 — young for a component on the security boundary. CoDNA defines its own sandbox.Runtime interface so the binding is swappable for a hand-rolled wazero host.
  • QuickJS is not a modern JS engine. Scripts are small; this has not been a constraint.

Correction

2026-09-23 — This record said wall-clock was enforced by an interrupt handler. In fastschema/qjs v0.0.6 the corresponding option, MaxExecutionTime, does not stop an infinite loop. The mechanism that works is wazero's close-on-context-done: cancel the run's context and the module is torn down mid-call, surfacing as a panic on the executing goroutine, which the sandbox recovers and records as a breach.

That mechanism is configured on the compiled module, which the binding caches globally, so the first runtime built in the process decides for all of them. A warm-up call that omitted the flag disabled every timeout in the process. The interface this ADR required — CoDNA's own sandbox.Runtime — is what kept the fix inside one package.

Alternatives rejected

A declarative condition→action DSL. Safer and simpler on day one. Rejected because it accretes: "unless already shared", "only in wiki", "not on weekends" — and ends up a language with no debugger, no tests, and no documentation.

goja (pure-Go JS interpreter). Far simpler interop and no wasm blob. Rejected on limits: memory has no hard cap and CPU is stopped by a cooperative watchdog rather than the engine itself.

Starlark. The strongest safety story and the least host code. Rejected because it is Python-shaped — users reviewing what their agent wrote should be reading something they recognize.

Deno subprocess. Most capable, OS-level sandboxing. Rejected because it adds a runtime dependency, which contradicts 0003.