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--denyon everything else. The supervisor runs the guest withclearEnv: true, so its own environment never leaks. - Reject SSRF at the manifest. Every
netandimporthost 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 the169.254.169.254cloud-metadata address is rejected, with DNS failing closed. - Harden the guest before it runs.
globalThis.Workeris nulled (orphan-CPU DoS),SharedArrayBuffer/Atomicsare deleted (Spectre timers),console.*is captured into a capped log buffer, and the guest mustexport defaultafunction(input). - Broker capabilities, never hand them out. When you pass
appBindings, the guest reaches your app's collections/files/services through aglobalThis.questpieproxy 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:
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.tsSupervisor 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,
memoryMbis unenforceable in-process andworker.terminate()doesn't reap grandchild Workers. A fresh subprocess gives a real V8 heap cap and clean teardown (SIGTERM → SIGKILL after a 250ms grace). netandimportare independent axes, andimportfails open. Omitting--allow-importdoes 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
fetchon the bindings path. WhenappBindingsis present the guest runs at--allow-net=[](no sockets at all) and its nativefetchis replaced by a shim that RPCshttp.fetchover stdio to the supervisor, which relays to your broker carrying a per-run token inx-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 rawnet-granted run is validated once, not continuously. - The kernel egress firewall is Linux-only and belt-and-suspenders. On Linux withunshare/nft/ipand 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 withSANDBOX_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
ExecutorAdaptercontract lives inquestpie/executor; this package implements it. The engine enforcesnet/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.run→POST /runon the supervisor → a fresh subprocess.
KV adapter
A typed key-value store on your app context, ctx.kv.get/set/delete/has/clear with TTL and tag-based invalidation. In-memory by default; swap to Redis or Cloudflare KV with one config line and no app-code change.
Overview
Add the generated admin panel, configure its navigation and branding, and extend it with views, components, blocks, actions, auth, and audit trails.