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 artifacts; the only runtime dependency is wasmtime. ES modules resolve through a host loader callback; inline TypeScript is type-stripped by a separate OXC-backed transform .wasm before QuickJS sees it.

[!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 by the host transform adapter 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.

Transform flags are also public for hosts that need a different policy. Runtime flags apply to top-level eval calls and module-loaded sources:

from quickjs_rs import Runtime, SourceTransform

with Runtime(transform_flags=SourceTransform.TOP_LEVEL_CONST_TO_VAR) as rt:
    with rt.new_context() as ctx:
        ctx.eval("const exposed = 1;")
        assert ctx.eval("globalThis.exposed") == 1

For TypeScript sources, choose the source kind explicitly:

from quickjs_rs import Runtime, SourceTransform

with Runtime(transform_flags=SourceTransform.SOURCE_TS | SourceTransform.STRIP_TYPESCRIPT) as rt:
    with rt.new_context() as ctx:
        assert ctx.eval("const value: number = 2; value") == 2

Eval calls can override the runtime policy:

ctx.eval("const scoped = 1;", transform_flags=SourceTransform.NONE)

Module loaders can also override the runtime policy per canonical module name. For example, this enables the extra top-level const to var rewrite while keeping the default TypeScript/TSX module behavior:

from quickjs_rs import SourceTransform, default_module_transform_flags

def transform_flags(name):
    return default_module_transform_flags(name) | SourceTransform.TOP_LEVEL_CONST_TO_VAR

rt.set_module_loader(load=sources.get, transform_flags=transform_flags)

Policy precedence is: per-eval transform_flags, then module-loader transform_flags, then runtime transform_flags, then the default module TypeScript/TSX policy. Pass SourceTransform.NONE to disable transforms explicitly.

For one-off transforms outside module loading, use transform_source():

from quickjs_rs import SourceTransform, transform_source

js = transform_source(
    "plain.js",
    "export const value = 1;",
    flags=SourceTransform.TOP_LEVEL_CONST_TO_VAR,
)

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 wasm guests (needs the Rust toolchain + the wasm target):
#   rustup target add wasm32-wasip1
python scripts/build_guest.py        # cargo build -> quickjs_rs/_guest.wasm + _transform.wasm

# Dev install (pure-Python package; 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.3.tar.gz (829.0 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.3-py3-none-any.whl (799.8 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: quickjs_rs-0.2.3.tar.gz
  • Upload date:
  • Size: 829.0 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.3.tar.gz
Algorithm Hash digest
SHA256 5715bb3b6c7583999a4f053a4547fb149c70a9ef0962aa93884cbfb89fb5a471
MD5 79e1527d08491aa3f9c15c200c849dcf
BLAKE2b-256 46d53657636d9cdff1b2e76e17c210a52704158f13149e7f8175cb533bb64ca2

See more details on using hashes here.

Provenance

The following attestation bundles were made for quickjs_rs-0.2.3.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.3-py3-none-any.whl.

File metadata

  • Download URL: quickjs_rs-0.2.3-py3-none-any.whl
  • Upload date:
  • Size: 799.8 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.3-py3-none-any.whl
Algorithm Hash digest
SHA256 52879678d62dea10f7f30c5821f217a75a2c6d51a3f4c3059d0eacf319b9a889
MD5 8985700ae24d5ec4f2f4fa723cf17a7b
BLAKE2b-256 b7fe637b4128ee76febedc18350329099a764024736eafa79b72635b928a7b80

See more details on using hashes here.

Provenance

The following attestation bundles were made for quickjs_rs-0.2.3-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