Squashed 'third_party/pi/' content from commit d981de122
git-subtree-dir: third_party/pi git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
This commit is contained in:
1855
packages/agent/docs/mobile-handoff/02-plugins/01-facets/facets.md
Normal file
1855
packages/agent/docs/mobile-handoff/02-plugins/01-facets/facets.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,162 @@
|
||||
# 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-vm | QuickJS | native |
|
||||
| --- | --- | --- | --- |
|
||||
| 300 markdown components, small | **70 ms** | 494 ms | ~40 ms |
|
||||
| 300 markdown components, typical | **108 ms** | 1118 ms | 64 ms |
|
||||
| 300 markdown components, large | **419 ms** | 4260 ms | ~150 ms |
|
||||
| 146 KB bundle load | **35 ms** | 104 ms | — |
|
||||
| membrane crossing | **4.5 µs** | 7–17 µs | — |
|
||||
| per compartment | 1080 KB, 5.5 ms | **77 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 `await`s 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:
|
||||
|
||||
| version | engines | prebuilds |
|
||||
| --- | --- | --- |
|
||||
| **6.2.0** | `>=22.0.0` | linux-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.
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "facet-sandbox-ivm",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Running pi facets in an isolated-vm isolate: full JIT, no ambient authority.",
|
||||
"scripts": {
|
||||
"demo": "node --experimental-strip-types src/demo-facet.ts",
|
||||
"audit": "node --experimental-strip-types src/escape-audit.ts",
|
||||
"bench": "node --experimental-strip-types src/bench.ts",
|
||||
"test": "node --experimental-strip-types --expose-gc src/property-test.ts"
|
||||
},
|
||||
"dependencies": { "isolated-vm": "6.2.0" }
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Cost of the isolate, the membrane, and a real component at transcript scale.
|
||||
* Compare against `../facet-sandbox-poc` (QuickJS) and native.
|
||||
*/
|
||||
import ivm from "isolated-vm";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createCompartment } from "./membrane.ts";
|
||||
|
||||
// ── membrane crossing ─────────────────────────────────────────────────────
|
||||
{
|
||||
const c = await createCompartment();
|
||||
await c.endow("__h", { echo: (v: unknown) => v });
|
||||
c.load(`globalThis.spin = function (n) { for (var i = 0; i < n; i++) __h.echo("x"); };`);
|
||||
c.load(`spin(2000);`);
|
||||
const N = 20000, t = Date.now();
|
||||
c.load(`spin(${N});`, 60000);
|
||||
console.log(`membrane crossing: ${((Date.now() - t) * 1000 / N).toFixed(1)} µs`);
|
||||
c.dispose();
|
||||
}
|
||||
|
||||
// ── isolate creation ──────────────────────────────────────────────────────
|
||||
{
|
||||
const before = process.memoryUsage().rss, t = Date.now();
|
||||
const held = Array.from({ length: 50 }, () => {
|
||||
const iso = new ivm.Isolate({ memoryLimit: 32 });
|
||||
const ctx = iso.createContextSync();
|
||||
ctx.evalSync(`var c = { render: function () { return ["x"]; } };`);
|
||||
return iso;
|
||||
});
|
||||
console.log(`50 isolates: ${(((process.memoryUsage().rss - before) / 50) / 1024).toFixed(0)} KB each, ${Date.now() - t} ms total`);
|
||||
for (const i of held) i.dispose();
|
||||
}
|
||||
|
||||
// ── real Markdown at transcript scale ─────────────────────────────────────
|
||||
{
|
||||
const iso = new ivm.Isolate({ memoryLimit: 512 });
|
||||
const ctx = iso.createContextSync();
|
||||
ctx.global.setSync("global", ctx.global.derefInto());
|
||||
const t = Date.now();
|
||||
ctx.evalSync(readFileSync(new URL("./markdown-bundle.js", import.meta.url), "utf8"), { timeout: 60000 });
|
||||
console.log(`146 KB bundle load: ${Date.now() - t} ms`);
|
||||
for (const [label, pts] of [["small", 2], ["typical", 8], ["large", 35]] as [string, number][]) {
|
||||
ctx.evalSync(`__bench(${pts}, 20, 50)`, { timeout: 120000 }); // warm the JIT
|
||||
const ms = ctx.evalSync(`__bench(${pts}, 300, 3)`, { timeout: 300000 });
|
||||
console.log(`300 markdown components, ${label.padEnd(8)} ${ms.toFixed(0)} ms per full repaint`);
|
||||
}
|
||||
iso.dispose();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createCompartment } from "./membrane.ts";
|
||||
const mounted = new Map(), commands = new Map(), notifications = [], subs = [];
|
||||
const Tui = {
|
||||
slots: { claim: (s, f) => mounted.set(s, f) },
|
||||
commands: { add: (n, h) => commands.set(n, h) },
|
||||
notify: (m) => notifications.push(m),
|
||||
};
|
||||
const Transcript = { tail: { subscribe: (f) => subs.push(f) } };
|
||||
const c = await createCompartment();
|
||||
await c.endow("__host", { use: (t) => (t === "Tui" ? Tui : Transcript) });
|
||||
c.load(readFileSync(new URL("./facet-example.js", import.meta.url), "utf8"));
|
||||
c.load(`globalThis.__r = __facet.construct({ use: function (t) { return __host.use(t); } });`);
|
||||
console.log("contributions:", { slots: [...mounted.keys()], commands: [...commands.keys()], subs: subs.length });
|
||||
const comp = mounted.get("footer")({ label: "presses" });
|
||||
console.log(comp.render(30).join("\n"));
|
||||
comp.handleInput("abc");
|
||||
console.log(comp.render(30).join("\n"));
|
||||
console.log("command ->", commands.get("footer.reset")());
|
||||
subs[0]({ entries: [1, 2, 3] });
|
||||
console.log("notifications:", notifications);
|
||||
c.dispose();
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Escape audit.
|
||||
*
|
||||
* The claim the membrane makes is that the guest's entire view of the host is
|
||||
* `{ number, string }` — no object graph, so nothing to walk. This tries to
|
||||
* break that, including the specific `Reference` footgun that makes isolated-vm
|
||||
* dangerous when used naively.
|
||||
*/
|
||||
import { createCompartment } from "./membrane.ts";
|
||||
|
||||
const c = await createCompartment();
|
||||
const seen: string[] = [];
|
||||
await c.endow("__h", {
|
||||
sink: (v: string) => seen.push(v),
|
||||
give: () => ({ nested: { a: 1 } }),
|
||||
giveFn: () => () => "host function result",
|
||||
});
|
||||
|
||||
const probe = (name: string, src: string) => {
|
||||
c.load(`globalThis.__p = String(${src});`);
|
||||
c.load(`__h.sink(__p);`);
|
||||
const got = seen.pop();
|
||||
const bad = got !== undefined && /GOT (?!undefined)|INVOKED|CALLABLE|^object$/.test(got) && !/blocked/.test(got);
|
||||
console.log(` ${bad ? "!!" : "ok"} ${name.padEnd(34)} ${got}`);
|
||||
return !bad;
|
||||
};
|
||||
|
||||
console.log("Ambient authority:");
|
||||
let ok = true;
|
||||
ok = probe("require / process / fetch", `JSON.stringify([typeof require, typeof process, typeof fetch])`) && ok;
|
||||
ok = probe("global names visible", `Object.getOwnPropertyNames(globalThis).length + " globals"`) && ok;
|
||||
|
||||
console.log("\nClassic escape ladders:");
|
||||
ok = probe("Function('return process')()", `(function(){ try { return String((function(){}).constructor("return typeof process")()); } catch(e){ return "blocked: "+e.name; } })()`) && ok;
|
||||
ok = probe("constructor walk on host data", `(function(){ try { var o = __h.give(); return String(o.constructor.constructor("return typeof process")()); } catch(e){ return "blocked: "+e.name; } })()`) && ok;
|
||||
|
||||
console.log("\nThe isolated-vm Reference footgun:");
|
||||
ok = probe("__invokeRef.deref()", `(function(){ try { return "GOT " + typeof __invokeRef.deref(); } catch(e){ return "blocked: "+e.name; } })()`) && ok;
|
||||
ok = probe("__invokeRef.copySync()", `(function(){ try { return "GOT " + typeof __invokeRef.copySync(); } catch(e){ return "blocked: "+e.name; } })()`) && ok;
|
||||
ok = probe("__invokeRef.getSync('constructor')", `(function(){ try { return "GOT " + typeof __invokeRef.getSync("constructor"); } catch(e){ return "blocked: "+e.name; } })()`) && ok;
|
||||
ok = probe("derefInto() is callable?", `(function(){ var d = __invokeRef.derefInto(); return typeof d === "function" ? "CALLABLE" : "inert (" + typeof d + ")"; })()`) && ok;
|
||||
ok = probe("invoke derefInto() result", `(function(){ try { __invokeRef.derefInto()(); return "INVOKED"; } catch(e){ return "blocked: "+e.name; } })()`) && ok;
|
||||
ok = probe("host globals via derefInto()", `(function(){ var d = __invokeRef.derefInto(); return String(d.process || d.global || "no host globals"); })()`) && ok;
|
||||
|
||||
console.log("\nWhat a host FUNCTION looks like to the guest:");
|
||||
ok = probe("typeof a returned host fn", `(function(){ var f = __h.giveFn(); return typeof f; })()`) && ok;
|
||||
// NOTE: `f.deref` is NOT undefined — the membrane's proxy returns a proxy for
|
||||
// every property. That is not a leak: calling it routes to hostFn["deref"],
|
||||
// which does not exist, and throws host-side. Testing for the property's
|
||||
// absence gave a false positive in a first draft of this audit.
|
||||
ok = probe("calling f.deref() reaches nothing", `(function(){ try { var f = __h.giveFn(); f.deref(); return "INVOKED"; } catch(e){ return "blocked: "+e.name; } })()`) && ok;
|
||||
ok = probe("calling the host fn works", `(function(){ var f = __h.giveFn(); return String(f()); })()`) && ok;
|
||||
|
||||
console.log("\nResource bounds:");
|
||||
const t0 = Date.now();
|
||||
let interrupted = false;
|
||||
try { c.load(`while(true){}`, 200); } catch { interrupted = true; }
|
||||
console.log(` ${interrupted ? "ok" : "!!"} spinning guest interrupted after ${Date.now() - t0}ms`);
|
||||
console.log(` ok isolate usable afterwards ${c.load(`1+1`) === 2}`);
|
||||
|
||||
c.dispose();
|
||||
console.log(`\n${ok && interrupted ? "AUDIT PASSED" : "AUDIT FAILED"}`);
|
||||
process.exit(ok && interrupted ? 0 : 1);
|
||||
@@ -0,0 +1,42 @@
|
||||
// ── REAL facet code. Nothing here knows it is sandboxed. ────────────────────
|
||||
// No imports rewritten, no string marshalling, no __dispatch. It uses the
|
||||
// host API exactly as facets.md §11.1 describes it.
|
||||
|
||||
class FooterComponent {
|
||||
constructor(props) { this.label = props.label; this.count = 0; }
|
||||
render(width) {
|
||||
const text = ` ${this.label}: ${this.count} `;
|
||||
const pad = Math.max(0, width - text.length - 2);
|
||||
const bar = "\u2500".repeat(Math.max(0, width - 2));
|
||||
return [`\u250c${bar}\u2510`, `\u2502${text}${" ".repeat(pad)}\u2502`, `\u2514${bar}\u2518`];
|
||||
}
|
||||
handleInput(data) { this.count += data.length; }
|
||||
}
|
||||
|
||||
const facet = {
|
||||
id: "@demo/footer:tui",
|
||||
uses: ["Tui", "Transcript"],
|
||||
|
||||
construct(ctx) {
|
||||
const tui = ctx.use("Tui");
|
||||
const transcript = ctx.use("Transcript");
|
||||
|
||||
// A factory crossing guest → host, by reference.
|
||||
tui.slots.claim("footer", (props) => new FooterComponent(props));
|
||||
|
||||
// A command handler, likewise.
|
||||
tui.commands.add("footer.reset", () => {
|
||||
tui.notify("footer reset");
|
||||
return "ok";
|
||||
});
|
||||
|
||||
// A subscription: host calls back into the guest.
|
||||
transcript.tail.subscribe((tail) => {
|
||||
tui.notify(`transcript now has ${tail.entries.length} entries`);
|
||||
});
|
||||
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
globalThis.__facet = facet;
|
||||
@@ -0,0 +1,34 @@
|
||||
import ivm from "isolated-vm";
|
||||
const rss = () => process.memoryUsage().rss / 1024 / 1024;
|
||||
|
||||
// 1. Are two CONTEXTS in one isolate isolated from each other?
|
||||
{
|
||||
const iso = new ivm.Isolate({ memoryLimit: 64 });
|
||||
const a = iso.createContextSync(), b = iso.createContextSync();
|
||||
a.evalSync(`globalThis.secret = "from-A"`);
|
||||
console.log("context B sees A's global:", b.evalSync(`typeof globalThis.secret`));
|
||||
// Can a spin in A be timed out without harming B?
|
||||
try { a.evalSync(`while(true){}`, { timeout: 100 }); } catch { /* expected */ }
|
||||
console.log("B usable after A was interrupted:", b.evalSync(`1+1`) === 2);
|
||||
// Does A's memory bomb kill B?
|
||||
try { a.evalSync(`var x=[]; for(;;) x.push(new Array(50000).fill(1));`, { timeout: 15000 }); }
|
||||
catch (e: any) { console.log("A bomb:", e.message.slice(0, 45)); }
|
||||
try { console.log("B after A's bomb:", b.evalSync(`1+1`)); }
|
||||
catch (e: any) { console.log("B after A's bomb: DEAD -", e.message.slice(0, 40)); }
|
||||
try { iso.dispose(); } catch {}
|
||||
}
|
||||
|
||||
// 2. Cost per facet, both ways.
|
||||
for (const mode of ["one isolate, N contexts", "N isolates"]) {
|
||||
const before = rss(), t = Date.now();
|
||||
const held: any[] = [];
|
||||
const shared = mode.startsWith("one") ? new ivm.Isolate({ memoryLimit: 512 }) : null;
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const iso = shared ?? new ivm.Isolate({ memoryLimit: 32 });
|
||||
const ctx = iso.createContextSync();
|
||||
ctx.evalSync(`var c = { render: function () { return ["x"]; } };`);
|
||||
held.push(iso);
|
||||
}
|
||||
console.log(`50 facets, ${mode.padEnd(24)}: ${(((rss() - before) / 50) * 1024).toFixed(0)} KB each, ${Date.now() - t} ms`);
|
||||
if (shared) { try { shared.dispose(); } catch {} } else for (const i of held) { try { i.dispose(); } catch {} }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { createCompartment } from "./membrane.ts";
|
||||
const c = await createCompartment();
|
||||
let seen = 0;
|
||||
await c.endow("__l", { drop: (_fn: unknown) => { seen++; } });
|
||||
c.load(`globalThis.churn = function (n) { for (var i = 0; i < n; i++) __l.drop(function () { return i; }); };`);
|
||||
|
||||
const rss = () => process.memoryUsage().rss / 1024 / 1024;
|
||||
const before = rss();
|
||||
c.load(`churn(20000);`, 60000);
|
||||
const peak = c.debugRefCount();
|
||||
const afterChurn = rss();
|
||||
|
||||
globalThis.gc?.(); await new Promise((r) => setTimeout(r, 100));
|
||||
globalThis.gc?.(); await new Promise((r) => setTimeout(r, 100));
|
||||
const settled = c.debugRefCount();
|
||||
|
||||
console.log(`20000 crossings: host called ${seen}x`);
|
||||
console.log(` guest refs: ${peak.guestRefs} -> ${settled.guestRefs}`);
|
||||
console.log(` host table: ${peak.hostTable} -> ${settled.hostTable}`);
|
||||
console.log(` rss: ${before.toFixed(0)} -> ${afterChurn.toFixed(0)} -> ${rss().toFixed(0)} MB`);
|
||||
|
||||
// Cost of the release path: one evalSync crossing per finalized ref.
|
||||
const t = Date.now();
|
||||
c.load(`churn(5000);`, 60000);
|
||||
globalThis.gc?.(); await new Promise((r) => setTimeout(r, 200));
|
||||
console.log(` 5000 more + gc: ${Date.now() - t} ms, refs now ${c.debugRefCount().guestRefs}`);
|
||||
c.dispose();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,197 @@
|
||||
import ivm from "isolated-vm";
|
||||
|
||||
/**
|
||||
* A membrane between the host realm and an isolated-vm isolate.
|
||||
*
|
||||
* ── The safety argument, which is structural, not a convention ──────────────
|
||||
*
|
||||
* isolated-vm CAN be used unsafely. The documented 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 can walk that object's
|
||||
* prototype chain into the host realm.
|
||||
*
|
||||
* This membrane makes that impossible by construction rather than 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 do not cross. They are replaced by 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 — and its `deref()` throws cross-isolate ("Cannot
|
||||
* dereference this from current isolate"). Its `derefInto()` yields an
|
||||
* inert `Dereference` marker: not callable, no host globals.
|
||||
* 4. `derefInto()` is used exactly once, on the guest's OWN global. No host
|
||||
* object is ever an argument to it.
|
||||
*
|
||||
* The consequence: the guest's entire view of the host is `{ number, string }`.
|
||||
* There is no object graph to walk, so there is nothing to walk it into. This is
|
||||
* the same property the QuickJS membrane has, and it is why the choice between
|
||||
* them is about performance and packaging rather than about safety.
|
||||
*
|
||||
* Verified by `src/escape-audit.ts`.
|
||||
*/
|
||||
export async function createCompartment({ memoryLimit = 256 } = {}) {
|
||||
const isolate = new ivm.Isolate({ memoryLimit });
|
||||
const ctx = await isolate.createContext();
|
||||
await ctx.global.set("global", ctx.global.derefInto()); // guest's own global only
|
||||
|
||||
const hostTable = new Map();
|
||||
let nextHostId = 1;
|
||||
const exposeHost = (o) => { const id = nextHostId++; hostTable.set(id, o); return id; };
|
||||
|
||||
const guestProxies = new Map();
|
||||
const finalizer = new FinalizationRegistry((id) => {
|
||||
guestProxies.delete(id);
|
||||
if (!released) ctx.evalSync(`delete __refs[${JSON.stringify(String(id))}]`);
|
||||
});
|
||||
let released = false;
|
||||
|
||||
// Async: `applySync` is synchronous, so a host Promise cannot itself cross.
|
||||
// Instead the host returns a token, the guest builds a real Promise against
|
||||
// it, and the host settles that Promise by calling back in when it resolves.
|
||||
let nextPending = 1;
|
||||
function encodePending(promise) {
|
||||
const token = nextPending++;
|
||||
promise.then(
|
||||
(v) => settle(token, true, v === undefined ? "" : encode(v)),
|
||||
(e) => settle(token, false, JSON.stringify(String(e?.message ?? e))),
|
||||
);
|
||||
return JSON.stringify({ __pending: token });
|
||||
}
|
||||
function settle(token, ok, json) {
|
||||
if (released) return;
|
||||
ctx.evalSync(`__settle(${token}, ${ok}, ${JSON.stringify(json)})`);
|
||||
}
|
||||
|
||||
function encode(v) {
|
||||
if (v && typeof v.then === "function") return encodePending(v);
|
||||
return JSON.stringify(v, (_k, val) => {
|
||||
if (typeof val === "function") return { __ref: exposeHost(val) };
|
||||
if (val && typeof val === "object" && !Array.isArray(val) && hasMethods(val))
|
||||
return { __obj: exposeHost(val), keys: methodNames(val) };
|
||||
return val;
|
||||
});
|
||||
}
|
||||
const decode = (json) => JSON.parse(json, (_k, v) => reviveGuest(v));
|
||||
|
||||
function intern(id, build) {
|
||||
const hit = guestProxies.get(id)?.deref();
|
||||
if (hit !== undefined) return hit;
|
||||
const p = build();
|
||||
guestProxies.set(id, new WeakRef(p));
|
||||
finalizer.register(p, id);
|
||||
return p;
|
||||
}
|
||||
function reviveGuest(v) {
|
||||
if (!v || typeof v !== "object") return v;
|
||||
if (typeof v.__guestRef === "number") { const r = v.__guestRef; return intern(r, () => (...a) => callGuestRef(r, a)); }
|
||||
if (typeof v.__guestObj === "number") {
|
||||
const r = v.__guestObj;
|
||||
return intern(r, () => { const o = {}; for (const k of v.keys) o[k] = (...a) => callGuestRef(r, [k, a]); return o; });
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function callGuestRef(ref, args, timeout = 5000) {
|
||||
const out = ctx.evalSync(
|
||||
`__refs[${JSON.stringify(String(ref))}](${JSON.stringify(JSON.stringify(args))})`,
|
||||
{ timeout },
|
||||
);
|
||||
return out === "" || out === undefined ? undefined : decode(out);
|
||||
}
|
||||
|
||||
// The single host entry point. A Reference, applied synchronously with copied
|
||||
// arguments — no host object graph is ever exposed.
|
||||
const invoke = new ivm.Reference((idJson, pathJson, argsJson) => {
|
||||
const path = JSON.parse(pathJson);
|
||||
let target = hostTable.get(JSON.parse(idJson));
|
||||
for (let i = 0; i < path.length - 1; i++) target = target[path[i]];
|
||||
const result = path.length ? target[path[path.length - 1]](...decode(argsJson)) : target(...decode(argsJson));
|
||||
return result === undefined ? "" : encode(result);
|
||||
});
|
||||
await ctx.global.set("__invokeRef", invoke);
|
||||
|
||||
await ctx.eval(`
|
||||
globalThis.__refs = {}; globalThis.__nextRef = 1;
|
||||
globalThis.__outgoing = new WeakMap();
|
||||
globalThis.__methodNames = function (o) {
|
||||
var out = [], p = o;
|
||||
while (p && p !== Object.prototype) {
|
||||
for (const k of Object.getOwnPropertyNames(p))
|
||||
if (k !== "constructor" && typeof o[k] === "function" && out.indexOf(k) < 0) out.push(k);
|
||||
p = Object.getPrototypeOf(p);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
globalThis.__mkRef = function (fn) {
|
||||
var prior = __outgoing.get(fn); if (prior !== undefined) return { __guestRef: prior };
|
||||
var id = __nextRef++; __outgoing.set(fn, id);
|
||||
__refs[String(id)] = function (j) { var r = fn.apply(null, JSON.parse(j)); return r === undefined ? "" : __encode(r); };
|
||||
return { __guestRef: id };
|
||||
};
|
||||
globalThis.__mkObjRef = function (obj) {
|
||||
var prior = __outgoing.get(obj);
|
||||
if (prior !== undefined) return { __guestObj: prior, keys: __methodNames(obj) };
|
||||
var id = __nextRef++; __outgoing.set(obj, id);
|
||||
__refs[String(id)] = function (j) { var c = JSON.parse(j); var r = obj[c[0]].apply(obj, c[1]); return r === undefined ? "" : __encode(r); };
|
||||
return { __guestObj: id, keys: __methodNames(obj) };
|
||||
};
|
||||
globalThis.__encode = function (v) {
|
||||
return JSON.stringify(v, function (k, val) {
|
||||
if (typeof val === "function") return __mkRef(val);
|
||||
if (val && typeof val === "object" && !Array.isArray(val) && __methodNames(val).length) return __mkObjRef(val);
|
||||
return val;
|
||||
});
|
||||
};
|
||||
globalThis.__pending = {};
|
||||
globalThis.__settle = function (token, ok, json) {
|
||||
var d = __pending[token];
|
||||
if (!d) return;
|
||||
delete __pending[token];
|
||||
var value = json === "" ? undefined : JSON.parse(json);
|
||||
ok ? d.resolve(__revive(value)) : d.reject(new Error(value));
|
||||
};
|
||||
globalThis.__revive = function (v) {
|
||||
if (v && typeof v === "object" && typeof v.__pending === "number") {
|
||||
var token = v.__pending;
|
||||
return new Promise(function (res, rej) { __pending[token] = { resolve: res, reject: rej }; });
|
||||
}
|
||||
if (v && typeof v === "object") {
|
||||
if (typeof v.__ref === "number") return __wrap(v.__ref, []);
|
||||
if (typeof v.__obj === "number") return __wrap(v.__obj, []);
|
||||
for (var k in v) v[k] = __revive(v[k]);
|
||||
}
|
||||
return v;
|
||||
};
|
||||
globalThis.__wrap = function (id, path) {
|
||||
return new Proxy(function () {}, {
|
||||
get: function (t, prop) {
|
||||
if (typeof prop !== "string") return undefined;
|
||||
if (prop === "then" || prop === "toJSON" || prop === "inspect") return undefined;
|
||||
if (prop === "length" || prop === "name") return t[prop];
|
||||
return __wrap(id, path.concat([prop]));
|
||||
},
|
||||
apply: function (_t, _this, args) {
|
||||
var out = __invokeRef.applySync(undefined, [JSON.stringify(id), JSON.stringify(path), __encode(args)]);
|
||||
return out === "" ? undefined : __revive(JSON.parse(out));
|
||||
},
|
||||
});
|
||||
};
|
||||
`);
|
||||
|
||||
return {
|
||||
async endow(name, obj) { await ctx.eval(`globalThis[${JSON.stringify(name)}] = __wrap(${exposeHost(obj)}, []);`); },
|
||||
load: (src, timeout = 30000) => ctx.evalSync(src, { timeout }),
|
||||
debugRefCount: () => ({ hostTable: hostTable.size, guestRefs: ctx.evalSync(`Object.keys(__refs).length`) }),
|
||||
dispose() {
|
||||
released = true;
|
||||
// An isolate that hit its memory limit is already disposed, and calling
|
||||
// dispose() again throws "Isolate is already disposed".
|
||||
try { isolate.dispose(); } catch { /* already gone */ }
|
||||
},
|
||||
};
|
||||
}
|
||||
const RESERVED = new Set(["constructor"]);
|
||||
const methodNames = (o) => { const out = []; for (let p = o; p && p !== Object.prototype; p = Object.getPrototypeOf(p)) for (const k of Object.getOwnPropertyNames(p)) if (!RESERVED.has(k) && typeof o[k] === "function" && !out.includes(k)) out.push(k); return out; };
|
||||
const hasMethods = (o) => methodNames(o).length > 0;
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Property tests for the membrane.
|
||||
*
|
||||
* Every bug found by hand while building it failed SILENTLY — wrong output, no
|
||||
* throw. That is the worst possible failure mode for a boundary, and the reason
|
||||
* these exist. Run with: node --experimental-strip-types --expose-gc property-test.ts
|
||||
*/
|
||||
import { createCompartment } from "./membrane.ts";
|
||||
|
||||
let pass = 0;
|
||||
const failures: string[] = [];
|
||||
const check = (name: string, ok: boolean, detail = "") => {
|
||||
if (ok) pass++;
|
||||
else failures.push(`${name}${detail ? ` — ${detail}` : ""}`);
|
||||
};
|
||||
|
||||
/** Random JsonValue. The membrane claims to round-trip exactly these. */
|
||||
function randomJson(depth = 0): unknown {
|
||||
const r = Math.random();
|
||||
if (depth > 3 || r < 0.25) return Math.floor(Math.random() * 1000) - 500;
|
||||
if (r < 0.35) return Math.random() * 1e6 - 5e5;
|
||||
if (r < 0.45) return Math.random() < 0.5;
|
||||
if (r < 0.5) return null;
|
||||
if (r < 0.65) {
|
||||
const alphabet = ['a', 'z', '0', ' ', '"', '\\', '\n', '\u00e9', '\u4e2d', '\u{1f600}'];
|
||||
let s = "";
|
||||
const n = Math.floor(Math.random() * 12);
|
||||
for (let i = 0; i < n; i++) s += alphabet[Math.floor(Math.random() * alphabet.length)];
|
||||
return s;
|
||||
}
|
||||
if (r < 0.8) return Array.from({ length: Math.floor(Math.random() * 5) }, () => randomJson(depth + 1));
|
||||
const o: Record<string, unknown> = {};
|
||||
for (const k of ["a", "b", "c", "d", "__proto__x", "0", "length"]) {
|
||||
if (Math.random() < 0.5) o[k] = randomJson(depth + 1);
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
const c = await createCompartment();
|
||||
|
||||
// ── 1. Value round-trip, both directions ──────────────────────────────────
|
||||
const echoed: unknown[] = [];
|
||||
await c.endow("__h", { echo: (v: unknown) => v, sink: (v: unknown) => { echoed.push(v); } });
|
||||
c.load(`globalThis.roundTrip = function (v) { return __h.echo(v); };
|
||||
globalThis.sendBack = function (v) { __h.sink(v); };`);
|
||||
|
||||
for (let i = 0; i < 400; i++) {
|
||||
const value = randomJson();
|
||||
const json = JSON.stringify(value);
|
||||
if (json === undefined) continue;
|
||||
// host -> guest -> host
|
||||
c.load(`globalThis.__t = roundTrip(${json});`);
|
||||
c.load(`sendBack(__t);`);
|
||||
const got = echoed.pop();
|
||||
check("value round-trip", JSON.stringify(got) === json, `${json} became ${JSON.stringify(got)}`);
|
||||
}
|
||||
|
||||
// ── 2. Identity: the same guest object must map to the same host proxy ────
|
||||
const seen: unknown[] = [];
|
||||
await c.endow("__id", { take: (o: unknown) => { seen.push(o); } });
|
||||
c.load(`globalThis.shared = { m: function () { return 1; } };
|
||||
globalThis.sendShared = function () { __id.take(shared); __id.take(shared); };`);
|
||||
c.load(`sendShared();`);
|
||||
check("identity preserved across crossings", seen[0] === seen[1]);
|
||||
c.load(`sendShared();`);
|
||||
check("identity stable across separate calls", seen[0] === seen[2]);
|
||||
|
||||
// ── 3. Callables survive and remain callable ──────────────────────────────
|
||||
let held: any;
|
||||
await c.endow("__f", { keep: (fn: unknown) => { held = fn; } });
|
||||
c.load(`__f.keep(function (a, b) { return a + b; });`);
|
||||
check("guest function callable from host", held(2, 3) === 5, `got ${held?.(2, 3)}`);
|
||||
|
||||
// ── 4. Errors cross with name and message ─────────────────────────────────
|
||||
c.load(`__f.keep(function () { throw new TypeError("boom"); });`);
|
||||
let msg = "";
|
||||
try { held(); } catch (e: any) { msg = e.message; }
|
||||
check("guest throw reaches host", msg.includes("boom"), `got "${msg}"`);
|
||||
|
||||
// ── 5. Reference release: crossings must not leak ─────────────────────────
|
||||
await c.endow("__l", { drop: (_fn: unknown) => {} });
|
||||
c.load(`globalThis.churn = function () { for (var i = 0; i < 500; i++) __l.drop(function () { return i; }); };`);
|
||||
c.load(`churn();`);
|
||||
const beforeGc = c.debugRefCount().guestRefs;
|
||||
globalThis.gc?.();
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
globalThis.gc?.();
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
const afterGc = c.debugRefCount().guestRefs;
|
||||
check("dropped references are released", afterGc < beforeGc / 2, `${beforeGc} -> ${afterGc}`);
|
||||
|
||||
// ── 5b. Prototype pollution must not cross ────────────────────────────────
|
||||
// JSON.parse is safe (it makes __proto__ an own property), but a membrane that
|
||||
// assigns parsed keys onto a fresh object is not.
|
||||
const before = ({} as any).polluted;
|
||||
c.load(`__h.sink(JSON.parse('{"__proto__":{"polluted":true}}'));`);
|
||||
check("guest cannot pollute host Object.prototype", ({} as any).polluted === before);
|
||||
await c.endow("__p", { give: () => JSON.parse('{"__proto__":{"hostPolluted":true}}') });
|
||||
await c.endow("__q", { show: (v: string) => check("host cannot pollute guest prototype", v === "undefined", v) });
|
||||
c.load(`var got = __p.give(); __q.show(String(({}).hostPolluted));`);
|
||||
|
||||
// ── 6. Interrupt budget, and the VM survives it ───────────────────────────
|
||||
// Direct: the budget on this call bounds it.
|
||||
{
|
||||
const t0 = Date.now();
|
||||
try { c.load(`while (true) {}`, 100); } catch { /* expected */ }
|
||||
check("runaway guest code is interrupted", Date.now() - t0 < 600, `took ${Date.now() - t0}ms`);
|
||||
}
|
||||
// Re-entrant: a guest function invoked BY THE HOST gets `callGuestRef`'s own
|
||||
// budget, not the outer one. Budgets do not nest — see README "Limitations".
|
||||
c.load(`globalThis.spin = function () { while (true) {} };`);
|
||||
await c.endow("__s", { run: (fn: any) => { try { fn(); return "returned"; } catch { return "threw"; } } });
|
||||
{
|
||||
const t0 = Date.now();
|
||||
try { c.load(`globalThis.__x = __s.run(spin);`, 50); } catch { /* expected */ }
|
||||
const took = Date.now() - t0;
|
||||
check("re-entrant call uses its OWN budget, not the outer one", took > 1000,
|
||||
`took ${took}ms — if this is now <1s the nesting behaviour changed`);
|
||||
}
|
||||
c.load(`globalThis.__alive = 1 + 1;`);
|
||||
check("VM usable after an interrupt", true);
|
||||
|
||||
// ── 7. Async, and clean teardown with a pending promise ───────────────────
|
||||
await c.endow("__a", { never: () => new Promise(() => {}), soon: () => Promise.resolve("v") });
|
||||
c.load(`globalThis.out = null; __a.soon().then(function (v) { out = v; });`);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
await c.endow("__o", { show: (v: string) => check("async host result reaches guest", v === '"v"', v) });
|
||||
c.load(`__o.show(JSON.stringify(out));`);
|
||||
c.load(`__a.never().then(function () {}, function () {});`); // deliberately unsettled
|
||||
|
||||
let disposeOk = true;
|
||||
try { c.dispose(); } catch { disposeOk = false; }
|
||||
check("dispose with a pending promise does not crash", disposeOk);
|
||||
|
||||
console.log(`\n${pass} passed, ${failures.length} failed`);
|
||||
for (const f of failures.slice(0, 10)) console.log(" FAIL", f);
|
||||
process.exit(failures.length === 0 ? 0 : 1);
|
||||
Reference in New Issue
Block a user