Sandboxed execution
Strategy update (2026-09-18): The tool integration strategy defines the planned shared catalog, named connections, progressive discovery, and Add capability flow. The delivery plan tracks implementation. Descriptions of existing behavior below remain the current contract until those changes ship. A tool call and the process that made it must not be the same object. That is the whole document. Everything below is the consequence of taking it seriously. The obvious design executes tools in the harness process, on the harness host, in whatever working directory the harness happens to be sitting in. It is obvious because it requires no design at all — the filesystem is already there, the credentials are already in the environment, andexec is one line. It is
also the design that produces every one of the following, and they are not
independent bugs:
- two concurrent runs corrupt each other’s working directory
- a destructive command, or an instruction smuggled in through content the agent read, executes with the harness’s full ambient authority
- concurrency is bounded by one machine, and one heavy run degrades every agent on the box
- nothing about the execution is a row anywhere, so nothing about it can be audited, resumed, or explained
Two wins, and they are not the same win
It is tempting to say “run it in the cloud” and consider all four problems solved. They are not, and conflating them produces a system that has paid for isolation without receiving it. Collisions are not caused by locality. They are caused by a shared mutable working directory. Two runs in the same directory corrupt each other whether that directory is on a laptop or a rented worker. Moving execution to a remote sandbox while still sharing a directory relocates the collision somewhere harder to observe. So the two properties are separate and both are required:- Remote execution buys the security boundary, horizontal scale, and host integrity.
- Workspace-per-run buys the absence of collisions.
The execution layer is a separate concern
Mesh does not integrate with a sandbox vendor. Mesh has an execution layer, and a vendor is a backend that implements it. The first shipped backend is a managed sandbox API; it is a backend, not the design. That distinction is the architecture, not a hedge, so it is stated before the rest of the design rather than as a footnote at the end. Nothing above the execution layer names a backend. Tools do not know where they run. The harness does not know where they run. Which system executes a command is one row of configuration, and every other part of Mesh reads a declared capability rather than a product name. The backends this is expected to serve are genuinely different from each other, which is why the boundary has to be designed now rather than extracted later:- a managed sandbox API — the default, and what ships first
- a Kubernetes cluster, for an operator who already runs one and wants agent workloads inside their own perimeter
- an edge container platform, where sandboxes are addressed by a caller-supplied identity and can sleep and wake rather than being created and destroyed
- an isolate runtime, for the narrow case of running a snippet with no shell, no package installs, and no filesystem worth the name
- a local backend, for people working on Mesh itself
interrupt-model.md already establishes that
a conversation can resume long after the work paused; a lifecycle that only knows
how to destroy would throw away exactly the state that makes resumption cheap.
Some backends cannot host the workload at all. An isolate runtime that runs a
JavaScript or Python snippet is a legitimate backend for “evaluate this
expression” and a non-starter for “run the test suite.” This is the case that
proves capabilities have to be declared rather than assumed: a backend that
cannot offer a shell is not a broken sandbox backend, it is a different capability
set, and the layer above must be able to see that before routing a tool to it
rather than discovering it from a failed exec.
Instead:
- The contract is the small intersection: acquire a workspace, exec in it, read and write files, release it. Every backend must implement all of it.
- Everything else is a declared capability the backend advertises, and the layer above degrades legibly when it is absent. Snapshotting is the clear example: a backend that cannot snapshot project state is slower to start and otherwise correct, and Mesh must say so rather than fail.
- Semantics travel with the contract, not the backend. Workspace-per-run, credential scoping, configurable egress, and budget enforcement are Mesh’s invariants. A backend that cannot enforce one of them declares that it cannot, and it is a policy decision whether that backend is allowed to run a given agent. “Isolation” must never quietly mean something weaker because of which backend is configured.
agent-provisioning.md already establishes for agents and their
credentials, and for the same reason.
The workspace
A workspace is a row, created for a run, materialized by the execution layer:external_ref is deliberately opaque. Nothing outside the backend’s own
implementation is allowed to parse it, which is the mechanical guarantee that no
caller has quietly grown a dependency on a particular vendor’s identifier
format. It is also not assumed to be assigned by the backend: a platform that
addresses sandboxes by a caller-chosen identity is served by letting the backend
decide what goes in this column, rather than by modelling every backend as
returning a handle it invented.
released rather than reaped is deliberate. Mesh’s invariant is that a
workspace stops being reachable by its run, not that a container is destroyed.
A backend that hibernates instead of destroying satisfies the invariant, and
saying reaped in the schema would have quietly encoded one backend’s lifecycle
as the universal one.
Provisioning is lazy
A run does not get a workspace. A run gets a workspace when a tool first reaches for one. This matters more in Mesh than it would in a coding-agent harness. Mesh lives in Slack threads, and most turns in a Slack thread are conversational — a question, a status check, a clarification. Those turns touch no filesystem at all. Provisioning a sandbox for them buys nothing and costs startup latency on every single reply, which is exactly the wrong tax on the interaction Mesh is supposed to be good at. So the status column starts atpending and most runs never leave it. The
useful consequence is that execution cost scales with work rather than with
conversation volume: a busy channel full of chatter is nearly free, and a run
that compiles something pays for what it used.
One per run, and no more
At most one workspace per run, enforced by the schema rather than by convention. Sub-agent work in Mesh is a child run, so a sub-agent gets its own workspace with no additional machinery — which is precisely the property whose absence causes sub-agents to trample each other in a single-directory harness.created_by_actor_id is not decoration. philosophy.md commits to every
agent turn being explainable, and “which human’s request caused this container
to exist” is part of that. Execution is the most consequential thing an agent
does; it does not get to be the one part of the system with no attribution.
The loop stays in the control plane
The naive reading of “sandbox the agent” is to put the agent in the sandbox. Do not do that. Mesh splits it:- Control plane owns the harness loop, run and step persistence, model calls, outbound connector emissions, and all database writes.
- Sandbox is the effect surface: shell commands, file reads and writes, git, dependency installs, tests, builds, linters.
agent-harness.md makes a run durable
by committing each iteration as a transaction, and interrupt-model.md
builds coalescing, supersession, and awaiting_input on top of that durable
state. All of it depends on the loop’s state living in Postgres. Move the loop
into an ephemeral container and every one of those properties becomes a
distributed-systems problem that was previously a transaction.
It keeps the interesting credentials out of the blast radius. A workspace
that only runs commands needs no model provider key, no Slack bot token, and no
database connection string. The credential surface inside the boundary is the
smallest it can be, by construction, rather than by discipline.
It keeps the execution layer thin enough to have more than one
implementation. This is the part that matters for pluggability. A backend that
only has to run commands and move files can be written against Kubernetes, a
managed sandbox API, or a local process in comparable effort. A backend that has
to host the agent loop must also reproduce durable run state, model access, and
outbound delivery — at which point there will only ever be one backend, whatever
the interface claims.
It costs approximately nothing. An exec round trip to a warm sandbox is
milliseconds. The model call it sits between is seconds. The split is invisible
in the latency budget.
Cancellation safety is unaffected. RunStep already carries a replayability
classification, and an effectful step is still effectful when it happens in a
sandbox. A step that pushed a commit cannot be silently redone regardless of
where the push ran from.
Credentials are declared and minted, never dumped
Credential delivery follows the agent-owned identity guardrail (repository): an acting credential belongs to the agent running the workspace. A delivery grant cannot authorize another agent’s account or a pooled bot identity. Modal/Fly backend credentials configure shared infrastructure; they are not guest identities. The rejected design is to pass the agent’s full credential set into the sandbox as environment variables. It is the natural first instinct, and it gives away the entire benefit. The threat is not the agent running a destructive command. A sandbox contains that. The threat is an instruction arriving through content — a file in a cloned repository, an issue comment, a fetched page, a dependency’s postinstall script — and the resulting action being exfiltration. A sandbox does not contain exfiltration of a keyring that was handed over at boot, and exfiltration is the failure that cannot be rolled back. So:- An environment declares what it needs: which repositories, which registries, which named secrets.
- Mesh mints short-lived, narrowly scoped credentials per run.
- Repository access is a scoped installation token for the specific repository, with a short expiry. Not a long-lived personal token.
- Everything else is reached through a per-run token that the sandbox exchanges against a broker endpoint in the control plane. Every secret read is a persisted event tied to the run and the actor who triggered it.
- The declaration is persisted, so what could this run see is a query rather than an investigation.
Workspace network access
Agent workspaces have unrestricted outbound network access by default. Mesh cannot predict which HTTP APIs or hosts an open-source installation needs. Filesystem/process isolation and credential permissions remain separate from network policy: open egress does not grant additional credentials, but code in that workspace can send any data or credentials it already holds to arbitrary reachable destinations. Provider-level network restrictions still apply. Operators can opt intomode: "allowlist" under Settings → Agent runtimes →
Workspace network access (GET/PUT /api/settings/workspace-egress). Restricted
mode allows Mesh’s built-in source hosts and package registries
(workspace.DefaultEgressDomains), operator-specified domains, and the Mesh
broker host. It is default-deny outside that effective list. The UI shows the
built-in hosts and defaults to refusing incapable backends when restriction is
enabled; an operator may explicitly accept open egress on those backends instead.
In open mode, the workspace spec carries no execution.EgressPolicy, including
on a backend with Capabilities.EgressControl. In restricted mode, Modal enforces
the domain allowlist and an empty CIDR allowlist at sandbox creation; raw IPs
cannot bypass it. On an incapable backend such as Fly Sprites, uncontrolled: "refuse" rejects acquisition before a sandbox is created; "allow" accepts open
egress. That fallback setting is dormant in open mode. The policy follows both
warm restores and cold retries.
An install with no saved policy defaults to mode: "open". A saved legacy
policy without mode retains its previous allowlist behavior on upgrade. Its
owner can explicitly switch to open mode. New API writes must specify the mode;
malformed or unreadable policies still fail acquisition rather than silently
widening access.
Policy changes apply to newly created workspaces. Existing running or held
(paused) sandboxes retain their creation-time policy until replaced. Updating
Mesh or saving this setting does not retrofit a live sandbox’s network rules.
The credential-free document parser remains separately network-isolated; this
setting does not relax document-processing isolation or connector URL checks.
The allowlist bounds destinations, not what can be sent there. Some built-in
hosts accept uploads, so even restricted mode is not an exfiltration barrier by
itself. Scoped credentials and human review still matter.
Denied attempts are not yet recorded as structured events: a backend denial may
surface only as a connection error. A connection reset alone does not prove a
policy denial; inspect the configured policy rather than inferring one from the
hostname or asking for another token.
Cold start is layered, not atomic
The objection to a fresh workspace per run is that a large repository would be cloned every time. That objection is correct about a single atomic snapshot and wrong about the design. Workspace state has natural layers with different lifetimes:- Base image — operating system and toolchain. Changes when the toolchain changes.
- Project state — the checked-out repository, installed dependencies, warm build caches. Changes on push.
- Run artifacts — build output, scratch files. Ephemeral by definition.
Budgets extend to compute
agent-harness.md commits to runs terminating legibly rather than
hanging. Compute is now a resource a run consumes, so it joins the budget set:
- max compute-seconds per run
- max concurrent workspaces per agent
- an idle reaper, because a crashed worker must not leak paid compute
The backend contract
The intersection every backend must implement:if backend == "..." is a missing capability
declaration, and the fix is to add the capability rather than the branch. This is
the single rule that keeps a pluggable layer pluggable, because it is always
cheaper in the moment to special-case one vendor.
The contract is the intersection, not the union. A backend that needs a
concept no other backend has expresses it inside its own configuration, not by
widening the interface everyone implements. An interface that grows to fit every
backend’s best feature is how a second implementation becomes impossible to
write.
Mesh’s invariants are Mesh’s, not the backend’s. Workspace-per-run,
credential scoping, configurable egress, and budget accounting live above the
backend. Where a backend cannot enforce one, it declares the gap and Mesh
decides by policy whether that backend may run a given agent. The word
“isolated” must mean the same thing regardless of what is configured underneath.
Ordering: the local backend ships last
A local backend exists — requiring a third-party account before anything executes at all is a real cost for someone running Mesh for the first time — but it ships after a remote one, implemented against the same contract, and is documented as unsafe for anything but working on Mesh itself. It still allocates a distinct working directory per run, because the collision fix is not allowed to regress on the development path. The ordering is the point. Building local first and abstracting later produces an interface shaped like a filesystem, and every remote backend then spends its life pretending to be one. The first implementation of an interface defines it no matter what the documentation claims, so the first implementation should be the one with the most constraints.What ships today
The contract above is the design. This section is the honest inventory of what is built against it, so a reader can tell a guarantee from an intention without reading the code. Keep it current in the same PR that changes the code. Built.- The
Backendcontract (internal/execution) and its first, remote backend: Fly Sprites (internal/execution/fly).Command.Dircarries a working directory. The workspace layer delivers authorized credentials through a protected file before commands, without putting them in the create-time environment (see the credential bullet below). - Backends as runtime configuration: the
agent_runtimessettings section, configured from the dashboard or claimed byMESH_FLY_API_TOKEN, projected through theexecution.Providerdescriptor catalog (internal/execution/backends). Nothing above that catalog names a vendor; the workspace manager constructs whichever enabled backend it finds through the descriptor’sNew. - Which enabled backend a run uses (per-agent selection). An
install may enable more than one backend at once. The resolver
(
internal/app/workspace_runtime.go) chooses among the enabled set without ever branching on which vendor a name identifies:- One enabled → it is used, no configuration required.
- More than one enabled → each agent may pin one via
runtime_agents.runtime_backend(a catalog backendName, set through the agent-detail page, which shows the selector only when more than one is enabled). An agent with no pin takes the documented instance default: the first enabled backend in catalog order, logged once at acquisition so an unpinned agent’s implicit runtime is observable rather than looking random. - A pin naming a backend that is not currently enabled (turned off, or a name that predates a catalog change) is refused with an operator-actionable error — the resolver never silently falls through to a different vendor’s runtime. The set of enabled backends is validated at the admin boundary too, so a pin can only be stored for a backend that is enabled at the time it is set.
- Disabling a backend un-pins its agents (the disable-time cascade). When
an operator turns a backend off, the same transaction that flips its
enabledswitch also NULLsruntime_agents.runtime_backendfor every agent pinned to it (ClearAgentRuntimeBackendPins), and the save response reports the reset slugs so the settings page can say “disabling<backend>reset N agent(s) to the instance default.” A reset agent falls back to the instance default on its next acquisition — it is never left pointing at a runtime the install no longer offers. This is the primary path; the not-currently- enabled refusal above is the defense-in-depth backstop for a pin that survives a clean disable (a race, a direct DB edit, or a backend that goes unhealthy without being disabled). The pin is a selection among enabled backends, never a credential: the backend’s token remains an install-owned setting.
- Release resolves by the row, not by the selection. Selection answers
where new work runs; ending an existing workspace uses the backend its row
recorded (
workspaces.backend_kind), looked up by name in the catalog (workspace.KindResolver, wired frominternal/app/workspace_runtime.go). Both the per-run release and the process-wide reaper — which has no agent and sweeps every agent’s rows — go through it, so a workspace an agent pinned to a non-default backend materialized is released by that backend rather than failing against the default and running on. A backend whose enable switch is off still releases what it minted while its credential is retained (turning off acquisition never turns off cleanup, the same rule credential revocation follows). Only a recorded backend with no catalog entry or no credential left is unreachable: the row stays live as evidence and the release reports that it must be released by hand. Snapshot reclaim follows the same rule: the reaper’s snapshot sweep (Reaper.SweepSnapshots) lists every backend that has reclaimable snapshots (ListReclaimableSnapshotKinds, byproject_snapshots.backend_kind), resolves each through the sameKindResolver, and deletes that backend’s artifacts through it — so a non-default enabled backend’s retired snapshots are reclaimed instead of costing storage forever. The per-sweep row limit applies per backend (filtered before theLIMIT), so one backend’s backlog cannot starve another’s, and so is the sweep’s time: kinds run in name order under the sweep’s one deadline, each bounded to its fair share of what remains (unused time rolls forward), so a hung backend early in the order cannot spend the deadline and starve every later kind on every tick. An unreachable kind is skipped — warned about once per process per kind, then logged at debug on later ticks — and its rows stay unreclaimed as evidence; a backend without the snapshot capability has nothing to reclaim; neither blocks the other kinds. Resume verification and snapshot capture stay on the selection, because both are about continuing work there (a capture’s own orphan cleanup deletes through the backend that just minted it). Nothing that maintains an existing workspace or snapshot row resolves the default backend alone. - Sized workspaces: a vendor-neutral workspace tier (
execution.Size—small,standard,large,xlarge, ordered smallest-first) that each backend maps to its own guest units at its leaf (internal/execution/fly,internal/execution/modal); nothing above the leaf names RAM or CPU. The ONLY human-facing knob is a per-install ceiling (themax_sizeconfig field on every backend descriptor, a closed enum validated at the form like the region enum) — size is a cost guardrail, not an agent-level functional dial. The tier a workspace actually asks for is the workspace manager’s policy, clamped to the ceiling before the backend sees it. 🚨 The default tier today is a REVERSIBLE BRIDGE, not the end state. It carries working headroom (large, above thestandardbuild floor) so heavier builds — commongrid’snext buildis the one that forced it — succeed before OOM-aware auto-climb lands. Once auto-climb ships, the default drops tosmall(the smallest tier that boots) and headroom is reached by climbing tiers on OOM, not by a fat default; the enum is built for that reversal (a realsmallfloor below the default, room above it up to the ceiling).NODE_OPTIONS/--max-old-space-sizeis explicitly out of scope — it is operator repo config, language-specific. - The
workspacestable (0030) andinternal/workspace: one workspace per run enforced byUNIQUE (run_id), the row written before the backend call, acquisition lazy on the first exec-class tool call, reuse within the run, release when the run ends however it ends, and a background sweep (at boot and every ten minutes, each sweep time-bounded) that releases workspaces whose run is already terminal or that outlived the longest possible run. - The exec-class tools, on the crawlable package format:
workspace_exec,workspace_read_file,workspace_write_file(tools/workspace,Needs: ["workspace"]), and the GitHub package’scheckout_github_repo(Needs: ["workspace", "secret:github_pat"]), which clones a repository into the workspace, configures git to commit as the agent’s GitHub account, and checks out a working branch.create_github_pull_requestis the control-plane fallback for opening the PR whenghis not in the workspace image. - A progress notice on the run’s first workspace acquisition: one threaded
message telling the requester a sandbox is being set up and a reply will
follow, so a multi-minute coding run is not an hourglass and silence
(
agent-harness.md§Progress emissions). - Explicit, agent-owned credential delivery (repository): a saved
PAT alone does not grant sandbox access. A visible permission authorizes the agent’s
GitHub credential as
GH_TOKENandGITHUB_TOKEN, plus non-interactive guards for git and gh. Credentials are resolved from the agent’s own secrets per acquisition and made visible to every command the workspace runs. The first live run proved a create-time environment does not reach a Sprite’s exec sessions, and the backend’s per-exec environment parameter rides the request URL (which vendor logs retain) and replaces the session environment, so neither is the credential path. Instead the workspace layer writes the grants once per workspace to a mode-0600 file under the sandbox user’s home through the filesystem surface (a request body, not a URL) and prefixes each command with a fixedsh -c '. "$1"; shift; exec "$@"'wrapper that sources it; argv is never re-parsed. The token is therefore on the sandbox’s disk for the run’s lifetime — a deliberate trade: an agent that can runenvcan read either, while a credential in a third party’s access logs is exposure nothing in the run can bound. Nothing else from the keyring goes in, git authenticates through a credential helper that readsGH_TOKENat use time, and the workspace is destroyed at release. Permission changes and credential rotation invalidate active and held sandboxes; handles revalidate every operation and a background worker retries provider teardown. Previously copied long-lived tokens require revocation at GitHub.
- Minted credentials and the broker. The grant is the agent’s own personal access token for the run’s lifetime, not a short-lived scoped installation token. Use a fine-grained PAT scoped to the repositories the agent works on.
- Egress control on every backend. Open access is the default. Opt-in
allowlist enforcement is available on Modal; Fly Sprites declare
EgressControl: false, so restricted installs must refuse that backend or explicitly accept its open egress. Denied attempts are not yet recorded as events. - Declared environments and the layered cold start. Every run clones. There is no per-repository project-state snapshot yet; Sprites checkpoints are the obvious first implementation, and a warm clone per repository is the next meaningful latency win.
Resumable. Release destroys the sprite. A conversation’s next turn re-clones rather than waking the previous turn’s workspace, so an agent must push its branch before the run ends — the tool descriptions say so.- Compute budgets. A workspace’s life is bounded by its run’s wall clock and by per-command timeouts, not by a compute-seconds budget of its own.
- Artifacts. Large command output is clamped into the observation (head and tail kept, a visible marker for the middle) rather than stored whole.
Design guardrails
- do not execute tool calls in the harness process
- do not share a working directory between runs, locally or remotely
- do not put the harness loop inside the sandbox
- do not hard-code an execution backend anywhere above the execution layer
- do not branch on backend identity; branch on declared capability
- do not widen the backend contract to fit one backend’s best feature
- do not let “isolated” mean something weaker because of which backend is configured
- do not pass an agent’s credential set into a sandbox wholesale
- distinguish filesystem/process isolation from optional network restrictions
- do not snapshot toolchain and project state as one unit
- do not provision a workspace for a run that never asked for one
- do not let a workspace stay reachable by a run that has ended
- do not assume release means destroy, or that acquire means create