Execution Sandbox
The execution sandbox gives each Agent-Protocol conversation thread a hard-isolated workspace — filesystem, shell, and network — instead of the local <appRoot>/workspace/ directory the agent otherwise reads and writes on the host. It's opt-in: add a sandbox key to dawn.config.ts and every readFile, writeFile, listDir, and runBash call for that thread routes into the isolated environment through a provider-agnostic SandboxProvider contract. Dawn ships a Docker reference implementation.
This is a distinct layer from the other two access controls in Dawn: tool scoping decides which tools the model may call; permissions decide whether a given call should run (human-in-the-loop approval); the sandbox decides what an allowed, approved call can actually touch. The three compose — none of them substitutes for the others.
Quickstart
Docker must be installed and the daemon running. Configure a provider in dawn.config.ts using the typed config() helper (a bare object still works — config() is pure identity for IntelliSense):
import { config } from "@dawn-ai/cli"
import { dockerSandbox } from "@dawn-ai/sandbox"
export default config({
sandbox: {
provider: dockerSandbox({ image: "node:22-slim" }),
network: { mode: "allow", denylist: ["169.254.169.254"] },
env: { NODE_ENV: "production" },
resources: { memoryMb: 512, cpus: 1, timeoutMs: 120_000 },
idleTimeoutMs: 600_000,
},
})No sandbox key means no behavior change — the app keeps using the local workspace/ directory exactly as before.
dawn check validates the sandbox config shape and runs the provider's preflight() — for dockerSandbox, that means confirming the Docker daemon is reachable — so a misconfiguration or a stopped daemon fails at check time instead of mid-run.
What's isolated
- Filesystem —
readFile,writeFile, andlistDiroperate inside the sandbox's workspace volume. The host filesystem is never touched. - Shell —
runBashexecutes inside the sandbox, still gated by the permissions allow/deny lists. - Network — governed by the configured network policy;
denymode is zero egress. - Environment — the host's environment variables are never inherited. Only the key/value pairs in
sandbox.envare injected into the sandbox. - Resources —
resources.memoryMbandresources.cpuscap the sandbox's memory and CPU;resources.timeoutMscaps how long a single exec call may run.
Lifecycle
One sandbox is created per conversation thread, on that thread's first turn. It stays warm and is reused across every subsequent turn on the same thread — the agent isn't paying container start-up cost on every message.
- Persistence — the workspace (a named volume) survives across turns, across a container being idle-reaped, and across a full server restart. On the next turn, the provider reattaches the existing volume by its deterministic name rather than starting from an empty workspace.
- Idle reap — a thread with no activity for
idleTimeoutMs(default 10 minutes) has its warm container released. The volume is kept, so the next turn on that thread reattaches it with all files intact. - Thread delete — an Agent-Protocol
DELETEon the thread destroys the sandbox and its volume. This is the only operation that discards the workspace permanently.
Turn trace: turn 1 on a new thread → no live sandbox → acquire() creates the container and volume. Turns 2..N on the same thread → the same live sandbox is reused. Thread idle past idleTimeoutMs → container released, volume kept. Next turn after that → acquire() reattaches the existing volume into a fresh container. Thread deleted → destroy() removes the container and the volume.
Security hardening
The Docker provider is hardened by default — a fresh dockerSandbox() config with no security key applies all of the following:
| Control | Default | Effect |
|---|---|---|
| Linux capabilities | --cap-drop ALL | The container gets none of the default Docker capability set (no CAP_NET_RAW, CAP_SYS_ADMIN, etc.) |
| Privilege escalation | --security-opt no-new-privileges | setuid/setgid binaries can't escalate to root inside the container |
| Process count | --pids-limit 512 | Caps forked processes as a fork-bomb defense |
| Root filesystem | --read-only + --tmpfs /tmp + --tmpfs /run | The image's root filesystem is immutable; only the workspace volume and the two tmpfs mounts are writable |
| User | --user 1000:1000 with HOME=/workspace | The process runs as a non-root uid/gid instead of the image's default (often root) |
This is expressed as a provider-agnostic SandboxPolicy.security intent, not a Docker-only flag set — a future microVM- or gVisor-backed provider implements the same fields against its own mechanism.
Every control has a per-flag opt-out for images that need it:
import { config } from "@dawn-ai/cli"
import { dockerSandbox } from "@dawn-ai/sandbox"
export default config({
sandbox: {
provider: dockerSandbox({ image: "node:22-slim" }),
security: { runAsNonRoot: false, readOnlyRootFilesystem: false }, // opt out if your image needs it
},
})security accepts:
dropAllCapabilities?: boolean— defaulttruenoNewPrivileges?: boolean— defaulttruereadOnlyRootFilesystem?: boolean— defaulttruerunAsNonRoot?: boolean | { uid: number; gid: number }— defaulttrue(uid/gid1000:1000); pass an object for a different fixed uid/gid, orfalseto run as the image's default userpidsLimit?: number— default512
Per-command timeout
resources.timeoutMs bounds how long a single runBash call may run, enforced inside the container (the command is wrapped in timeout; an overrun exits with code 124):
sandbox: {
provider: dockerSandbox({ image: "node:22-slim" }),
resources: { timeoutMs: 120_000 },
},It's enforced only when set — omitting resources.timeoutMs leaves exec calls unbounded in duration (still subject to memoryMb/cpus caps, if configured). Enforcement shells out to the timeout binary (GNU coreutils) inside the container, so when you set resources.timeoutMs make sure your image ships it — most full base images (node:22-slim, debian, ubuntu) do, but minimal ones (alpine without the coreutils package, distroless) may not. The ceiling is rounded up to whole seconds (timeoutMs: 500 enforces a 1-second limit).
Network policy
sandbox.network takes one of two shapes:
{ mode: "deny" }— zero egress. This is an exact guarantee in the Docker reference (--network none); the sandbox cannot reach the network at all. An optionalallowlistmay be added for providers that support scoped egress.{ mode: "allow", denylist?: [...] }— egress is on by default, with an optional denylist of hosts to block.
The default, when network is omitted, is { mode: "allow", denylist: ["169.254.169.254"] } — the cloud-metadata endpoint is blocked out of the box since it's a common SSRF target.
Kubernetes provider
kubernetesSandbox runs each thread's sandbox as a Kubernetes Pod instead of a local Docker container, for deployments where the Dawn server itself runs in a cluster:
import { config } from "@dawn-ai/cli"
import { kubernetesSandbox } from "@dawn-ai/sandbox"
export default config({
sandbox: {
provider: kubernetesSandbox({ image: "node:22-slim", namespace: "dawn-sandboxes" }),
network: { mode: "deny" },
resources: { memoryMb: 512, cpus: 1, diskGb: 2 },
},
})kubernetesSandbox({ image, namespace?, storageClass?, startupTimeoutMs? }) implements the same SandboxProvider contract as dockerSandbox — every security, network, and resources field on SandboxConfig means the same thing regardless of which provider is configured.
Per thread: a Pod plus a per-thread ReadWriteOnce PersistentVolumeClaim mounted at /workspace, mirroring the Docker provider's named-volume persistence. release() deletes the Pod but keeps the PVC, so the next turn's acquire() schedules a fresh Pod against the existing claim with the workspace intact. destroy() deletes both the Pod and the PVC, discarding the workspace permanently — the same lifecycle semantics as the Docker provider's container/volume split, just on Kubernetes' own objects.
Auth: the provider talks to the Kubernetes API using @kubernetes/client-node's loadFromDefault(), which auto-detects an in-cluster ServiceAccount token when the Dawn server itself runs as a Pod, or a local kubeconfig otherwise. The in-cluster token path is the documented production setup.
Sizing: resources.diskGb sets the PVC's storage request. It's new to this provider — dockerSandbox ignores it, since Docker volumes aren't quota-bound the same way.
Security hardening on Kubernetes
The security intent maps onto the Pod and container SecurityContext fields instead of docker run flags, but the defaults and opt-outs are identical to the table in Security hardening:
| Control | Kubernetes mechanism |
|---|---|
| Non-root user | securityContext.fsGroup — kubelet recursively chowns the PVC to the given gid on mount, so there's no chown-init container and no root step at all |
| Read-only root filesystem | readOnlyRootFilesystem: true on the container SecurityContext, plus emptyDir volumes mounted at /tmp and /run for scratch space |
| Dropped capabilities | capabilities.drop: ["ALL"] |
| Privilege escalation | allowPrivilegeEscalation: false |
| Seccomp | seccompProfile: { type: "RuntimeDefault" } by default |
Sandbox Pods also set automountServiceAccountToken: false, so a compromised sandbox process has no Kubernetes API credentials to steal — it can't call the cluster API even if it escapes the container boundary.
pidsLimit is not enforced by this provider — Kubernetes has no per-Pod or per-namespace process-count field equivalent to Docker's --pids-limit (pids is not a valid LimitRange/ResourceQuota resource). Fork-bomb defense on Kubernetes is a node-level kubelet setting (podPidsLimit / --pod-max-pids) that a cluster operator configures on the nodes running sandbox Pods; it cannot be set by the provider or a namespaced chart.
Network policy on Kubernetes
The same network config ({ mode: "deny", allowlist? } or { mode: "allow", denylist? }) is honored, but enforcement is a Kubernetes NetworkPolicy object rather than a container network mode:
{ mode: "deny" }— the provider creates a per-threadNetworkPolicythat denies all egress from the sandbox Pod except to cluster DNS and any CIDRs listed inallowlist.{ mode: "allow" }— noNetworkPolicyis created, so egress is open by default; adenylist, if given, is best-effort only and not enforced — the same honest-scope caveat as the Docker provider'sallow-mode denylist.
Kubernetes Pod isolation is the same boundary class as Docker's container isolation, not a microVM — the same caveats in What it is — and isn't apply. The namespace, RBAC, default-deny NetworkPolicy, LimitRange, and PVC-reaper are provisioned separately by the Helm chart below, not by the provider itself.
Deploying the sandbox infrastructure (Helm)
For the orientation-level view of both charts before diving into the reference below, see Deploying on Kubernetes.
kubernetesSandbox assumes a namespace already exists with the RBAC, quotas, and network policy the provider's Pods need. The dawn-sandbox-infra Helm chart provisions that cluster-side infrastructure — install it once per cluster (or per environment) before pointing kubernetesSandbox at it:
helm install dawn-sandbox-infra oci://ghcr.io/cacheplane/charts/dawn-sandbox-infraOr from a local checkout of the chart:
helm install dawn-sandbox-infra ./charts/dawn-sandbox-infraWhat it provisions:
- Namespace (
dawn-sandboxesby default) with configurable Pod Security Standard labels. - Least-privilege RBAC — a
dawn-orchestratorServiceAccount, Role, and RoleBinding scoped to exactly what the provider needs: creating/getting/deleting Pods and PersistentVolumeClaims,pods/exec, and managing NetworkPolicies. No access to Secrets or any other resource. - A default-deny egress
NetworkPolicybackstop — applies to every Pod in the namespace, with a carve-out for cluster DNS. ResourceQuotaandLimitRange— namespace-wide aggregate caps plus container-level cpu/memory/ephemeral-storage defaults. (PID limiting is node-level on Kubernetes and cannot be set by a namespaced chart — see Security hardening on Kubernetes.)- Pod Security Standards — namespace labels controlling what the cluster's built-in admission controller allows.
- A PVC reaper — a CronJob that marks and eventually deletes sandbox PVCs that are no longer referenced by any Pod, so abandoned per-thread volumes don't accumulate.
Key caveats
Pod Security Standards default to enforce: baseline with warn/audit: restricted — permissive enough that most images pass, while still surfacing restricted-profile warnings. Tighten enforcement with:
helm install dawn-sandbox-infra oci://ghcr.io/cacheplane/charts/dawn-sandbox-infra \
--set podSecurityStandard.enforce=restrictedThe PVC reaper's time-to-live is configurable via reaper.ttlHours (default 168, one week) — a PVC with no Pod referencing it for longer than the TTL is deleted:
helm install dawn-sandbox-infra oci://ghcr.io/cacheplane/charts/dawn-sandbox-infra \
--set reaper.ttlHours=24dawn-sandbox-infra is the second of three sub-projects in Dawn's Kubernetes arc: this chart hardens the cluster for the sandbox provider; the dawn-app chart below runs the Dawn app itself on Kubernetes, completing the arc.
Deploying a Dawn app (Helm)
dawn build's node target (see Deploying to production (Node/Docker)) emits a server.mjs plus a hardened Dockerfile that boot the real Dawn runtime. Build that image when your app configures kubernetesSandbox — the sandbox provider only runs inside the Dawn runtime process, so an image built from the langsmith target's langgraph.json (containerized separately with @langchain/langgraph-cli's langgraphjs dockerfile) never calls the Kubernetes API and will not create sandbox Pods, even with kubernetesSandbox configured in dawn.config.ts. The dawn-app Helm chart takes that user-built image and runs it on Kubernetes as a Deployment + Service, with optional Ingress, HorizontalPodAutoscaler, and PodDisruptionBudget. It owns only the Kubernetes deploy concerns — it does not build your image or bake dawn.config.ts; the image is the runtime contract.
# Build the image via dawn build's node target (on by default)
dawn build
docker build -t ghcr.io/you/your-app:latest .
# Install the chart against that image
helm install dawn-app oci://ghcr.io/cacheplane/charts/dawn-app \
--set image.repository=ghcr.io/you/your-app \
--set image.tag=latestOr from a local checkout of the chart:
helm install dawn-app ./charts/dawn-app --set image.repository=ghcr.io/you/your-appimage.repository is required — install/upgrade fails fast with a clear error if it's unset. containerPort (default 8000) and healthPath (default /healthz) are also values, matching the node target's Dockerfile (EXPOSE 8000, /healthz) out of the box — verify both against your built image before relying on the defaults if you're deploying a differently-shaped image, since they drive the liveness/readiness/startup probes.
ServiceAccount and namespace wiring
If your app's dawn.config.ts configures kubernetesSandbox, the app process calls the Kubernetes API to create sandbox Pods — so the app's own Pod must run under a ServiceAccount bound to the dawn-sandbox-infra chart's dawn-orchestrator Role. Two ways to satisfy that, via values.serviceAccount:
-
Run the app in the sandbox namespace. Install
dawn-appinto the same namespace asdawn-sandbox-infra(sandboxNamespace, defaultdawn-sandboxes) and reuse the existingdawn-orchestratorServiceAccount (serviceAccount.create=false, the default). -
Add a cross-namespace subject. Keep the app in its own namespace, set
serviceAccount.create=trueso the chart creates a ServiceAccount there, then bind it to the sandbox-infra Role by adding it to that chart'sorchestrator.subjects:bashhelm upgrade dawn-sandbox-infra charts/dawn-sandbox-infra \ --reuse-values \ --set-json 'orchestrator.subjects[0]={"kind":"ServiceAccount","name":"dawn-app","namespace":"default"}'
The chart's post-install NOTES print the exact subject to add for your release. Either way, keep sandboxNamespace (informational only) in sync with the namespace passed to kubernetesSandbox({ namespace }) in dawn.config.ts and with dawn-sandbox-infra's namespace.name.
Env, secrets, and scaling
Supply environment variables directly (env) or via an existing Secret (secretName, wired as a convenience envFrom.secretRef — the chart does not template Secrets itself):
helm install dawn-app oci://ghcr.io/cacheplane/charts/dawn-app \
--set image.repository=ghcr.io/you/your-app \
--set secretName=my-app-secretsIngress, autoscaling, and a PodDisruptionBudget are all opt-in:
helm install dawn-app oci://ghcr.io/cacheplane/charts/dawn-app \
--set image.repository=ghcr.io/you/your-app \
--set ingress.enabled=true --set ingress.className=nginx --set ingress.host=app.example.com \
--set autoscaling.enabled=true --set autoscaling.maxReplicas=10 \
--set podDisruptionBudget.enabled=trueSubagents
A subagent dispatch runs under the same conversation thread as its parent, so it resolves to and shares the parent's sandbox — the coordinator and its subagents operate in one isolated environment, not one each.
Custom providers
SandboxProvider is the contract any isolation backend implements — Docker, a microVM, or a cloud sandbox service:
import type { SandboxHandle, SandboxPolicy, SandboxProvider } from "@dawn-ai/workspace"
export interface SandboxProvider {
readonly name: string
acquire(input: {
readonly threadId: string
readonly policy: SandboxPolicy
readonly signal: AbortSignal
}): Promise<SandboxHandle>
release(threadId: string): Promise<void>
destroy(threadId: string): Promise<void>
preflight?(): Promise<{ readonly ok: boolean; readonly detail?: string }>
}acquire is create-or-reattach and idempotent per threadId: called at the start of every turn, it returns the same live sandbox until release or destroy is called. release drops warm compute but keeps the workspace volume (idle reap, server shutdown); destroy removes the volume too (thread delete). The returned SandboxHandle's filesystem and exec are the same FilesystemBackend/ExecBackend interfaces the workspace capability already consumes, so wiring a new provider in requires no change to the capability itself.
Validate a custom provider against the same conformance suite dockerSandbox and fakeSandbox are held to:
import { runProviderConformance } from "@dawn-ai/sandbox/testing"
import { describe } from "vitest"
import { myCloudSandbox } from "./my-cloud-sandbox.js"
runProviderConformance({
name: "my-cloud-sandbox",
makeProvider: () => myCloudSandbox({ apiKey: process.env.MY_SANDBOX_KEY! }),
describe,
})The conformance kit checks acquire idempotency and reattachment, per-thread isolation, release-keeps/destroy-clears volume semantics, and that exec returns a numeric exit code.
Testing your agent
fakeSandbox() from @dawn-ai/sandbox/testing is an in-memory SandboxProvider — deterministic, CI-safe, and requires no Docker daemon:
import { config } from "@dawn-ai/cli"
import { fakeSandbox } from "@dawn-ai/sandbox/testing"
export default config({
sandbox: { provider: fakeSandbox() },
})Use it in harness-driven tests the same way you'd test any other agent route — it satisfies the same SandboxProvider contract as dockerSandbox, so wiring behavior (per-thread isolation, warm reuse, subagents sharing a thread's sandbox) is exercised without spinning up containers.
Verifying the full arc (end-to-end)
Two gated CI lanes prove the whole deployment path composes — a real Dawn app, built the user-facing way (dawn build's node target) and deployed, drives the provider from inside its own running workload to spawn a real, isolated sandbox on an Agent-Protocol request, then tears it down:
sandbox-docker-e2e— the app runs as a container with the host Docker socket mounted and drivesdockerSandbox(docker-out-of-docker) to spawn a sibling sandbox container.sandbox-k8s-e2e— the app runs as a Pod (deployed by thedawn-appchart, using the orchestrator ServiceAccount from thedawn-sandbox-infrachart) and driveskubernetesSandboxto spawn a sandbox Pod in the sandbox namespace.
Both are deterministic (the agent turn is driven by a mocked model, no API key) and assert on the real in-sandbox command output — that runBash ran as the hardened non-root user (id -u = 1000) inside the sandbox workload (not the app), and that the sandbox Pod/container and its volume are removed on thread delete. They're gated behind DAWN_TEST_SMOKE_E2E=1 and run on demand, the same way the provider conformance lanes do.
The distinction they lock in: only the Dawn runtime server (dawn dev, dawn start, or the node target's server.mjs) engages the sandbox. An image built via the LangSmith/langgraphjs platform path never runs the Dawn runtime and never touches the sandbox — see Deploying to production.
What it is — and isn't
Is: per-thread kernel-level filesystem and process isolation; the host filesystem is never touched; the host environment is never leaked into the sandbox; CPU and memory caps via resources; multi-tenant separation by thread; network: { mode: "deny" } is zero egress; the workspace survives turns, server restarts, and container crashes; hardened by default — all Linux capabilities dropped, no privilege escalation, a read-only root filesystem, a non-root user, and a process-count limit, each with an explicit opt-out.
Is not: a guarantee against container-escape zero-days. Dropped capabilities, a non-root user, a read-only root filesystem, and deny-mode networking materially raise the bar an attacker has to clear, but this remains Docker's isolation boundary, not a microVM. That's why the hardening is expressed as a provider-agnostic SandboxPolicy.security intent rather than a Docker-specific flag set: a gVisor-, Kata-, or cloud-microVM-backed provider satisfies the same seam with a stronger underlying substrate, as a drop-in replacement with no change to your app code. The allow-mode denylist is best-effort in the Docker reference; it does not stop an agent exfiltrating its own sandbox's data under allow mode. And the sandbox does not govern tool surface — that's tool scoping's job (agent({ tools })); the sandbox and tool scoping are complementary layers, not substitutes for each other.
Dawn ships the isolation seam plus a Docker reference. For hostile-grade multi-tenant isolation, plug in a microVM-backed provider.
The Kubernetes provider carries two of its own scope notes: its deny-mode NetworkPolicy only enforces egress control on a policy-capable CNI (Calico, Cilium) — a CNI that ignores NetworkPolicy leaves the Pod's network open regardless of config, which is why dawn check warns rather than guarantees; and its pidsLimit is not enforced at all — Kubernetes has no namespaced PID cap, so fork-bomb defense is a node-level kubelet setting (podPidsLimit) the cluster operator configures, not something the provider or the Helm chart can set.