Brokered app access
How a sandboxed guest reaches your collections and files without importing your app, which route carries the calls, why the guest loses its sockets when you turn this on, and the operations the broker still refuses.
A compute-only guest is easy to reason about. It gets input, it gets a host allowlist, it returns JSON. The moment it needs your data the question becomes who holds the credentials, and the answer here is that the guest never does.
The three pieces
Pass appBindings and a brokerUrl to run() and the executor mints a
short-lived token bound to that pair of capabilities and target, hands the
supervisor the broker coordinates, and revokes the token the moment the run
settles. The guest sees globalThis.questpie, whose every method call is one
RPC out over stdio.
const result = await ctx.executor.run({
isolation: "sandboxed",
source: entrySource,
capabilities: {
data: { collections: { posts: ["read"] } },
timeoutMs: 5_000,
},
appBindings: target, // the capability-scoped surface you built
brokerUrl, // from config, never from request input
});
// inside the guest:
// const posts = await questpie.collections.posts.find({ limit: 10 });The broker URL comes from config, always
It is the address the supervisor calls carrying the run token. Sourcing it
from an inbound request's Host or origin lets a spoofed header mail that
token to a host the caller picked. Read it from executor.brokerUrl or
SANDBOX_BROKER_URL.
Register the route
The broker endpoint is a route in a module, so it exists only once you add that
module. Codegen picks it up from modules.ts like any other.
import { sandboxModule } from "@questpie/sandbox/modules/sandbox";
const modules = [sandboxModule] as const;
export default modules;Run questpie generate and the route lands at sandbox/rpc under your handler's
base path, so /api/sandbox/rpc in the starters. That path is what brokerUrl
and the supervisor's SANDBOX_BROKER_URL both have to name. Calls arrive with
the per-run token in the x-questpie-sandbox-token header, and the broker
rechecks the run's capabilities on every one of them.
What the guest can call
| Call | Reaches |
|---|---|
questpie.files.read/write/list | Files, scoped by capabilities.files path globs |
questpie.collections.<name>.<op> | find, findOne, create, update, delete |
questpie.store.<name>.<op> | The same collection ops against one document_store namespace |
questpie.tools.list/call | Custom MCP tools you explicitly released |
questpie.store.posts.create(x) is exactly
questpie.collections.document_store.create({ ...x, store: "posts" }). The sugar
saves the argument, it is not a second enforcement path, and the host still
clamps the call to the stores the manifest granted.
What it refuses
The manifest types services, jobs and workflows, and the broker will
capability-check them, but none of them dispatches. They answer
not_implemented, which is a deferral rather than a silent success. Globals are
refused earlier still: the four namespaces in the table above are the whole
proxy, so guest code has no questpie.globals to call. The broker would refuse a
guest's globals.set anyway, because a global is a singleton with no per-tenant
boundary to clamp a write against.
Everything else depends on the target you built. The broker dispatches only to
handlers present on it, so an operation the manifest allows but the target never
wired still resolves to not_implemented. Capability enforcement runs before
dispatch, so a missing handler can only narrow access, never widen it.
The guest loses its sockets
Turning bindings on changes the network story completely. The subprocess is
spawned with no --allow-net flag at all, which in Deno means no sockets, and
the guest's native fetch is replaced by a shim that relays an http.fetch RPC
over the same framed channel as the questpie proxy. Your declared net hosts
stay on the host side and gate the broker's fetch instead.
That is what buys the stronger egress guarantee. The broker resolves the host
itself, validates every A and AAAA record against the private, loopback,
link-local, CGNAT and metadata policy, rejects the host outright if any one
address fails, then pins the socket to a validated IP literal while keeping the
original hostname for SNI and the Host header. No DNS resolution happens at
connect time. Redirects are not auto-followed, so every hop is re-parsed,
re-resolved, re-validated and re-pinned, and a hop leaving the run's allowlist is
blocked mid-chain.
A plain `net` run does not get the pin
A compute-only guest holds a real Deno network grant, and its hosts are validated once at manifest time. Continuous validation is a property of the brokered path, where the guest cannot open a socket in the first place.
Next
Sandbox covers the adapters, the capability manifest and the run result.
Running the supervisor
The Deno process that actually spawns guest subprocesses, the command to start it, the environment it reads, and the shared secret without which it refuses every run your app sends.
Instrumented seams
Every span QUESTPIE opens, its kind and attributes, the one metric it records, how log records join a trace, and the values that are deliberately never attached to any of it.