# Sandbox (/docs/infrastructure/sandbox)

---
title: Sandbox
description: Dynamic code runs through one call with two isolation modes. Trusted stays in your process. Sandboxed goes to a fresh Deno subprocess with a memory cap, an empty environment and an explicit host allowlist, and refuses to run until you configure an adapter.
kind: guide
package: "@questpie/sandbox"
---

The call is always `ctx.executor.run()`. What sits behind it is decided by one
field on the run, `isolation`, and by which adapter you put in the matching
config slot. This page carries both, so the swap is visible in one place.

## With no configuration

Leave `executor` out of your runtime config and the service is built disabled.
Every `run()` throws, naming the config key it wants. There is no fallback,
because an executor that quietly ran untrusted source in your process would hand
it your database credentials.

Two defaults decide what happens once you do configure it.

### `isolation` defaults to `"sandboxed"`

A run that names no mode is treated as untrusted. Trusted callers opt in with
`isolation: "trusted"` on purpose.

### Only the trusted mode has a built-in

`executor.trusted` defaults to core's `InProcessExecutorAdapter`. Nothing
defaults into `executor.sandboxed`, so a sandboxed run throws until you install
an adapter there. Both of those are throws, not `{ ok: false }` results.

## The two adapters

| Adapter                    | Import from                 | Serves                   | What it needs                                              |
| -------------------------- | --------------------------- | ------------------------ | ---------------------------------------------------------- |
| `InProcessExecutorAdapter` | `questpie/executor`         | `isolation: "trusted"`   | Nothing, it is already the default                         |
| `httpSandboxAdapter()`     | `@questpie/sandbox/adapter` | `isolation: "sandboxed"` | A Deno supervisor sidecar and a shared secret of 32+ bytes |

Both satisfy the same one-method contract, so choosing between them is a config
line and nothing at the call site moves.

### What trusted actually does

It base64s your source into a `data:` URL and `import()`s it in the host
process. On the way it captures `console.*` into the result's `logs`, exposes
`secrets` as `globalThis.__secrets`, sets any `bindings` you passed on
`globalThis`, and races a soft timeout. That timeout is the run's own
`capabilities.timeoutMs` when it has one, otherwise `executor.defaultTimeoutMs`,
otherwise 30000ms. Those are shared process globals, so trusted runs are
serialized by a module-level mutex and one wedged guest queues the next.

<Callout type="warn" title="Trusted is not a weak sandbox, it is no sandbox">
	The guest gets everything the host process has. The soft timeout bounds your
	wait, not the guest, so a tight synchronous loop is never preempted. Use this
	mode only for source you wrote and reviewed.
</Callout>

### What sandboxed actually does

`httpSandboxAdapter` POSTs the run to the Deno supervisor, which spawns one
fresh subprocess per run with `--v8-flags=--max-old-space-size=<memoryMb>`, an
empty environment, cwd `/`, and no filesystem grant at all, since the guest is
loaded as a self-contained `data:` module. Before your source is imported,
`Worker` is nulled and `SharedArrayBuffer` and `Atomics` are deleted.

## Configure the sandbox

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

export default runtimeConfig({
	app: { url: process.env.APP_URL! },
	db: { url: process.env.DATABASE_URL! },
	executor: {
		sandboxed: httpSandboxAdapter({
			url: process.env.SANDBOX_URL,
			hostAdmissionSecret: process.env.SANDBOX_HOST_ADMISSION_SECRET,
		}),
		// Only when guests reach your app. See Brokered app access.
		brokerUrl: process.env.SANDBOX_BROKER_URL,
	},
});
```

`httpSandboxAdapter(options?)` takes `url` (falls back to `SANDBOX_URL`, and must
be a bare origin with no path, query, userinfo or fragment), `hostAdmissionSecret`
(falls back to `SANDBOX_HOST_ADMISSION_SECRET`), `fetchTimeoutMs` (default: the
guest timeout plus 10s), `validateEgress` (default `true`) and a custom `fetch`.

The adapter is only half of it. The supervisor is a separate Deno process that
ships as source under `node_modules`, so your app image stays Deno-free, and it
needs the same `SANDBOX_HOST_ADMISSION_SECRET` or it refuses your runs.
[Running the supervisor](/docs/infrastructure/sandbox/supervisor) has the command
and its environment.

### Swapping the adapter

Both slots take any object with a `run` method, so pointing sandboxed runs at a
different isolation service, or trusted runs somewhere other than in-process, is
a one-line change in the same config. Call sites never learn which one answered.

## Run guest code

Guest source must `export default` a `function(input)`, and everything crossing
the boundary is serializable. Grant only the hosts it needs.

```ts
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 };
  }`,
	input: { since: "2026-01-01" },
	capabilities: { net: ["api.example.com"], import: [], timeoutMs: 5_000 },
});
// → { ok: true, output: { count: 42 }, logs: [...], ms: 312 }
```

The result is `{ ok, output?, logs, error?, timedOut?, ms? }`. A supervisor that
is down and a guest that throws both come back as `ok: false`. Only
misconfiguration throws.

## The capability manifest

Every run declares what it may reach, and omitted means denied. Treat building
the manifest as an authorization decision rather than passing request data
through.

| Key                                        | Grants                                      | Enforced by                                                    |
| ------------------------------------------ | ------------------------------------------- | -------------------------------------------------------------- |
| `net`                                      | `fetch()` hosts, `host[:port]`              | Deno `--allow-net`, or the broker's pinned fetch with bindings |
| `import`                                   | Remote module hosts                         | Deno `--allow-import`, see the callout                         |
| `timeoutMs`                                | Wall clock, default 5000, max 30000         | The supervisor clamps it                                       |
| `memoryMb`                                 | V8 old space, default 128, min 16, max 1024 | The supervisor clamps it                                       |
| `files`, `data.collections`, `data.stores` | Brokered access to your app                 | The broker, on every call                                      |
| `services`, `jobs`, `workflows`            | Declared and capability-checked             | Nothing yet, the broker answers `not_implemented`              |

<Callout type="warn" title="An empty `import` list is not the same as silence">
	Omitting Deno's `--allow-import` does not deny. It grants seven default hosts
	including `esm.sh` and `jsr.io`. So an empty allowlist emits an explicit
	`--deny-import` of exactly those. Never alias `import` to `net`, they are
	independent axes.
</Callout>

### Egress

Both `net` and `import` hosts are validated before any socket opens, in the
adapter and again in the supervisor. A host that is or resolves to a private,
loopback, link-local or CGNAT address, or to `169.254.169.254`, is rejected, and
a DNS failure is a rejection too. On capable Linux each run additionally gets its
own network namespace with a default-drop nftables ruleset. Everywhere else that
layer logs a notice and is absent, so treat the subprocess flags as the real
boundary.

## Give the guest your app

Pass `appBindings` and a `brokerUrl` and the guest gets `globalThis.questpie`,
a proxy whose calls are relayed server to server. The executor mints a per-run
token that never enters the guest process, and the broker rechecks the manifest
on every call.

```ts
// inside the guest
const posts = await questpie.collections.posts.find({ limit: 10 });
```

[Brokered app access](/docs/infrastructure/sandbox/brokered-access) covers the
guest surface, the route you have to register, and what the broker refuses.

## Writing your own adapter

`ExecutorAdapter` is one method. Import it from `questpie/executor`.

```ts
import type { ExecutorAdapter } from "questpie/executor";

export const myAdapter: ExecutorAdapter = {
	async run({ source, input, capabilities, secrets, sandboxBindings }) {
		// hand these to your runner, return { ok, output, logs, error?, ms? }
		return { ok: true, output: undefined, logs: [] };
	},
};
```

`ExecutorService` picks the adapter by `isolation` before it calls you. On the
bindings path it has already minted the token and put it on
`options.sandboxBindings` as `{ url, token }`. Forward that to your runner and
never to the guest.

## Next

**[Services](/docs/code/services)** is where `ctx.executor` sits among the other
injected objects, and **[MCP](/docs/agents/mcp)** is the other place you hand
declared capabilities to code you did not write.
