Skip to content

feat: execution sandbox — per-thread hard isolation for the agent workspace (opt-in)#289

Merged
blove merged 24 commits into
mainfrom
feat/execution-sandbox
Jul 6, 2026
Merged

feat: execution sandbox — per-thread hard isolation for the agent workspace (opt-in)#289
blove merged 24 commits into
mainfrom
feat/execution-sandbox

Conversation

@blove

@blove blove commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

What

An opt-in execution sandbox: each Agent-Protocol conversation thread gets a hard-isolated workspace — filesystem, shell, and network — via a provider-agnostic SandboxProvider contract with a Docker reference implementation. Complements tool scoping (#261): scoping limits which tools the model may name; the sandbox bounds what an allowed tool can actually do.

Spec: docs/superpowers/specs/2026-06-25-execution-sandbox-design.md · Plan: docs/superpowers/plans/2026-06-25-execution-sandbox.md · Docs page: /docs/sandbox

// dawn.config.ts — new typed config() helper included
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"] },
    resources: { memoryMb: 512, cpus: 1 },
  },
})

No sandbox key → zero behavior change.

How

  • Contract types (SandboxProvider/SandboxHandle/SandboxPolicy/SandboxConfig) in @dawn-ai/workspace — the handle's filesystem/exec implement the existing backend interfaces, so the workspace capability consumes them unchanged.
  • SandboxManager (cli, per-server singleton): per-thread acquire/reuse with concurrent-acquire dedup, idle reaper (release keeps the volume), thread DELETE → destroy (volume too), shutdown → release-all.
  • Runtime wiring: threadId threaded into route prep; when configured, the workspace capability + offload store get the thread's sandbox backends and in-sandbox workspaceRoot. Subagents share the parent thread's sandbox via a dedicated sandboxThreadId key (deliberately decoupled from LangGraph's checkpoint thread_id).
  • dockerSandbox (new @dawn-ai/sandbox pkg): persistent per-thread container + named volume (create-or-reattach → workspace survives turns, restarts, container crashes), clean env (host env never inherited), --memory/--cpus caps, deny--network none (exact).
  • dawn check validates the config + runs provider.preflight() (e.g. Docker daemon reachable).
  • Isolation correctness: the materialized-agent cache is bypassed when sandboxed (same remedy as the subagent task-tool precedent) so tools bind per-thread — caught by the wiring e2e, which found and killed a real cross-thread leak during development. A separately-bisected checkpoint-identity regression in subagent dispatch was also fixed (sandboxThreadId decoupling).

Testing

  • Docker-free in validate: fakeSandbox (in-memory provider) + a provider conformance kit + SandboxManager lifecycle units + a behavioral wiring e2e (writes route into the sandbox, persist across turns, host fs untouched, per-thread isolation — false-green-verified both ways).
  • Gated sandbox-docker CI job (new, independent lane): the conformance kit against real Docker + deny-network egress block + host-fs-untouched + release→reacquire volume durability. This PR's run is the authoritative real-Docker proof (local daemon here can't pull images).
  • Full local verification: lint/build/typecheck green; pnpm test 976 passing (the one failure found during verification was bisected and fixed on this branch); check-docs green; Verdaccio publish harness green including the new package.

Honest scope (also in the docs page)

Docker's boundary, not a microVM — the seam is provider-agnostic so a gVisor/Kata/cloud-microVM provider is the stronger drop-in for hostile-grade multi-tenant. allow-mode denylist is best-effort in the Docker reference (deny is exact). The sandbox protects the host and other tenants; it does not prevent an agent exfiltrating its own sandbox's data under allow mode.

Release notes

🤖 Generated with Claude Code

blove and others added 22 commits July 5, 2026 20:07
Provider-agnostic SandboxProvider contract + Docker reference; per-thread
whole-workspace (fs+exec+network) isolation; create-or-reattach lifecycle
(release keeps the volume, destroy removes it); allow+denylist egress; opt-in
`sandbox` config key + new typed `config()` helper. Grounded against the real
integration points (workspace capability backends, thread_id plumbing into
prepareRouteExecution, DELETE/shutdown hooks, dawn check).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
16 tasks across 6 phases (contract+config helper → fakeSandbox+conformance+
SandboxManager → runtime wiring → dawn check → dockerSandbox+gated lane →
docs+changeset). Corrects spec type placement (contract types in @dawn-ai/
workspace, config() in @dawn-ai/core) after verifying the dep graph avoids a
core↔sandbox / sdk↔core cycle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add SandboxPolicy, SandboxHandle, SandboxProvider, and SandboxConfig
interfaces to @dawn-ai/workspace, re-using FilesystemBackend/ExecBackend
so the workspace capability can be redirected into a sandbox with no
interface changes. Export all four types from the package index.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add runProviderConformance() shared suite that enforces the four
SandboxProvider invariants (acquire idempotency, thread isolation,
release-keeps/destroy-clears, numeric exit code) against fakeSandbox now
and dockerSandbox in a later gated lane.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ead sandbox

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire a per-server SandboxManager into createRuntimeRequestListener: resolve
it from config, start a 60 s idle-reaper interval (unref'd), pass it through
buildRouteTable into every run handler so streamResolvedRoute/invokeResolvedRoute
receive it alongside thread_id. DELETE /threads/:id calls destroyThread before
204; close() clears the reaper and calls releaseAll. All hooks are no-ops when
no sandbox config is present.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Keystone behavioral proof for the execution-sandbox feature. Drives
streamResolvedRoute directly with the SandboxManager resolved from the
fixture's dawn.config.ts (+ threadId) — exactly how the runtime HTTP
server injects it — so no Docker / real model key is needed (aimock).

Proves (green): an agent's workspace writeFile/readFile route into the
thread's sandbox volume, persist across turns on the same thread, and
leave the host fs untouched. The workspace capability activates here
ONLY because the injected sandbox workspaceRoot is present (no host
workspace/ dir), so writeFile being offered is itself proof. Verified
real via a false-green check: forcing the local backend (disabling the
sandboxBackends assignment) makes writeFile disappear and the test fail.

Also surfaces a real DEFECT (tracked as `it.fails`): per-thread sandbox
isolation is currently broken — @dawn-ai/langchain materializeAgent()
caches the compiled agent per descriptor, freezing the first thread's
sandbox-bound workspace tools, so a second thread reuses them and sees
the first thread's files. Affects the real server for any agent served
across more than one thread in a process.

Exposes resolveSandboxManager + SandboxManager from @dawn-ai/cli/runtime
so out-of-band drivers (and this test) can build the same manager.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…thread tool isolation

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ubagent dispatch

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
dawnai Ready Ready Preview, Comment Jul 6, 2026 4:22am

Request Review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated approval: this PR received an intelligent (AI) code review. See the review comments on this PR.

@blove blove enabled auto-merge (squash) July 6, 2026 03:11
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Claude finished @blove's task in 4m 24s —— View job


PR Review: feat/execution-sandbox

  • Gather context
  • Read changed files
  • Analyze for correctness bugs, security issues, type-safety, test coverage
  • Post findings

The overall architecture is solid — the SandboxProvider/SandboxHandle contract is clean, the Docker reference implementation is straightforward, and the sandbox-bypass of the materialized-agent cache (to prevent cross-thread tool reuse) is the right call. The conformance kit + fakeSandbox testing strategy is well-structured. The sandboxThreadId decoupling from thread_id is a subtle but correct design.

Found 4 correctness/security issues and 1 minor concern:

Security

  • docker-exec.ts:13 — Env key is interpolated unsanitized into sh -c. Only the value is shellQuoted; a key containing ;, &&, or $ can inject shell commands. Validate keys against [A-Za-z_][A-Za-z0-9_]*.

Correctness

  • sandbox-manager.ts:37–61inUse counter only tracks the acquiring phase, not active turns. Once a handle is returned from cache, inUse is 0. The idle reaper can fire and release() a container that is still mid-turn if the turn takes longer than idleTimeoutMs (default 10 min).
  • docker-filesystem.ts:26writeFile uses cat > path which fails if the parent directory doesn't exist inside the container. The host localFilesystem presumably creates parent dirs; this behavioral difference will cause unexpected failures when agents write to sub-paths.
  • runtime-server.ts:129 — Shutdown calls sandboxManager.releaseAll() (removing containers) before draining in-flight requests. Mid-turn requests that don't immediately honour the abort signal will encounter a removed container and get opaque docker exec errors instead of a clean abort.

Feature gap

  • execute-route.ts:500buildOffload checks existsSync(join(appRoot, "workspace")) on the host. There is no host workspace/ dir when sandboxed, so tool-output offloading is silently disabled for all sandboxed routes. Pass sandboxWorkspaceRoot to buildOffload to enable it.

Minor

  • docker-sandbox.ts:7 — The sanitize function maps all non-alphanumeric chars to _, so t/1 and t_1 collide to the same container name. Harmless with UUID thread IDs but fragile by design; a short hex digest would be collision-free.

Comment thread packages/sandbox/src/docker/docker-exec.ts
Comment thread packages/cli/src/lib/runtime/sandbox-manager.ts
Comment thread packages/sandbox/src/docker/docker-filesystem.ts Outdated
Comment thread packages/cli/src/lib/runtime/execute-route.ts
Comment thread packages/cli/src/lib/dev/runtime-server.ts Outdated
Comment thread packages/sandbox/src/docker/docker-sandbox.ts
…elease shutdown, env key validation

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Claude finished @blove's task in 4m 42s —— View job


PR Review: feat/execution-sandbox

  • Gather context
  • Read changed files
  • Verify status of previously-flagged issues
  • Check for new issues (correctness, security, type-safety, test coverage)
  • Post findings

The architecture is solid and the core isolation contract is clean. Three of the five issues from the previous review are confirmed fixed:

  • docker-exec.ts — env key validation added (/^[A-Za-z_][A-Za-z0-9_]*$/)
  • docker-filesystem.tswriteFile now runs mkdir -p "$(dirname ...)" before cat >
  • runtime-server.ts (listener)releaseAll is correctly sequenced after the in-flight request drain inside createRuntimeRequestListener.close()

Two prior issues remain open, and one new correctness bug was found:

Still open from prior review

  • sandbox-manager.ts:36–61 — The cache-hit path in getForThread updates lastUsedAt at turn start but does not increment inUse. The idle reaper's guard (entry.inUse > 0) is therefore false for any active turn that came from cache. Turns longer than idleTimeoutMs (default 10 min) will have their container reaped mid-turn.

  • execute-route.ts:500–504buildOffload checks existsSync(join(appRoot, "workspace")) on the host. There is no host workspace/ dir when sandboxed, so offloading is silently disabled for all sandboxed routes. sandboxWorkspaceRoot is already available at the call site and just needs to be threaded in.

New issue

  • runtime-server.ts:144–151startRuntimeServer (the production path) destructures only { listener, state, shutdownController } from createRuntimeRequestListener, discarding its close function. startRuntimeServer.close() never clears the sandbox reaper interval or calls sandboxManager.releaseAll() — both are scoped inside createRuntimeRequestListener and unreachable. All sandbox containers leak on every production server shutdown. The createRuntimeRequestListener.close() path with the correct drain-then-release sequencing is only reachable from tests.

The sanitize collision minor (docker-sandbox.ts:7) acknowledged from the prior review remains; it's low-risk with UUID thread IDs.

Comment thread packages/cli/src/lib/runtime/sandbox-manager.ts
Comment thread packages/cli/src/lib/runtime/execute-route.ts
Comment thread packages/cli/src/lib/dev/runtime-server.ts
…e — sandbox cleanup on production shutdown
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Claude finished @blove's task in 2m 55s —— View job


PR Review: feat/execution-sandbox

  • Gather context
  • Read changed files (sandbox-manager, runtime-server, execute-route, docker files)
  • Verify status of previously-flagged issues
  • Check for new issues
  • Post findings

Three of the five issues from prior reviews are confirmed fixed:

  • docker-exec.ts — env key validation against /^[A-Za-z_][A-Za-z0-9_]*$/ added
  • docker-filesystem.tswriteFile now runs mkdir -p "$(dirname ...)" before cat >
  • runtime-server.tsstartRuntimeServer now destructures close: listenerClose and calls await listenerClose(), correctly routing production shutdown through the drain-then-releaseAll sequence (the issue flagged as new in the second review is fixed)

Two issues remain open:

sandbox-manager.ts:38–42inUse counter not incremented in cache-hit or concurrent-dedup paths. After the first acquisition completes (and finally() drops inUse to 0), every subsequent cached turn runs with inUse === 0. The reaper guard entry.inUse > 0 || entry.acquiring is false for all cached turns, so any turn longer than idleTimeoutMs (10 min default) can have its container released mid-turn. The existing test only covers the still-acquiring case. See inline comment for the [handle, done] fix.

execute-route.ts:500–505buildOffload still checks existsSync(join(appRoot, "workspace")) on the host. There is no host workspace/ dir when sandboxed, so buildOffload always returns undefined and offloading is silently disabled for all sandboxed routes. sandboxWorkspaceRoot is available at line 497 and just needs to be threaded in as a fifth arg. See inline comment.

The sanitize collision minor (docker-sandbox.ts:7) from the prior review is low-risk with UUID thread IDs and is acknowledged as a known limitation.

Comment thread packages/cli/src/lib/runtime/sandbox-manager.ts
Comment thread packages/cli/src/lib/runtime/execute-route.ts
@blove blove merged commit 4ede7b8 into main Jul 6, 2026
7 of 8 checks passed
@blove blove deleted the feat/execution-sandbox branch July 6, 2026 04:56
blove added a commit that referenced this pull request Jul 6, 2026
…ating

Resolves the check.ts import conflict: keeps both collectSandboxErrors
(from #289) and the renamed collectToolScopeIssues (this branch).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant