@johnhenry/andbox 0.0.9 → 0.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -435,7 +435,7 @@ andbox is **not** a boundary against code that is actively trying to escape it.
435
435
 
436
436
  - **Worker-global APIs are directly reachable, regardless of `capabilities`.** Sandboxed code executes in a real Worker global scope, so `fetch`, `WebSocket`, `Worker` (nested workers), `importScripts`, `indexedDB`, and `self.postMessage` are all callable directly -- omitting a `fetch` capability does not block network access. This is fundamental to how `Function`-based evaluation works and isn't fixable without a different execution strategy: [`mode: 'wasm'`](#mode-wasm) is that strategy (QuickJS in WebAssembly, no ambient authority), and a cross-origin iframe with a strict CSP is another. See [andbox#10](https://github.com/johnhenry/andbox/issues/10).
437
437
  - **`sandboxImport()` will load and execute an arbitrary remote URL.** Any `http(s)://` specifier is passed straight to `import()` with no allowlist, independent of any network policy configured for capabilities. See [andbox#7](https://github.com/johnhenry/andbox/issues/7).
438
- - **A timeout stops message delivery, not in-flight host-side effects.** If a capability call with a real side effect (a write, an API call) is in flight when the timeout fires, that side effect still completes on the host even though the Worker is killed. Capabilities with real side effects should be designed to be idempotent and/or cancellable via `AbortSignal`. See [andbox#8](https://github.com/johnhenry/andbox/issues/8).
438
+ - **A timeout cannot undo in-flight host-side effects; it can ask them to stop.** When the Worker is terminated (timeout, an aborted `evaluate()`, `dispose()`, a crash) every capability call still in flight sees `this.signal` abort, and its late result is dropped rather than delivered. Cancellation is cooperative: a capability that ignores `this.signal` still runs to completion on the host. Write effectful capabilities as `function`s (not arrows) and pass the signal on (`fetch(url, { signal: this.signal })`), and keep them idempotent. In `mode: 'wasm'`, a cooperative `deadlineMs` ends the evaluation without terminating the Worker, so the signal does not abort in that case. See [andbox#8](https://github.com/johnhenry/andbox/issues/8).
439
439
  - **`mode: 'service-worker'` does not provide isolation by merely existing.** It's a hosting mechanism -- a real Service Worker, same-origin by default, serving your `files` map with real fetch/navigation interception. Content served through it can see and touch its own origin exactly like any other same-origin page can; nothing about registering a Service Worker sandboxes what runs inside the pages it serves. If you're hosting content you don't fully trust, point this mode at a genuinely separate origin from day one -- the same recommendation the `fetch`/`WebSocket`/`Worker` item above makes for `worker` mode (a cross-origin iframe with a strict CSP), not something bolted on after the fact. See [andbox#14](https://github.com/johnhenry/andbox/issues/14).
440
440
  - **The Service Worker does not control the very first navigation into its scope.** A page/iframe navigation into `scope` that happens *before* the registration has finished activating is a normal, unintercepted network request -- Service Workers never retroactively intercept a request that already went out. `createSandbox({ mode: 'service-worker' })`'s returned promise only resolves once the registration is active (its generated script also calls `clients.claim()` on activate, which helps *already-open* clients but not fresh navigations); the documented, load-bearing contract is: don't navigate anything into `scope` until that promise resolves. Do that and every request is intercepted from the first byte, because the registration already matches `scope` before the navigation request is made. See [andbox#14](https://github.com/johnhenry/andbox/issues/14).
441
441
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnhenry/andbox",
3
- "version": "0.0.9",
3
+ "version": "0.0.10",
4
4
  "type": "module",
5
5
  "description": "Sandboxed JavaScript runtime with Worker isolation, RPC, import maps, timeouts, and an optional QuickJS-in-WebAssembly mode",
6
6
  "main": "./src/index.mjs",
@@ -49,7 +49,7 @@ export function gateCapabilities(capabilities, policy = {}) {
49
49
  for (const [name, fn] of Object.entries(capabilities)) {
50
50
  const capLimits = { ...DEFAULT_CAPABILITY_LIMITS, ...(hasOwn(capPolicies, name) ? capPolicies[name] : undefined) };
51
51
 
52
- gated[name] = async (...args) => {
52
+ gated[name] = async function (...args) {
53
53
  // Measure argument bytes
54
54
  const argStr = JSON.stringify(args);
55
55
  const argBytes = new TextEncoder().encode(argStr).byteLength;
@@ -82,7 +82,7 @@ export function gateCapabilities(capabilities, policy = {}) {
82
82
  concurrent++;
83
83
 
84
84
  try {
85
- return await fn(...args);
85
+ return await fn.apply(this, args);
86
86
  } finally {
87
87
  concurrent--;
88
88
  }
package/src/index.d.ts CHANGED
@@ -310,12 +310,23 @@ export interface EvaluateOptions {
310
310
  deadlineMs?: number;
311
311
  }
312
312
 
313
+ /**
314
+ * `this` inside a capability function (use a `function`, not an arrow).
315
+ * `signal` aborts when the sandbox's Worker is terminated (timeout, an
316
+ * aborted `evaluate()`, `dispose()`, crash); cooperative capabilities should
317
+ * pass it to whatever they are doing (`fetch(url, { signal })`, ...).
318
+ */
319
+ export interface CapabilityContext {
320
+ signal: AbortSignal;
321
+ name: string;
322
+ }
323
+
313
324
  /** Options for createSandbox(). */
314
325
  export interface SandboxOptions {
315
326
  /** Import map for module resolution inside the sandbox. */
316
327
  importMap?: ImportMap;
317
328
  /** Host functions callable from sandbox code via host.call(name, ...args). */
318
- capabilities?: Record<string, (...args: any[]) => any>;
329
+ capabilities?: Record<string, (this: CapabilityContext, ...args: any[]) => any>;
319
330
  /** Default timeout in milliseconds for evaluate() calls. */
320
331
  defaultTimeoutMs?: number;
321
332
  /** Base URL for resolving relative imports inside the sandbox. */
package/src/sandbox.mjs CHANGED
@@ -446,6 +446,8 @@ async function createWorkerSandbox(options = {}, forceNode = false, isWasm = fal
446
446
  let disposed = false;
447
447
  let worker = null;
448
448
  let workerBlobURL = null;
449
+ // Lifetime of the current Worker; aborted when it is terminated.
450
+ let workerAbort = null;
449
451
 
450
452
  // `unref`: the thread only keeps the host process alive while work is in
451
453
  // flight (startup, evaluate, defineModule); idle, it lets the process exit.
@@ -467,6 +469,7 @@ async function createWorkerSandbox(options = {}, forceNode = false, isWasm = fal
467
469
  // ── Worker lifecycle ──
468
470
 
469
471
  function createWorker() {
472
+ workerAbort = new AbortController();
470
473
  const source = isWasm ? makeWasmWorkerSource() : makeWorkerSource();
471
474
  if (workerFactory) {
472
475
  worker = workerFactory(source);
@@ -570,30 +573,45 @@ async function createWorkerSandbox(options = {}, forceNode = false, isWasm = fal
570
573
  }
571
574
 
572
575
  async function handleCapabilityCall(rpcId, name, args) {
576
+ // Bind to the worker that made the call. If it is killed (timeout, abort,
577
+ // dispose, crash) while the capability is still running, the late result
578
+ // must be dropped: `worker` is then null or a *different* replacement
579
+ // thread that never asked for it (andbox#30).
580
+ const callerWorker = worker;
581
+ const callerSignal = workerAbort?.signal;
582
+ if (!callerWorker || !callerSignal) return;
583
+ const reply = (payload) => {
584
+ if (worker !== callerWorker || callerSignal.aborted) return;
585
+ try {
586
+ callerWorker.postMessage({ type: 'capabilityResult', id: rpcId, ...payload });
587
+ } catch {
588
+ // the thread went away between the check and the post: nothing to tell
589
+ }
590
+ };
591
+
573
592
  const fn = lookupCapability(name);
574
593
  if (!fn) {
575
- worker.postMessage({
576
- type: 'capabilityResult',
577
- id: rpcId,
578
- success: false,
579
- error: `Unknown capability: ${name}`,
580
- });
594
+ reply({ success: false, error: `Unknown capability: ${name}` });
581
595
  return;
582
596
  }
583
597
  try {
584
- const value = await fn(...args);
585
- worker.postMessage({ type: 'capabilityResult', id: rpcId, success: true, value });
598
+ // The signal travels as `this`, not as an argument, so capabilities
599
+ // with their own arity (Math.max, rest params, ...) are unaffected.
600
+ // It aborts when this Worker is terminated (andbox#8).
601
+ const value = await fn.call({ signal: callerSignal, name }, ...args);
602
+ reply({ success: true, value });
586
603
  } catch (e) {
587
- worker.postMessage({
588
- type: 'capabilityResult',
589
- id: rpcId,
590
- success: false,
591
- error: e.message || String(e),
592
- });
604
+ reply({ success: false, error: e?.message || String(e) });
593
605
  }
594
606
  }
595
607
 
596
608
  function terminateWorker() {
609
+ if (workerAbort) {
610
+ // Tell in-flight capability calls their caller is gone so cooperative
611
+ // ones can cancel the underlying effect (andbox#8).
612
+ workerAbort.abort(new Error('Sandbox worker terminated'));
613
+ workerAbort = null;
614
+ }
597
615
  if (worker) {
598
616
  worker.terminate();
599
617
  worker = null;