Back to Pi Mono

Facet sandbox — isolated-vm

packages/agent/docs/mobile-handoff/02-plugins/02-sandbox/README.md

0.85.17.7 KB
Original Source

Facet sandbox — isolated-vm

Unmodified pi facet code running in a V8 isolate with no ambient authority. Full JIT, so ~1.8× native rather than the 8–17× a WASM interpreter costs.

bash
npm install
npm run demo     # a facet: contributions, components, commands, callbacks
npm run audit    # escape audit — the interesting one
npm run bench    # crossing cost, isolate cost, 300 components
npm test         # 412 property assertions

Node 22+ (--experimental-strip-types). See ../facet-sandbox-poc for the QuickJS-WASM equivalent.

The safety claim, and why it is structural

isolated-vm can be used unsafely, and that is what facets.md §14.2 objected to. The footgun is handing the guest a live handle to a host object — via derefInto(), or by returning a Reference from a host call — after which the guest walks that object's prototype chain into the host realm.

This membrane makes that impossible by construction, not by care:

  1. encode() is the only way a host value reaches the guest, and it is JSON.stringify with a replacer. Its output is a string. A string cannot carry a reference.
  2. Host callables never cross. They become an integer id into hostTable, which lives entirely host-side. The guest receives a number.
  3. Exactly one Reference is ever given to the guest — __invokeRef, the single call-in point.
  4. derefInto() is used exactly once, on the guest's own global. No host object is ever its argument.

The guest's entire view of the host is { number, string }. There is no object graph, so there is nothing to walk.

Audit results (npm run audit)

Ambient authority:
  require / process / fetch          ["undefined","undefined","undefined"]
  global names visible               64 globals

Classic escape ladders:
  Function('return process')()       undefined
  constructor walk on host data      undefined

The isolated-vm Reference footgun:
  __invokeRef.deref()                blocked: TypeError   ("Cannot dereference
                                     this from current isolate")
  __invokeRef.copySync()             blocked: TypeError
  __invokeRef.getSync('constructor') blocked: TypeError
  derefInto() is callable?           inert (object)
  invoke derefInto() result          blocked: TypeError
  host globals via derefInto()       no host globals

Resource bounds:
  spinning guest interrupted         after 205ms
  isolate usable afterwards          true

One probe gave a false positive in a first draft: testing f.deref === undefined to prove a returned host function is a Proxy rather than a Reference. The membrane's proxy returns a proxy for every property, so f.deref is truthy. That is not a leak — calling it routes to hostFn["deref"], which does not exist and throws host-side. The audit now calls it instead of checking for absence.

Performance

isolated-vmQuickJSnative
300 markdown components, small70 ms494 ms~40 ms
300 markdown components, typical108 ms1118 ms64 ms
300 markdown components, large419 ms4260 ms~150 ms
146 KB bundle load35 ms104 ms
membrane crossing4.5 µs7–17 µs
per compartment1080 KB, 5.5 ms77 KB, 0.8 ms

~1.8× native at typical size. QuickJS is 8–17× and gets worse with input size.

Isolates cost 14× more memory than a QuickJS runtime (1080 KB vs 77 KB) and are slower to create. For a handful of facets that is irrelevant; for hundreds it would not be.

Intl.Segmenter exists here, which is the single hard blocker for reusing packages/tui components under QuickJS. That alone may decide it.

Limitations

Budgets do not nest. A timeout bounds one evalSync. A guest function re-entered by the host — a contributed callback, a component method — gets callGuestRef's own budget, not the outer one. Asserted explicitly in property-test.ts; a runaway callback took 5005 ms under an outer budget of 50 ms. Bounding total facet time needs separate accounting.

Async is a settle-callback, not a native promise. applySync is synchronous, so a host Promise cannot cross. The host returns a token, the guest builds a real Promise against it, and the host settles it by calling back in. Verified: two sequential guest awaits while the host event loop ticked 22 times.

Native addon, and the ABI matrix is real. isolated-vm ships prebuilds and only falls back to node-gyp rebuild when none matches — which is why install takes a second, not an hour. It is not building V8; V8 is already in the Node binary. But coverage is narrow:

versionenginesprebuilds
6.2.0>=22.0.0linux-x64/arm64, darwin-arm64, win32-x64 — abi127, abi137
7.0.1>=24.0.0
7.0.0>=26.0.0

darwin-x64 is absent everywhere. Installing isolated-vm@7 on Node 22 falls through to a source build and fails without Python and a C++ toolchain — verified here. Note also that 6.2.0 is latest on npm despite 7.0.1 existing, because it published one day later.

The project is not abandonware — 6.0.1 (Jul 2025) through 7.0.1 (Aug 2026), actively released. §14.2's "maintenance mode" claim is out of date and should be corrected. The real risk is different: adopting it couples your minimum Node version to theirs, and they dropped Node 20 in 6.x and Node 22 in 7.x within a year. Shipping SEA builds moves that from every user's install to your CI.

Memory: references release, but lazily. The WeakRef + FinalizationRegistry interning table ports unchanged from the QuickJS membrane and works — 20 000 crossings went to 0 live refs. But finalization is not prompt: 5 000 refs were still live 200 ms after a forced GC. Under sustained churn refs accumulate faster than the collector reclaims them, and a 32 MB isolate did hit its limit.

The consequence is an API rule, not a membrane fix: do not create references in a hot path. slots.claim(factory) at construct is one reference forever; a component method returning a fresh closure every frame is one per frame.

Hitting memoryLimit kills the facet cleanly. It throws a catchable "Isolate was disposed during execution due to memory limit" and the host survives — but the isolate is dead and unrecoverable, so teardown must tolerate an already-disposed isolate. dispose() on it throws "Isolate is already disposed"; the membrane guards this.

Do not call isolate.getHeapStatisticsSync() on a stressed isolate. It aborted the whole process during testing — a hard crash, not an exception. A heapMB() helper was removed from the membrane rather than shipped.

Still one process, one engine. A separate isolate is a far stronger boundary than SES, but Figma's argument for QuickJS was that a different VM cannot confuse objects because the representations differ. Here the guarantee is V8's isolate boundary plus the membrane's discipline — strong, and structurally enforced above, but not the same category of claim.

Files

  • src/membrane.ts — the membrane. The safety argument is in the header comment.
  • src/facet-example.js — unmodified facet code: ctx.use(), slots.claim() with a closure, a class instance returned to the host, a subscribe callback.
  • src/demo-facet.ts — loads it and exercises four boundary crossings.
  • src/escape-audit.ts — the audit above.
  • src/property-test.ts — 412 assertions: random JsonValue round-trips both directions, identity across crossings, callables, error propagation, reference release under GC, prototype pollution both ways, budgets, async, disposal.
  • src/bench.ts — the numbers above.
  • src/markdown-bundle.js — the real pi Markdown component, esbuild-bundled.