Skip to main content

Sandboxed JavaScript execution for Python, via wasmtime + rquickjs.

Project description

quickjs-rs

Sandboxed JavaScript execution for Python.

JS runs inside a WebAssembly sandbox: quickjs-ng (a QuickJS fork) via rquickjs is compiled to wasm32-wasip1 and driven by wasmtime. The package is pure Python - one universal wheel that bundles the guest .wasm; the only runtime dependency is wasmtime. ES modules resolve through a host loader callback; inline TypeScript is type-stripped via oxidase.

[!WARNING] quickjs-rs is experimental. Before putting this in production, you should read the Security guide.

Install

pip install quickjs-rs
uv add quickjs-rs

Ships as a single universal pure-Python wheel (py3-none-any) — the bundled guest is platform-independent WebAssembly, and wasmtime supplies the per-platform runtime. Requires Python 3.11+; runs anywhere wasmtime has a wheel (Linux, macOS, Windows; x86_64 + arm64).

Quickstart

from quickjs_rs import Runtime

with Runtime() as rt:
    with rt.new_context() as ctx:
        assert ctx.eval("1 + 2") == 3

        # Register a Python callable as a JS global.
        @ctx.function
        def greet(name: str) -> str:
            return f"hi {name}"
        assert ctx.eval("greet('world')") == "hi world"

Async + top-level await:

import asyncio

async def main():
    with Runtime() as rt:
        with rt.new_context() as ctx:
            @ctx.function
            async def fetch_thing() -> str:
                await asyncio.sleep(0.01)
                return "from python"

            result = await ctx.eval_async("await fetch_thing()")
            assert result == "from python"

asyncio.run(main())

ES modules

Supply modules through a host loader callback pair: normalize(base, specifier) resolves an import to a canonical name, and load(name) returns the source. The host owns all resolution policy — there is no built-in scope model.

import posixpath
from quickjs_rs import Runtime

sources = {
    "@agent/config": "export const MAX_RETRIES = 3;",
    "@agent/utils": "export { slugify } from './strings.js';",
    "@agent/utils/strings.js":
        "export const slugify = s => s.toLowerCase().replace(/ /g, '-');",
}

def normalize(base, spec):
    if not spec.startswith("."):
        return spec                       # bare name → canonical name
    base_dir = base if "." not in posixpath.basename(base) else posixpath.dirname(base)
    return posixpath.normpath(posixpath.join(base_dir, spec))  # relative → joined

with Runtime() as rt:
    rt.set_module_loader(normalize=normalize, load=sources.get)
    with rt.new_context() as ctx:
        assert await ctx.eval_async("""
            const { slugify } = await import("@agent/utils");
            const { MAX_RETRIES } = await import("@agent/config");
            slugify("Hello World") + '/' + MAX_RETRIES;
        """) == "hello-world/3"

normalize is where sandboxing lives — return None to refuse a specifier.

TypeScript

Module sources whose canonical name ends in .ts, .mts, .cts, or .tsx are type-stripped (in the guest, via oxidase) before evaluation. Enums, namespaces, and parameter properties are transformed; plain type annotations erase. No type checking — run tsc --noEmit separately for that.

# A canonical name ending in .ts/.tsx is stripped before QuickJS sees it.
ts_sources = {
    "util.ts": """
        export enum Mode { Strict = 1, Loose = 2 }
        export function slug(s: string, mode: Mode): string {
            return s.toLowerCase().replace(/ /g, mode === Mode.Strict ? '_' : '-');
        }
    """,
}

with Runtime() as rt:
    rt.set_module_loader(load=ts_sources.get)
    with rt.new_context() as ctx:
        assert await ctx.eval_async(
            "const { slug, Mode } = await import('util.ts');"
            "slug('Hello World', Mode.Strict)"
        ) == "hello_world"

A TypeScript parse error surfaces as a module-load error rather than at eval.

Snapshots

A snapshot captures the entire guest heap — every object, the atom table, the job queue, closures, and pending promises — as a flat image, and reconstitutes it into a fresh context. Because it's the whole VM memory, aliasing and closure state survive exactly.

from quickjs_rs import Runtime, Snapshot

with Runtime() as rt:
    with rt.new_context() as ctx:
        ctx.eval("""
            const shared = { count: 1 };
            const a = shared, b = shared;
            const counter = (() => { let n = 0; return () => ++n; })();
        """)
        payload = ctx.create_snapshot().to_bytes()

with Runtime() as rt2:
    with rt2.new_context() as ctx2:
        rt2.restore_snapshot(Snapshot.from_bytes(payload), ctx2)
        assert ctx2.eval("a === b") is True       # aliasing preserved
        assert ctx2.eval("counter()") == 1        # closure state preserved

A snapshot must be taken at a quiescent point (no in-flight eval_async, no pending async host calls); create_snapshot_async() is the async-context form. Restore validates a fail-closed header — including a build_id that rejects a snapshot taken from a different guest build — before writing the image. Treat snapshot bytes as trusted input (see Security).

snap = await ctx.create_snapshot_async()
rt.restore_snapshot(snap, other_ctx, inject_globals=True)

Security

  • The WebAssembly sandbox is the isolation boundary. JS executes inside the guest's wasm linear memory; quickjs-ng never sees a host pointer and cannot read or write Python's address space. A bug in the JS engine is contained within the sandbox, not a path to host-memory compromise — this is the central reason JS runs in wasm rather than as a native extension.

  • The residual runtime-escape risk is wasmtime itself. wasmtime executes the guest in-process, so a vulnerability in wasmtime / Cranelift (the JIT) is the one path that could cross the sandbox boundary. Keep wasmtime updated. For hostile multi-tenant workloads where you must defend against an active runtime-attacker, add process/container isolation on top and recycle on timeout/OOM — the sandbox raises the bar but does not replace defense in depth.

  • Registered host callbacks are capability boundaries. Anything you expose to JS via ctx.register(...) is reachable by the sandboxed code; treat every such callback as privileged when running untrusted JS. The sandbox contains the engine, not the capabilities you hand it.

  • Resource limits are enforcedRuntime(memory_limit=...) caps heap, a per-eval timeout interrupts runaway JS (the instance survives), and a runaway recursion is contained by the sandbox (it traps the wasm instance rather than the host; see the threat model for the wasi stack-check caveat).

  • Each Context is its own isolated wasm instance — separate linear memory, no shared globals/modules. Still, use one Runtime per trust domain.

  • Snapshots are trusted input. A whole-memory snapshot is an arbitrary guest heap image. Restore validates a fail-closed header (incl. a build_id that rejects a snapshot taken from a different guest build), but a same-build crafted image is not made safe by that check — do not restore snapshots from an untrusted source, and do not restore across guest builds.

Development

# Build the guest wasm (needs the Rust toolchain + the wasm target):
#   rustup target add wasm32-wasip1
python scripts/build_guest.py        # cargo build → quickjs_rs/_guest.wasm

# Dev install (pure-Python package; the wasm is bundled above).
pip install -e ".[dev]"

# Run tests, type-check, lint.
pytest
mypy quickjs_rs
ruff check

License

MIT. See LICENSE.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

quickjs_rs-0.2.0.tar.gz (483.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

quickjs_rs-0.2.0-py3-none-any.whl (454.5 kB view details)

Uploaded Python 3

File details

Details for the file quickjs_rs-0.2.0.tar.gz.

File metadata

  • Download URL: quickjs_rs-0.2.0.tar.gz
  • Upload date:
  • Size: 483.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.13

File hashes

Hashes for quickjs_rs-0.2.0.tar.gz
Algorithm Hash digest
SHA256 db1a8d72ddfc0e766772622adb510f91aa8626d87afd843893c1e2a360ee1ce8
MD5 fc38279fe2fb5281300595807c8291c8
BLAKE2b-256 3f59263e19bcff54b7e7993de3f79da25c23b7d7296d84c13d31a3e38ea0ec7f

See more details on using hashes here.

Provenance

The following attestation bundles were made for quickjs_rs-0.2.0.tar.gz:

Publisher: release.yml on langchain-ai/quickjs-rs

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file quickjs_rs-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: quickjs_rs-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 454.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.13

File hashes

Hashes for quickjs_rs-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 cb661d40ee3ba6d3673a75729223315fd4f0b22087373156425fa4227fa2adc6
MD5 74258c778ad1defede3dc3b1f2284e9f
BLAKE2b-256 a7227caf4552a7880f205c95099c3c3e6d36f146af4e34a0b3baa12d49c47ea1

See more details on using hashes here.

Provenance

The following attestation bundles were made for quickjs_rs-0.2.0-py3-none-any.whl:

Publisher: release.yml on langchain-ai/quickjs-rs

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page