Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
ebe7572
docs(spec): execution sandbox design
blove Jun 27, 2026
dd9c482
docs(plan): execution sandbox implementation plan
blove Jun 27, 2026
c034c8b
feat(workspace): sandbox provider contract types
blove Jun 27, 2026
5ce373b
feat(core): DawnConfig.sandbox key + typed config() helper
blove Jun 27, 2026
239cac8
chore(sandbox): scaffold @dawn-ai/sandbox package
blove Jun 27, 2026
0152fa4
feat(sandbox): in-memory fakeSandbox provider for tests
blove Jun 27, 2026
1a59bb3
feat(sandbox): provider conformance kit
blove Jun 27, 2026
97b81f1
feat(cli): SandboxManager per-thread lifecycle
blove Jun 27, 2026
b88cbd3
feat(cli): resolve sandbox manager + route the workspace into the thr…
blove Jun 27, 2026
f32436c
feat(cli): sandbox manager singleton, DELETE/shutdown hooks, idle reaper
blove Jun 27, 2026
8d9d191
test(runtime): sandbox wiring e2e via fakeSandbox (no Docker)
blove Jun 27, 2026
a92a2c9
fix(langchain): bypass materialized-agent cache when sandboxed — per-…
blove Jul 6, 2026
9c3a48d
feat(cli): dawn check validates sandbox config + runs provider preflight
blove Jul 6, 2026
af0199c
feat(sandbox): injectable docker CLI wrapper
blove Jul 6, 2026
ea4dc27
feat(sandbox): docker filesystem + exec backends
blove Jul 6, 2026
1d44aa6
feat(sandbox): dockerSandbox provider (acquire/release/destroy/prefli…
blove Jul 6, 2026
75e2d28
test(sandbox): gated real-Docker conformance + e2e CI lane
blove Jul 6, 2026
99b6bb7
docs: execution sandbox guide
blove Jul 6, 2026
1e488ac
chore: changeset for execution sandbox (patch)
blove Jul 6, 2026
3e74d32
fix(cli): decouple sandbox scoping key from checkpoint thread_id in s…
blove Jul 6, 2026
ac74458
chore(sandbox): align new package version with fixed group (0.8.5)
blove Jul 6, 2026
22830f9
chore: lockfile after sandbox version align
blove Jul 6, 2026
c578762
fix(sandbox): review findings — writeFile parent dirs, drain-before-r…
blove Jul 6, 2026
ebafe30
fix(cli): route startRuntimeServer shutdown through the listener clos…
blove Jul 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/execution-sandbox.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@dawn-ai/sandbox": patch
"@dawn-ai/workspace": patch
"@dawn-ai/core": patch
"@dawn-ai/cli": patch
"@dawn-ai/langchain": patch
---

Add an opt-in execution sandbox: a provider-agnostic `SandboxProvider` contract
with a Docker reference (`dockerSandbox`), giving each conversation thread a
hard-isolated workspace (filesystem + shell + network). Enable via
`dawn.config.ts` `sandbox: { provider: dockerSandbox({ image }) }`; without it,
behavior is unchanged. Adds a typed `config()` helper. When sandboxed, the
materialized agent cache is bypassed so tools bind per-thread. Honest scope:
Docker's boundary (not a microVM); `allow`-mode network denylist is best-effort
in the Docker reference. New package `@dawn-ai/sandbox` (+ `@dawn-ai/sandbox/testing`
`fakeSandbox` and a provider conformance kit).
34 changes: 34 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,37 @@ jobs:
name: harness-artifacts
path: artifacts/testing/
retention-days: 7

sandbox-docker:
runs-on: ubuntu-latest
timeout-minutes: 20

steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

- name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with:
version: 10.33.0

- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
# 22.14.0 — node:sqlite (used by @dawn-ai/sqlite-storage) is only
# available without the --experimental-sqlite flag from 22.13+.
# Matches release.yml and the engines floor.
node-version: 22.14.0
cache: pnpm

- name: Install
run: pnpm install --frozen-lockfile

- name: Build sandbox package
run: pnpm --filter @dawn-ai/workspace build && pnpm --filter @dawn-ai/sandbox build

- name: Pull sandbox image
run: docker pull node:22-slim

- name: Real-Docker sandbox conformance + e2e
run: DAWN_TEST_DOCKER=1 pnpm --filter @dawn-ai/sandbox test docker-sandbox.integration
1 change: 1 addition & 0 deletions apps/web/app/components/docs/nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const DOCS_NAV: readonly DocsNavSection[] = [
{ label: "Workspace Filesystem", href: "/docs/workspace" },
{ label: "Context Management", href: "/docs/context-management" },
{ label: "Permissions", href: "/docs/permissions" },
{ label: "Sandbox", href: "/docs/sandbox" },
{ label: "Retry", href: "/docs/retry" },
],
},
Expand Down
9 changes: 9 additions & 0 deletions apps/web/app/docs/sandbox/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { Metadata } from "next"
import Content from "../../../content/docs/sandbox.mdx"
import { DocsPage } from "../../components/docs/DocsPage"

export const metadata: Metadata = { title: "Execution Sandbox" }

export default function Page() {
return <DocsPage href="/docs/sandbox" Content={Content} />
}
134 changes: 134 additions & 0 deletions apps/web/content/docs/sandbox.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# 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](/docs/tools) decides *which* tools the model may call; [permissions](/docs/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):

```ts title="dawn.config.ts"
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`, and `listDir` operate inside the sandbox's workspace volume. The host filesystem is never touched.
- **Shell** — `runBash` executes inside the sandbox, still gated by the [permissions](/docs/permissions) allow/deny lists.
- **Network** — governed by the configured [network policy](#network-policy); `deny` mode is zero egress.
- **Environment** — the host's environment variables are never inherited. Only the key/value pairs in `sandbox.env` are injected into the sandbox.
- **Resources** — `resources.memoryMb` and `resources.cpus` cap the sandbox's memory and CPU; `resources.timeoutMs` caps 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 `DELETE` on 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.

## 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 optional `allowlist` may 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.

<Callout type="warn" title="allow-mode denylist is best-effort">
In the Docker reference provider, the `allow`-mode denylist is **best-effort**, not enforced with the same rigor as `deny` mode. Blocking arbitrary outbound hosts from inside a container needs an in-container firewall rule or an egress proxy; the reference does not guarantee every denylisted host is unreachable. `deny` mode's `--network none` remains exact. A provider backed by a microVM or a cloud sandbox can enforce the denylist more strongly.
</Callout>

## Subagents

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:

```ts
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](/docs/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:

```ts
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:

```ts title="dawn.config.ts (test)"
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.

## 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.

**Is not:** a guarantee against container-escape zero-days. This is Docker's isolation boundary, not a microVM — which is why the contract is provider-agnostic: a gVisor-, Kata-, or cloud-microVM-backed provider is a stronger 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](/docs/tools)'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.

## Related

<RelatedCards items={[
{ href: "/docs/workspace", title: "Workspace Filesystem", subtitle: "the pluggable backend the sandbox's handle satisfies" },
{ href: "/docs/permissions", title: "Permissions", subtitle: "human-in-the-loop approval for paths and runBash commands" },
{ href: "/docs/tools", title: "Tools", subtitle: "tool scoping — which tools a route's agent may call" },
{ href: "/docs/configuration", title: "Configuration", subtitle: "dawn.config.ts reference including the sandbox key" },
{ href: "/docs/subagents", title: "Subagents", subtitle: "how subagents share their parent thread's sandbox" },
]} />
Loading
Loading