QUESTPIE
Adapters

Sandbox adapter

Run untrusted or dynamically-authored code behind a real resource-and-permission boundary, ctx.executor.run() executes guest code in a fresh Deno subprocess with a memory cap, denied filesystem/env, scoped net/import allowlists, and SSRF egress validation.

The sandbox adapter enforces the isolated mode described in Sandboxed code. @questpie/sandbox runs each guest in a fresh Deno subprocess with bounded memory, restricted permissions, scoped network and import allowlists, and validated egress.

Prerequisites

Start with Sandboxed code for execution modes, capabilities, brokered access, and lifecycle. This page covers the Deno supervisor, deployment, security layers, and platform limits. @questpie/ai has a separate host execution model.

What it does

  • Isolate untrusted code with a real boundary. Each ctx.executor.run() spawns a fresh Deno subprocess with --v8-flags=--max-old-space-size=<memoryMb> (an enforced memory cap), --allow-net=<hosts> / --allow-import=<hosts> (independent allowlists), read scoped to the guest entry file only, and --deny on everything else. The supervisor runs the guest with clearEnv: true, so its own environment never leaks.
  • Reject SSRF at the manifest. Every net and import host is validated, in the adapter and the server, before a socket is opened: a host that is (or DNS-resolves to) private, loopback, link-local, CGNAT, or the 169.254.169.254 cloud-metadata address is rejected, with DNS failing closed.
  • Harden the guest before it runs. globalThis.Worker is nulled (orphan-CPU DoS), SharedArrayBuffer/Atomics are deleted (Spectre timers), console.* is captured into a capped log buffer, and the guest must export default a function(input).
  • Broker capabilities, never hand them out. When you pass appBindings, the guest reaches your app's collections/files/services through a globalThis.questpie proxy whose calls are relayed server-to-server to a broker URL carrying a per-run token, the token never enters the guest process.

Configuration

@questpie/sandbox implements the ExecutorAdapter contract (the contract itself lives in questpie/executor). Wire it into the executor slot of your runtime config:

src/questpie/server/questpie.config.ts
import { httpSandboxAdapter } from "@questpie/sandbox/adapter";
import { runtimeConfig } from "questpie/app";

export default runtimeConfig({
  executor: {
    // The isolated engine for `isolation: "sandboxed"` runs
    sandboxed: httpSandboxAdapter({
      url: process.env.SANDBOX_URL ?? "http://127.0.0.1:8787",
    }),
    // Where the guest's brokered capability calls are relayed (server-to-server)
    brokerUrl: process.env.SANDBOX_BROKER_URL ?? "http://127.0.0.1:3000/api/sandbox/rpc",
  },
});

httpSandboxAdapter(options?) takes url (defaults to SANDBOX_URL; run throws if unset), fetchTimeoutMs? (default = guest timeoutMs + 10s), validateEgress? (default true), and a custom fetch?.

Unconfigured means disabled

If you never set executor.sandboxed, ctx.executor.run({ isolation: "sandboxed" }) is a no-op boundary, sandboxed execution is opt-in. isolation: "trusted" uses core's in-process adapter instead (no isolation, for code you wrote).

Running the supervisor

The supervisor is Deno-only and ships as source under node_modules (your app image stays Deno-free). Run it as a sidecar:

deno run \
  --allow-net --allow-env --allow-run \
  --allow-read --allow-write=$TMPDIR \
  node_modules/@questpie/sandbox/src/sandbox-server.ts

Supervisor environment: PORT (default 8787), DENO_BIN, SANDBOX_BROKER_URL, SANDBOX_DISABLE_NETNS_FIREWALL. It exposes GET /health and POST /run.

Running guest code

Guest source must export default a function(input). Grant only the hosts it needs:

const result = await ctx.executor.run({
  source: `export default async function (input) {
    const res = await fetch("https://api.example.com/data?since=" + input.since);
    return { count: (await res.json()).length };
  }`,
  capabilities: { net: ["api.example.com"], timeoutMs: 5_000, memoryMb: 128 },
  input: { since: "2026-01-01" },
});
// → { ok: true, output: { count: 42 }, logs: [...], ms: 312 }

SandboxCapabilities is { net: string[]; import: string[]; timeoutMs: number; memoryMb: number }. The supervisor clamps them: timeout default 5000ms / max 30000; memory default 128MB / min 16 / max 1024.

To give a guest scoped access to your app (collections, files, services), pass appBindings and a brokerUrl from config (never from request input):

const result = await executor.run({
  source: buildEndpointEntrySource(entrySource, fn),
  isolation: "sandboxed",
  capabilities,
  appBindings: target, // capability-scoped surface
  brokerUrl, // from config
});
// inside the guest:
//   const posts = await questpie.collections.posts.find({ limit: 10 });
//   const file  = await questpie.files.read({ path: "data/report.json" });

Security model

  • Process-per-request, not a warm Worker. A Worker is a permission boundary but not a resource boundary, memoryMb is unenforceable in-process and worker.terminate() doesn't reap grandchild Workers. A fresh subprocess gives a real V8 heap cap and clean teardown (SIGTERM → SIGKILL after a 250ms grace).
  • net and import are independent axes, and import fails open. Omitting --allow-import does not deny, Deno silently grants ~7 default hosts (esm.sh, jsr.io, deno.land, …). So an empty import allowlist emits an explicit --deny-import=<those hosts>. Never alias the two axes.
  • Brokered fetch on the bindings path. When appBindings is present the guest runs at --allow-net=[] (no sockets at all) and its native fetch is replaced by a shim that RPCs http.fetch over stdio to the supervisor, which relays to your broker carrying a per-run token in x-questpie-sandbox-token. The token is supervisor-only, and the broker (/api/sandbox/rpc) re-checks the run's capabilities on every call.

Honest limitations

  • DNS-rebind pinning is not implemented. Egress validation happens at manifest time; the exact socket IP is not re-pinned across redirects (TODO(security)). The brokered path is safe because the guest has no sockets at all (--allow-net=[]); a raw net-granted run is validated once, not continuously. - The kernel egress firewall is Linux-only and belt-and-suspenders. On Linux with unshare/nft/ip and the right capabilities, each run also gets a per-run network namespace + nftables ruleset (default-DROP, private CIDRs dropped, public allowlist accepted). Off Linux, or when those tools/caps are missing, it is gracefully absent (logs a notice and runs without it). Disable with SANDBOX_DISABLE_NETNS_FIREWALL=1. The ruleset logic is unit-tested; the actual kernel drop is only verified on a real Linux worker. Treat it as a second layer, the subprocess permission flags are the primary boundary.

How it fits

  • The ExecutorAdapter contract lives in questpie/executor; this package implements it. The engine enforces net / import / timeoutMs / memoryMb; the richer capability surface (data.collections, files, services, jobs, workflows) is enforced by the broker, not the engine.
  • Flow: ctx.executor.run({ isolation: "sandboxed", ... }) → the executor service mints the per-run token → httpSandboxAdapter.runPOST /run on the supervisor → a fresh subprocess.

On this page