Search by

eddmann / terrarium

eddmann

Typed PHP SDK for running untrusted JS, TypeScript, Python, or PHP sandboxed in WebAssembly

Package info

github.com/eddmann/terrarium

pkg:composer/eddmann/terrarium

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 1

v1.4.0 2026-09-16 11:14 UTC

This package is auto-updated.

Last update: 2026-09-16 11:27:54 UTC


README

Terrarium

Terrarium

Run untrusted JavaScript, TypeScript, Python, or PHP inside PHP — sandboxed in WebAssembly, against a typed capability SDK you define in plain PHP.

You write capabilities as ordinary typed PHP functions and register() them; untrusted guest code runs inside a WebAssembly sandbox and calls them by name as a typed API. The guest reaches exactly what you registered — nothing else — and because its entire language engine runs inside the WASM boundary, even a memory-corruption bug in that engine cannot touch your process. No containers, no microVMs: one PHP extension.

The whole thing is one uniform API — a single Terrarium class. The guest's language is decided purely by which *_guest.wasm you load; nothing else changes.

Features

  • Language-agnostic guests — JavaScript (QuickJS or Boa), Python, PHP, TypeScript; anything that targets WASM behind a tiny contract.
  • Capability allowlist — the guest sees only the PHP functions you register, reached by their dotted names (no synthetic root). That allowlist is the entire trust boundary.
  • In-process memory isolation — the engine runs inside the WASM sandbox, so an engine bug stays contained without an outer microVM/container.
  • TypeScript checked in-sandbox — the real tsc runs in the guest and checks every eval against the .d.ts of your registered SDK, before it runs.
  • Typed SDK, three languages — types inferred from your PHP signatures + PHPDoc and emitted as .d.ts, .pyi, or a .php stub.
  • Typed exceptions + captured output — guest failures surface as a Terrarium\Exception family; console.log/print is captured separately and survives a throw.
  • Bounded and optionally deterministic — memory, time, stack, and fuel limits; fuel gives reproducible runs.

Quick example

require 'lib/Terrarium.php';   // or, via Composer: require 'vendor/autoload.php';

use Terrarium\Terrarium;

$wasm = new Terrarium('tests/wasm/quickjs_guest.wasm', timeoutMs: 500, memoryLimit: 32 << 20);

// Your SDK is plain, typed PHP — the signature is the schema.
$wasm->register('user.fetch',
    /**
     * Fetch a user by ID.
     * @return array{name: string, roles: string[]}
     */
    fn (int $id): array => ['name' => 'Ada', 'roles' => ['admin', 'dev']]);

// Untrusted guest code runs sandboxed and calls the SDK by its registered name.
echo $wasm->eval('user.fetch(42).roles.length');   // => 2

echo $wasm->types('dts');   // a typed .d.ts of the SDK ('pyi' for Python, 'php' for a PHP guest)

Swap quickjs_guest.wasm for boa_guest.wasm, rustpython_guest.wasm, or php_guest.wasm and the host code is unchanged. Fuller programs — typed capabilities, four languages over one SDK — in examples/.

TypeScript, checked inside the sandbox

Load typescript_guest.wasm and every eval is type-checked against the .d.ts generated from your registered SDK before it runs — the real TypeScript compiler executes inside the wasm guest:

use Terrarium\Terrarium;

$ts = new Terrarium('tests/wasm/typescript_guest.wasm');
$ts->register('user.fetch',
    /** @return array{name: string, roles: string[]} */
    fn (int $id): array => ['name' => 'Ada', 'roles' => ['admin', 'dev']]);

$ts->eval('user.fetch("42")');
// => Terrarium\GuestException: TS2345: Argument of type 'string' is not
//    assignable to parameter of type 'number'. (line 1) — nothing executed

$ts->eval('const u = user.fetch(42); `${u.name}: ${u.roles.join(", ")}`');
// => "Ada: admin, dev"

There's also a lint-style mode — check() validates without running and returns every diagnostic as data ([] = passed). It works on every guest at the depth its language allows (a full type-check here; a syntax/compile check on the JS, Python, and PHP guests). See docs/api.md.

analyze() is the same pass with more of the compiler's knowledge handed back. Name some callees and the TypeScript guest derives a JSON Schema from each call's type argument — the author writes the type, you get the contract:

$ts = new Terrarium('typescript_guest.wasm', typeArgumentSchemas: ['ctx.agent']);
$ts->analyze('const v = ctx.agent<{ ok: boolean; note?: string }>({ … }); v;')['schemas'];
// [['ordinal' => 0, 'callee' => 'ctx.agent', 'line' => 1,
//   'schema' => '{"type":"object","properties":{"ok":{"type":"boolean"},"note":{"type":"string"}},'
//             . '"required":["ok"],"additionalProperties":false}']]

Canonical JSON text, identified by call ordinal so reformatting can't repoint it, carrying the call's line for consumers that must find their schema at runtime, and anything with no faithful schema form is refused by member path rather than approximated. See docs/api.md.

The sandbox is synchronous — there is no event loop, so a program that suspends can never resume. That failure is never silent: a JS/TS eval that yields a promise, leaves callbacks queued, or registers a promise reaction raises AsyncIncomplete, and new Terrarium(..., syncOnly: true) makes the TypeScript guest reject async, await, function*, yield and every use of a promise at compile time, with the source line and a message naming the synchronous alternative. Exactly what the run-time guard does and does not catch is spelled out in errors.md; see also synchronous-only guests.

The TypeScript guest's check() also refuses what the sandbox engine cannot parse even though the compiler accepts it (accessor class members), and its lib declares nothing the engine lacks (no Intl, no Atomics) — so a clean check() means "this will run", not merely "this type-checks".

Installation

Prebuilt binaries are attached to each release for PHP 8.4 / 8.5 — self-hosted Linux, AWS Lambda (a ready Bref layer, full and runtime-only), and macOS (Apple Silicon) — plus the platform-independent PHP library and guest wasm. Enable the extension and point the facade at a guest:

; php.ini
extension=/path/to/terrarium-...so

Then pull the PHP library (the Terrarium\Terrarium facade + type inference) via Composer, and grab a guest engine from the guests.zip release artifact:

composer require eddmann/terrarium

The package requires ext-terrarium, so Composer errors clearly if the extension binary isn't enabled.

On Lambda, ship the guest precompiled rather than as .wasm — a cold start cannot reuse a module cache, and each release attaches the TypeScript guest already precompiled for its Lambda builds. A deployment that does is better served by the -runtime layer: the same extension without Cranelift, which loads those artifacts and cannot compile wasm at all — see which of the two Lambda builds.

Or build from source (Rust 1.96+, clang, PHP dev headers — a plain cargo cdylib, no phpize; the guest fixtures are committed, so no wasm toolchain is needed):

git clone https://github.com/eddmann/terrarium && cd terrarium
make build      # -> target/debug/libterrarium.{so,dylib}
make test       # Rust unit tests + the PHP suites

→ Full matrix, Docker, and AWS Lambda / Bref instructions: docs/install.md.

Reuse with a remaining time budget

Keep a Runtime warm while setting a separate timeout for each operation:

$rt = new \Terrarium\Runtime($wasmBytes, isolated: false);
$diagnostics = $rt->check($source, timeoutMs: 5000);
if ($diagnostics === []) {
    $result = $rt->eval($source, timeoutMs: 3000);
}
$rt->reset(); // drops the guest instance, retaining Engine/InstancePre

Recompute the remaining time before each call. Explicit positive timeouts cover guest initialization too; omitted/null uses the constructor default with its existing setup exemption. Zero means unbounded; negative overrides are rejected. PHP callbacks cannot be preempted, so cap blocking I/O separately. Reset retains host callbacks, declarations, options, output and handles: clean up captured session context yourself. See the full contract.

How it works

PHP (trusted)  ──ext-php-rs──►  Rust bridge  ──wasmtime──►  WASM guest (untrusted)
   register()                  dispatch table                a real language engine
   eval()                      host_call(name, bytes)        SDK names as globals
  1. PHP defines the SDK. register() typed PHP closures (and grant() live objects as opaque handles). That allowlist is the entire trust boundary.
  2. The guest runs sandboxed in WASM. A real engine compiled to wasm runs your source and sees the SDK as frozen, multi-level globals installed from the registered names — contained by memory / CPU / time limits and zero ambient authority. Values cross as MessagePack over linear memory through one host_call import.
  3. Types flow to the guest author. The SDK's types are inferred from the PHP signatures (Reflection + PHPDoc, incl. nested array{…} shapes) and emitted as .d.ts, .pyi, or a .php stub.

docs/architecture.md for the full design.

The engines

Five are bundled; the same bridge serves any language that targets WASM. Each guest pins the upstream version it tracks; the committed fixtures are built from these. See each guest's README for build details and internals.

  • QuickJS-ng v0.16.2 — JavaScript, C via the WASI SDK (the reference guest).
  • Boa 0.20 — JavaScript, pure Rust (no C toolchain).
  • RustPython 0.5 — Python, pure Rust.
  • PHP 8.3.14 — real php-src via its embed SAPI: sandboxed PHP inside PHP.
  • TypeScript — QuickJS-ng + tsc 6.0.3 as bytecode; type-checked against the SDK, erased, and run — Wizer-snapshotted so a checked eval is ~20 ms warm.

Scope

Terrarium's boundary is the WebAssembly VM, in-process. Unlike embedding an engine natively, a memory-corruption bug inside the guest engine is contained — it cannot form a pointer outside its linear memory or call a syscall you didn't import. The capability model contains what the guest can reach; the resource limits contain abuse (infinite loops, alloc bombs); and the VM contains the engine itself. That's a stronger default than a natively-embedded interpreter, with no outer microVM/gVisor required for memory safety.

The honest residual: a bug in Wasmtime itself is in the trust base — but that's a small, Rust, memory-safe, heavily-fuzzed surface, a far better bet than trusting each bundled engine's C codebase.

Documentation

  • Installation — the three pieces, prebuilt binaries, AWS Lambda (Bref), and building from source.
  • API reference — the Terrarium class and every method.
  • Architecture — why WebAssembly, the capability bridge and host_call ABI, marshaling, type inference, and the trust model.
  • Execution modes — shared vs. isolated instances.
  • Errors — the exception family, the $error sentinel, and output capture.
  • Per-engine notes under guests/.

License

MIT. The committed guest fixtures (tests/wasm/*.wasm) embed third-party engines under their own licenses — see THIRD_PARTY_LICENSES.md.