Skip to content

Sandbox

The sandbox is the confined server runtime. A server extension opts into it by declaring runtime: confined-server, and its code then runs in a confined child process instead of the API process. The confined process has no host imports and no raw Node. Every privileged effect goes through a brokered host.* call that the platform gates against the capabilities the extension declares.

This page is the reference for that runtime: how it runs, the boundary it enforces, how to author against it, the host API, the capability vocabulary, the build and load path, and the diagnostics. For the choice between full authority and the sandbox, see Server extensions.

A confined extension runs in a short-lived child process, one per invocation. The API spawns a child host that loads a QuickJS engine compiled to WebAssembly, evaluates the extension’s built artifact inside it, runs the handler, and returns the result.

The guest code never touches the host directly. It has no fetch, no filesystem, no Node builtins, and no imports from the platform. When it needs a privileged effect it calls a host.* method. That call is framed and sent over a dedicated channel to a broker in the API, which checks the call against the declared capabilities, performs the effect, and frames a reply back. The guest sees only the reply.

Inside the engine, the guest runs under per-invocation CPU, memory, and stack limits, with the console silenced and a cap on the number of timers it can create. A run that exceeds its CPU, memory, or stack limit is terminated.

The boundary is the engine. A confined guest runs inside a QuickJS engine with no host imports, no Node, no fetch, and no filesystem, so it cannot reach the API process, the database, the environment, or the network by itself. Its only way out is a host.* call, and every call is checked by the broker against the capabilities the extension declares. This is the sandbox: it is present in every mode and on every host, and it does not depend on the operating system. The OS hardening below is additional containment, not what makes the sandbox a sandbox.

A confined extension runs in its own child process, not in-process with the API. That lets the platform wrap the process in operating-system isolation, a layer an in-process sandbox cannot add. This hardening is extra containment around an already-closed boundary:

  • a network namespace that severs the child’s own network access,
  • the Node permission model with a read scoped to the child’s runtime directory,
  • a cgroup memory cap.

How strictly these layers are enforced is set with the EXTENSIONS_SANDBOX_OS_HARDENING environment variable.

  • auto, the default, applies each layer where the host supports it and reports where it is missing. A missing layer never blocks an extension, because the engine boundary already contains the guest.
  • required refuses to start a confined extension unless the escape-containment core is present, that core being the network namespace and the Node permission model with its runtime-directory-scoped read. Use it on hosts where the extra isolation must be guaranteed. The cgroup memory cap is applied and reported when available but is never required.

The resolved posture for every confined extension is visible in the diagnostics.

A confined extension swaps the authoring entrypoint and declares its runtime and capabilities in the manifest. The handler receives a host instead of reaching for platform internals.

The entrypoints come from @cairncms/extensions-server-api:

import { defineFlowOperation } from '@cairncms/extensions-server-api';
export default defineFlowOperation({
id: 'my-op',
async handler(payload, { host }) {
const result = await host.request.send({ url: 'https://api.example.com/status' });
if (!result.ok) {
await host.log.warn('status request failed', { code: result.error.code });
return { reached: false };
}
return { reached: true, status: result.value.status };
},
});

The matching manifest declares the runtime and the capabilities the handler uses:

{
"name": "cairncms-extension-my-op",
"cairncms:extension": {
"type": "operation",
"path": { "app": "dist/app.js", "api": "dist/api.js" },
"source": { "app": "src/app.ts", "api": "src/api.ts" },
"host": "^1.0.0",
"runtime": "confined-server",
"capabilities": {
"log": true,
"request": { "urls": ["https://api.example.com"], "methods": ["GET"] }
}
}
}

The three confined entrypoints, all from @cairncms/extensions-server-api:

  • defineFlowOperation({ id, handler }). The handler is (payload, context), where payload is { options, input } and it returns the operation’s output. See Operations.
  • defineJsonEndpoint({ id, handler }). The handler is (request, context), where request is { method, path, query, body }, and it returns { status?, body }. The handler is the whole endpoint, with no router. See Endpoints.
  • defineEventHook({ id, filters?, actions? }). filters and actions are keyed by exact platform event name. A filter handler is (payload, meta, context) and returns the payload (or undefined for no change). An action handler is (meta, context) and returns nothing. See Hooks.

In every case context is { extensionId, contributionId, activation, accountability, host }. The contribution id must equal the extension or entry name. The load gate enforces this.

Every privileged effect is a method on host. Each returns an ExtensionResult (except host.log, which is fire-and-forget), so a caller branches on the outcome rather than catching exceptions.

type ExtensionResult<T> =
| { ok: true; value: T }
| { ok: false; error: { code: ExtensionHostErrorCode; message: string; details?: Record<string, unknown> } };
type ExtensionHostErrorCode =
| 'denied'
| 'not_found'
| 'invalid_request'
| 'unsupported'
| 'timeout'
| 'rate_limited'
| 'internal';

The methods:

  • host.log.debug | info | warn | error(message, meta?) writes a structured log line on the host. Logging is fire-and-forget and needs the log capability.
  • host.request.send(request) makes an outbound HTTP request from the host and returns ExtensionResult<{ status, headers, body }>. The request is { url, method?, headers?, body?, timeoutMs?, auth? }. It needs the request capability, and the URL’s origin must be one the manifest declares.
  • host.items.read(collection, query?) returns ExtensionResult<T[]> and host.items.readOne(collection, key, query?) returns ExtensionResult<T | null>. The query is { fields?, filter?, sort?, limit?, offset?, page?, search? }. Both need the items capability. Reads are read-only in this version.
  • host.settings.get(key) returns ExtensionResult<T | ExtensionSecretReference | null> and needs the settings capability. No settings are declared readable to confined extensions in this version, so it resolves to null today.
  • host.template.renderLiquid(template, data?, options?) renders a Liquid template on the host and returns ExtensionResult<string>. The options may set custom delimiters. It needs the template capability.

A call whose capability is not declared, or whose target is not allowed by the declared capability, comes back as { ok: false, error: { code: 'denied' } } rather than throwing.

host.items reads under one of two accountability modes, fixed per extension by the items capability. It is not chosen per invocation.

  • items: 'current-user' reads as whoever triggered the work. A manual flow runs as the user who clicked Run Flow, an event-triggered flow as the user whose action fired the event, an authenticated request as the caller’s token. Field permissions apply exactly as on a REST read, and the read fails closed: a forbidden field is a hard denial, and the primary key must be among the readable fields. A null accountability, such as an anonymous webhook or a schedule, is denied in this mode. Prefer this mode.
  • items: 'system' reads with unrestricted system authority, for genuinely user-less flows such as schedules and anonymous webhooks. It is still confined and still read-only, and it is visible in the diagnostics as an elevated opt-in.

There is no per-flow identity. A flow runs as its trigger. For user-less work that should stay least-privilege, use a dedicated service user on an authenticated trigger and keep current-user.

The manifest capabilities block is the operator-reviewable list of what the extension can reach. A capability that is not declared is denied at the broker. The vocabulary:

CapabilityShapeStatus in this version
logtruePresent. Enables host.log.
request{ urls: string[], methods?: string[] }Present. Enables host.request.send.
templatetruePresent. Enables host.template.renderLiquid.
items'current-user' | 'system'Present, read-only. Enables host.items.
settings('read' | 'write')[]read enables host.settings.get, which resolves to null today (no readable settings are declared yet).
endpoint{ access: 'public' | 'authenticated' }Present. Sets the auth gate for a confined endpoint.
files'current-user' | 'system'Declarable for forward compatibility, not exposed in the host API.
schema('read' | 'write')[]Declarable for forward compatibility, not exposed in the host API.
secretstrueDeclarable for forward compatibility, not exposed in the host API.
jobstrueDeclarable for forward compatibility, not exposed in the host API.

These four capabilities validate and load, but the host API exposes no method for them in this version, so there is nothing to call. They are reserved for forward compatibility and operator review. Do not build on them yet.

The request capability bounds outbound HTTP to a declared set.

  • urls is a list of bare origins, each an http:// or https:// origin with no path, query, fragment, or credentials. At least one is required. A request is matched against the list by exact origin equality, with a case-insensitive host.
  • methods is an optional list drawn from GET, POST, PUT, PATCH, DELETE, HEAD, and OPTIONS. It defaults to ['GET']. A request with an undeclared method is denied.

A “reach any API” design is not expressible. The operator enumerates every origin up front, and the extension can reach only those.

A confined endpoint declares endpoint: { access: ... }. authenticated returns 401 to an anonymous caller. public admits anonymous callers. There is no app-only access level.

A confined hook declares the events it subscribes to in events, as { filter?: string[], action?: string[] }, with at least one list. Each list holds up to sixteen exact platform event names. The declared events are the operator-reviewable subscription surface, and an entry whose handlers do not match its declared events fails to load. Confined hooks subscribe to filter and action events only.

An operation can mark sensitive option fields with optionDelivery, a record of { <optionKey>: { delivery: 'reference' } }. A field marked this way is delivered to the handler as a reference the broker resolves on the host, so the secret value is never serialized into the guest. The handler can pass that reference to host.request.send as auth, so a confined operation can call a secret-protected external API without the secret ever entering the guest.

optionDelivery is operations-only in this version, and settings are dark, so an endpoint or panel has no source for a secret reference yet. A confined endpoint or panel that calls an external API must use a non-secret or public one for now.

A confined extension is checked twice: once when it is built, and again when the API loads it.

At build time, the confined build bundles the server entry into a single self-contained artifact that exposes the contract’s global. The build runs with Node builtins not externalized, so a node: import or any other unresolved import fails the build rather than being left to fail at runtime. Every bundled input is then containment-checked against the package root: an input that resolves inside the package, or to a published dependency under node_modules, is allowed, while a workspace:, file:, or link: dependency that resolves to a directory outside the package is refused. So a confined build needs its dependencies installed as published node_modules entries, not linked from a workspace. The build output is deterministic, so the same inputs produce a byte-stable artifact.

At load time, the API re-reads the manifest under a capped read, re-checks the confined declaration and the extension’s identity against the bytes it just read, resolves the declared server source set, runs a static source scan over it, and then probes the built artifact inside the sandbox to confirm the declared contract has a real binding. The gate is fail-closed: any unreadable, oversized, malformed, or contradictory input refuses, and the row is recorded as a load failure with a sanitized reason. A confined bundle probes its one shared artifact against all of its declared server entries at once, so one bad entry fails the bundle’s server side.

The source scanner runs at load time, not at build time. The two checks are complementary: the build keeps host imports and stray dependencies out of the artifact, and the load gate re-verifies the manifest, scans the declared source, and proves the artifact runs before the extension is admitted.

To scaffold a confined extension, pass --confined to the create command. Creating extensions covers the command path.

The resolved state of every extension is on the Settings > Extensions page. Each extension can be opened to display the runtime label: Sandboxed, Full authority, or Browser app. A sandboxed entry row shows its declared capabilities, and a partial status when some of its entries loaded and others did not.

An Advanced Diagnostics section holds the OS-hardening posture: the resolved mode, the decision to run or refuse, which hardening layers were applied, which are missing, and the cgroup mechanic when one is used. The same diagnostics are available from the GET /extensions API.