Sandboxed execution and shell skills
This document describes the execution plane: the core-owned, sandboxed place where models run shell commands, scripts, and package installs — and how skills execute on it.
Every model orchestrated through Cinatra — chat/assistant, agent runs, and deterministic tasks, across the OpenAI, Anthropic, and Gemini providers — reaches the same execution plane through one core execution capability: the provider-agnostic sandbox_execute tool, translated per provider (a native shell on shell-capable OpenAI models, a named function tool otherwise). There is one execution surface, not one per provider.
The connector shell path is gone. Older releases described a connector-owned Docker shell: the
@cinatra-ai/openai-connectordeclared ashellToolscapability, shipped its ownruntime/Dockerfile, exposed a Local shell configuration tab, and started a throwaway--rmcontainer with host workspace paths bind-mounted into it. That path is retired — the capability, the executor, the tab, and the runtime image are all removed from the connector, which is now a pure credential/provider surface. Nothing on this page describes that path as current; everything below is the core execution plane that replaced it.
How to read this page
Section titled “How to read this page”Security prose goes stale in one specific way: intent gets written in the present tense and then reads as a guarantee. This page separates the two, and marks a third state for anything that exists in code but is not reachable on a default install.
| Tier | What it means |
|---|---|
| A — Intended contract | What the plane is designed to guarantee. Design intent. Do not read a Tier A statement as a control that runs today. |
| B — Enforced by merged code | A control the merged code applies when a command runs. Every Tier B claim names the enforcing symbol and links to it at a pinned commit — see the claims table. |
| C — Gated | Behind the opt-in rollout flag, or landing in a later slice. Present in the tree, not reachable on a default install. |
Source citations below are pinned to cinatra-ai/cinatra at commit 6285e014. A pinned link keeps the citation honest even after the file moves.
Tier A — the intended security contract
Section titled “Tier A — the intended security contract”This section is intent. It is what the plane exists to achieve; the code that currently backs each point is in Tier B, and the parts not yet reachable are in Tier C.
- Never in the app process. Model-driven execution must never run in the app process or on the app host, where it would carry the app container’s ambient authority and environment. The app image ships no Docker CLI, so an in-app executor would require handing the app container control of Docker — the outcome the design refuses.
- Tenancy. Mutually-untrusted organizations never share an instance; organizations on one instance are mutually trusting. Hardened-container isolation is the accepted grade on every class — no microVM requirement.
- No credentials, ever. Scoped business credentials never enter a sandbox. Authenticated actions stay in the MCP and connector tools. This is load-bearing: it is what bounds the blast radius of default-on internet access.
- Egress is attributed, not blocked. Internet access is on by default — parity with the assistant’s web access is what lets a model download and install tools — but all of it transits an enforcing gateway that attributes, meters, and (in the restricted tiers) filters. Command allow/block lists are hygiene, never the security boundary.
- Availability. The capability is a property of the orchestration layer: on for every agent and every assistant/chat surface, with a per-org and per-agent opt-out. Only two carve-outs: single-step/structured-output tasks (no post-tool turn exists in which a model could use a result), and unidentifiable callers (no attributable identity, no capability).
- Tool persistence without a shared host. An agent that needs
pandocshould declare it once and get it on every run, without a durable machine anyone can accumulate state on. That is the tiered environment model (L0/L1/L2, with named persistent workspaces deferred). - Sandbox output is tainted. Anything a model brings back out of a sandbox is untrusted, model-produced content — never a trusted platform record and never the result of an authenticated action.
- Reviewed environment changes. A declared environment is a supply-chain surface. Changes to it ride the agent’s existing review path, and a model never edits its own environment; a promotion proposal is a reviewable diff a human accepts.
Tier B — enforced by merged code
Section titled “Tier B — enforced by merged code”Everything in this section is applied by merged code when a command runs. Reachability from a default install is a separate question, answered in Tier C.
The sandbox boundary
Section titled “The sandbox boundary”Every command runs in a fresh, hardened container built from the L0 base image, dispatched by the local-dev sandbox worker (LocalDevSandboxWorker, packages/execution-plane/src/worker.ts). The run profile is the contract:
- Non-root at a fixed, contractual UID/GID (
10001:10001), passed explicitly with--user—buildHardenedRunArgsdoes not trust the image’s ownUSERdirective. - Read-only root filesystem (
--read-only). The profile grants exactly two writable mounts: the per-run workspace volume at/workspaceand a 128 MiBtmpfsat/tmp. (Docker still provides its own container runtime mounts, such as/dev/shm; those are outside the profile’s argv.) - All capabilities dropped (
--cap-drop ALL) and no privilege escalation (--security-opt no-new-privileges:true). There is no root path inside a running sandbox; OS-level packages enter only at image build time. - No host bind mounts.
assertNoBindMountsis called on every dispatch: every--volumemust name a Docker volume (no path separator in the source) and no--mount type=bindmay appear. A violation raises, rather than degrading to a policy warning. - Environment scrubbed by omission.
sandboxEnvironmentreturns an explicit, enumerated environment —HOME, the user-space install prefixes inside the workspace, and the egress proxy variables when egress rides the gateway — and nothing else. The worker never forwards its own environment, so a new host variable cannot leak in by default. - Resource quotas: CPU, memory (swap pinned to the memory ceiling) and PID limits via cgroups; a wall-clock timeout the worker enforces host-side; per-stream
stdout/stderroutput caps. - A measured disk quota. A per-file
ulimit -fcap rides inside the wrapped command (wrapSandboxCommand), and after every command the worker measures the workspace withmeasureWorkspaceKb— run over the trusted L0 base, never the agent’s own L1 layer, so an L1 build cannot install aduthat under-reports. Two conditions are worth knowing exactly: the worker raisesdisk_quota_exceeded(which terminates the job) only for a command that otherwise exited normally — a command already terminated by the wall clock or the output cap keeps that termination class — and a measurement that fails is recorded as0KiB, so the command is treated as within quota and the next command re-measures. - Option-injection defense.
assertSafeImageRefrejects an image reference with a leading-or out-of-charset characters, and a--argv separator ends option parsing before the image and command.
The default per-command quotas are 1 CPU, 1 GiB memory, 256 PIDs, a 2-minute wall clock, a 1 MiB per-stream output cap, and a 256 MiB workspace. They are a per-broker setting, not a per-job one: the broker merges its construction override over the defaults once and applies the result to every job it runs.
Execution-session binding
Section titled “Execution-session binding”The execution session is the trust root. mintExecutionSession normalizes a session down to exactly {orgId, userId, surface, runId?} at a single choke point and raises UnidentifiableExecutionCallerError when orgId or userId is empty — no attributable identity, no capability. The orchestration layer turns that into a structured no_session signal rather than an exception into the model call.
The minted session is sealed by sealExecutionSession into an opaque, HMAC-SHA256-signed, expiring carrier, and only the broker opens it (openSealedSession, constant-time compare). Because the whitelist runs at seal time as well, a caller cannot smuggle a secret or host-data field into the carrier. The carrier rides on the internal sandbox_execution tool member and never reaches a provider: the adapter translates that member into a native type: "shell" declaration (or a sandbox_execute function tool) carrying only a description and a skill listing, and on every non-injected path the orchestration entry points call stripSandboxExecutionTools before handing tools to an adapter, so a smuggled or stale sandbox tool cannot slip through.
Surface issuers mint through one shared seam, resolveSurfaceExecutionBinding (src/lib/execution/surface-execution-session.ts). Be precise about the division of labour: that seam mints from the identity its caller hands it — it does not itself authenticate anything. The proving happens upstream, at each issuer. Chat mints from the auth-derived org and the authenticated user, with no run binding. The agent-run path (/api/llm-bridge) mints from the run it has already resolved and authorized for this request, never from a caller-supplied body id. The broker then re-checks per command: its run-liveness probe re-reads the run row and answers gone both when the row is missing and when its org or owner has diverged from the identity sealed into the carrier.
Egress
Section titled “Egress”resolveEgress maps the policy tier onto a network configuration; buildHardenedRunArgs turns that into the container’s --network argument; and ExecutionBroker.exec is where a failure to resolve or register becomes a refusal. The three tiers:
none→resolveEgressreturnskind: "none"and the run profile emits--network none: a kernel-level deny with nothing to bypass.default_internetandallowlist→resolveEgressnames a network and the profile attaches the container to it. What makes that network safe is a separate symbol:ensureInternalNetwork(called bystartLocalGateway) creates it withdocker network create --internal— no NAT route — and refuses an existing network of the same name that is not internal. The gateway container is then attached to it as the sole dual-homed path out. A process that ignores the proxy variables has no route: the enforcement is the network topology, not an environment hint.- A gateway-requiring tier with no gateway configured →
resolveEgressraisesEgressGatewayRequiredError, whichExecutionBroker.execconverts into a refusal. It does not silently grant direct egress, and it does not silently downgrade to no network (which would misreport the policy).
registerJobEgress posts the per-job token and its resolved policy to the gateway’s control-secret-authenticated channel before the sandbox runs; a failed registration raises EgressRegistrationError, and again it is ExecutionBroker.exec that turns that into a refused command rather than a dispatch the gateway would reject anyway.
The gateway denies by default. Its decide() function (packages/execution-plane/runtime/egress-gateway.cjs) is an allow-list of positive outcomes; every other path is a refusal:
| Condition | Outcome |
|---|---|
| No proxy credential on the request | 407, reason unattributed |
| A token the control channel never registered | 407, reason unregistered_token |
Host not on the allowlist (in allowlist mode) |
403, reason denied_by_allowlist |
| Per-job byte ceiling already crossed | 403, reason byte_quota_exceeded |
| Destination resolves into a protected range | 403 (see below) |
A sandbox therefore cannot forge attribution and cannot reach anything merely by not being registered. It also cannot pivot by name: resolvePinnedAddress resolves every destination host and pins the resolved IP for the connection, so the policy decision and the actual connection cannot diverge through DNS rebinding, and isProtectedAddress rejects the destination — in every mode, including allow-all. What that check covers, exactly: for IPv4, loopback, 0.0.0.0/8, the RFC 1918 private ranges, link-local (which includes the 169.254.169.254 cloud-metadata address), CGNAT, and everything from 224.0.0.0 up; for IPv6, the unspecified and loopback addresses, fe80::/10 link-local, fc00::/7 unique-local, and IPv4-mapped and IPv4-compatible forms re-checked numerically against the IPv4 rules. An address that cannot be parsed is rejected.
Allowlist matching in the gateway (hostAllowed) is exact or dot-suffix: pypi.org allows pypi.org and files.pypi.org, never notpypi.org or pypi.org.evil.example. A leading *. on an entry is stripped, so *.example.com and example.com behave identically.
The audit record — exactly what is kept, and what is not
Section titled “The audit record — exactly what is kept, and what is not”The broker emits an audit record for every command — executed, refused, and terminated alike — and toAuthzAuditEventInput maps it onto the platform’s existing authz audit vocabulary, with the model as the acting principal. That mapper is the boundary that decides what a durable row can ever contain, and it is the right thing to read when the question is “what could this record possibly leak”.
Retained — the top-level event: organizationId, actorPrincipalId (the session’s userId), actorPrincipalType: "model", authSource: "agent", resourceType: "execution_sandbox", resourceId (the broker job id), operation: "sandbox_execute", decision, and runId when the session carried one. Its metadata object carries exactly ten keys: surface, cwd, reason, exitCode, termination, imageDigest, egressMode, egressTotalBytes, wallMs, workspaceKb.
Read decision precisely. It answers “did the broker let this command run”, not “did it end well”. A command the broker refused — no live run, an untrusted environment, a worker error, a policy refusal — is denied with its refusal class in reason. A command that actually ran is allowed even when it was then terminated by the wall clock, the output cap, or the disk quota; the termination class is in termination, not in decision.
Excluded — the mapper drops the fields that exist on the in-memory record but must never reach a durable row:
- The executed command text. The broker holds
commandfor the command-policy hook and stdio correlation; it is not mapped. - The per-destination egress list.
egressDestinations(host, port, allowed) is dropped; only the tier and the byte total survive. - Command output.
stdout/stderrnever enter an audit record. The broker has an optional stdio-retention seam for them, and the local-dev construction supplies no sink — so today command output is retained nowhere by the plane. - Prompt text and credentials are never part of the record in the first place; the sandbox holds no credentials by construction.
The exclusions are pinned negatively, not merely by convention: audit-metadata-only.test.ts asserts the mapped output contains neither property and that a hostile record’s command string, bearer token, and destination hosts appear nowhere in the serialized event.
The write itself is best-effort. The mapper’s guarantee is about content; delivery is a separate question, and the honest answer is that this is a decision record, not a guaranteed-complete ledger. logAuditEvent never throws by contract, the plane’s sink additionally swallows any transport failure so an audit problem cannot propagate into a model’s tool loop, and the kernel cooldown-suppresses repeated denied events with the same key for 60 seconds (allowed events are never suppressed). The broker has already enforced its decision before the sink runs — a missing row never means a command escaped a control.
The org-admin read path (readExecutionAuditRows) is a second, independent allowlist: it projects named columns and named metadata keys only, and it returns an empty list rather than throwing when the store is unavailable. Its orgId argument scopes the read to one organization; called without one it reads instance-wide, which is why that form is reserved for the platform-admin surface. The function performs no authorization itself — the caller is the gate.
Readiness is gated on a completed handshake
Section titled “Readiness is gated on a completed handshake”Readiness cannot be asserted by configuration. runBrokerHandshake performs a real round trip: it seals a fresh carrier under a reserved non-tenant identity, opens a broker job, runs a probe command in a real container over the L0 image, and requires termination: "exited", exit code 0, and the expected stdout. Only on that completed handshake does the boot phase call registerExecutionExecutorFactory (executionBrokerPhases, src/lib/boot/phases/execution-broker.ts). Every other outcome registers nothing and leaves the fail-closed posture untouched. The health surface re-runs the same handshake on load (memoized, single-flighted) rather than reporting a cached boot verdict.
resolveExecutionEnvironmentReadiness then resolves one of three states, and never invents a fourth:
disabled— the instance never opted into the plane.unavailable— opted in, but no provenance key, or no executor factory (flag off, modedisabled/remote, or the handshake did not complete).ready— opted in, provenance key present, and an executor binding available.
The consequence is the run seam’s matrix (resolveRunExecutionBinding): an agent that declares no environment runs on L0 in every state; an agent that declares one runs its built layer only in ready, and is refused — audited, never silently downgraded to L0 — in every other state, including when its declaration is invalid.
Skills execute on the plane
Section titled “Skills execute on the plane”Skill delivery is unchanged by the plane; what moved is skill execution. Delivery is per-provider, and it is worth being exact rather than sweeping:
- OpenAI — the catalog exposes ids and descriptions; the model reads a skill’s files lazily, so content stays out of the request.
- Anthropic — skills are referenced as pre-synced Custom Skills through the provider’s container-skills surface; content likewise stays out of the request, and this mechanism is untouched by the plane.
- Gemini — skill content is read and inlined into the system prompt. On this path skill content is in the request by design; “content stays out of the prompt” is an OpenAI/Anthropic property, not a platform-wide one.
Staging into the sandbox is the OpenAI shell-delivery path. When such a request is execution-authorized, the catalog-resolved snapshots carried on its shell tool are staged read-only under /skills/<slug>. Staging carries no host mounts: content arrives as data (bytes plus SHA-256 digests), stageSkillsVolume recomputes and compares every file’s digest before it is written, and paths must be strictly relative and traversal-free. The read-only mount is a separate control in a separate symbol: the run profile mounts the populated volume with :/skills:ro, so the sandbox cannot modify a snapshot. A digest mismatch, an unsafe path, or a Docker failure refuses the staging outright.
Provider translation follows a singular-native-shell rule, so a model is never handed two competing shells:
- Skills + execution on a shell-capable OpenAI model ⇒ exactly one native shell bound to the session, with the staged skills listed on it.
- Skills, execution disabled ⇒ a restricted
skill_file_readnamed function tool — a catalog-scoped read-only reader, never a privileged shell. - A model that rejects the native shell ⇒ both surfaces become named function tools.
- Anthropic and Gemini use a named function tool for execution; their skill-delivery mechanics are unchanged.
A skill that genuinely needs to run scripts declares requiresExecution. Attaching one to an agent while the instance rollout flag is off returns an advisory warning that the skill’s shell/script steps will not run — it is advisory only, never a gate, and it keys on the instance flag, not on the agent’s own execution posture or on executor readiness.
Enforced-claims table
Section titled “Enforced-claims table”Every statement above, mapped to the symbol that enforces it, pinned at commit 6285e014.
| Enforced statement | Enforcing symbol | Pinned source |
|---|---|---|
| A sandbox has no host bind mounts, asserted on every dispatch | assertNoBindMounts, called by LocalDevSandboxWorker.runCommand |
l0-profile.ts · call site |
| Non-root fixed UID, read-only rootfs, all caps dropped, no-new-privileges | buildHardenedRunArgs, SANDBOX_RUNTIME_UID |
l0-profile.ts |
| No host environment crosses into the sandbox | sandboxEnvironment |
l0-profile.ts |
An image reference cannot be re-read as a docker run option |
assertSafeImageRef |
l0-profile.ts |
| The workspace is measured after every command; a normally-exited command over quota terminates the job (an unmeasurable workspace counts as within quota) | measureWorkspaceKb, applied in LocalDevSandboxWorker.runCommand |
workspace.ts · worker.ts |
| A caller with no attributable identity is refused the capability | mintExecutionSession, UnidentifiableExecutionCallerError |
session.ts |
| The session rides an HMAC-signed expiring carrier only the broker opens | sealExecutionSession, openSealedSession |
session.ts |
| A sandbox tool never reaches a provider on a non-injected path | stripSandboxExecutionTools |
tool.ts |
| One shared seam mints every surface’s session from the identity its issuer resolved (the issuer authorizes; this does not) | resolveSurfaceExecutionBinding |
surface-execution-session.ts |
none puts the container on no network at all |
resolveEgress (resolves the tier) + buildHardenedRunArgs (emits --network none) |
egress.ts · l0-profile.ts |
The network a gateway tier attaches to is created --internal (no NAT), and an existing same-named network that is NOT internal is refused |
ensureInternalNetwork, called by startLocalGateway |
local-gateway.ts |
| A gateway-requiring tier with no gateway refuses the job | resolveEgress raises EgressGatewayRequiredError; ExecutionBroker.exec refuses on it |
egress.ts · broker.ts |
| Per-job policy is registered before the sandbox runs; a failed registration refuses the command | registerJobEgress (the POST) + ExecutionBroker.exec (the ordering and the refusal) |
egress.ts · broker.ts |
| The gateway denies by default (unattributed/unregistered 407; allowlist and quota 403) | decide |
egress-gateway.cjs |
| Every destination is resolved and IP-pinned; the listed protected ranges are refused, even in allow-all | resolvePinnedAddress, isProtectedAddress |
egress-gateway.cjs |
| Gateway allowlist matching is exact or dot-suffix only | hostAllowed (the gateway’s own matcher) |
egress-gateway.cjs |
| An audit row can only ever carry the listed identity/outcome fields and ten metadata keys | toAuthzAuditEventInput |
broker.ts |
| The executed command and the per-destination egress list never reach a durable row | toAuthzAuditEventInput, pinned negatively |
audit-metadata-only.test.ts |
| The audit write is best-effort: a sink failure never propagates, and repeated denied events are cooldown-suppressed | createExecutionAuditSink over logAuditEvent |
execution-broker-construct.ts · audit.ts |
| The org-admin audit read projects a named column + metadata allowlist and scopes to the supplied org | readExecutionAuditRows (the caller is the authorization gate) |
execution-audit-read.ts |
| The executor registers only past a completed broker↔worker handshake | runBrokerHandshake, executionBrokerPhases |
execution-broker-construct.ts · execution-broker.ts |
Readiness is tri-state and fails closed to unavailable |
resolveExecutionEnvironmentReadiness |
environment-execution-service.ts |
| A declared environment is refused, never silently downgraded to L0 | resolveRunExecutionBinding |
resolve-run-execution-binding.ts |
| An L1 layer mounts only after its signed provenance re-verifies, by digest | resolveEnvironmentMount, verifyEnvironmentProvenance |
mount.ts · provenance.ts |
A build’s --build-arg names are restricted to the proxy set, and --secret/--ssh are refused |
assertNoCredentialBuildArgs (a name check, not a value check) |
builder.ts |
| The cache key is the full effective build recipe | computeEnvironmentRecipeKey |
recipe.ts |
| One layer per (recipe, partition); references dedup across nullable holders | environmentLayerStoreSchemaQueries |
environment-layer-schema.ts |
| A declaration is parsed fail-closed — one bad entry rejects the declaration | parseExecutionEnvironment |
execution-environment.ts |
| A package-declared (or unreadable) manifest owns the environment and renders read-only; every other case is editable config | resolveAgentEnvironmentAuthority |
execution-config.ts |
| An instance-local declaration on a packaged agent is labelled as such | buildAgentExecutionConfigView (localDeclarationNote) |
agent-execution-config-view.ts |
| Execution off plus a declared environment is refused at authoring and at the run seam | parseAgentExecutionConfigSubmission, resolveRunExecutionBinding |
execution-config.ts |
| Staged skill files are digest-verified before write, and the volume is mounted read-only | stageSkillsVolume (digests) + buildHardenedRunArgs (:/skills:ro) |
staging.ts · l0-profile.ts |
Injection is off unless the rollout flag is exactly on |
isExecutionPlaneRolloutEnabled |
policy.ts |
Tier C — gated behind the rollout flag or a later slice
Section titled “Tier C — gated behind the rollout flag or a later slice”The rollout flag is opt-in, and off by default
Section titled “The rollout flag is opt-in, and off by default”The plane’s activation is a single environment variable, CINATRA_EXECUTION_PLANE_ROLLOUT, and it is opt-in: isExecutionPlaneRolloutEnabled enables the plane only for the exact string on. Unset, empty, off, true, 1, ON, and "on " all leave it off. On an instance that has not set it:
executionBrokerPhases()returns an empty phase list — not a phase that skips, no phase at all — so the boot sequence, the boot-state snapshot and/api/healthare unchanged.- No executor factory is registered, so
resolveExecutionEnvironmentReadinessresolvesdisabled(orunavailableon an instance that opted in without a working handshake), and nosandbox_executetool reaches any provider. - Orchestration behaviour is byte-identical to an install without the plane.
This flag is a rollout gate, not the availability policy. The Tier A availability posture (on for every agent and chat surface, with a per-org/per-agent opt-out) describes what the plane does once an operator turns it on — it is not what a default install does today.
What is wired, and what is not
Section titled “What is wired, and what is not”- Placement modes. The persisted vocabulary is
remote | local-dev | disabled(readExecutionPlaneSettings). Onlylocal-devanddisabledare operable:remoterenders on the settings screen, is not selectable, and the write path refuses it — the remote broker’s service boundary lands with the CLI slice. - Admin surfaces.
/configuration/executionholds the placement/egress settings and a health page that re-runs the handshake and reads back org-scoped audit rows. The per-org/per-agent availability opt-out posture is honored at the injection point, and the per-agent posture is stored on the agent template; the org-level settings surface that resolves it lands later. - L1 declaration sources at the agent-run seam. The declaration surfaces, the fail-closed parser, the immutable version snapshot, the trusted content-addressed builder, the durable layer store, and the per-agent configuration UI are merged, and a resolved declaration does build and mount. What is not fully wired is which declarations that seam can see: the agent-run bridge (
/api/llm-bridge) passes only the live template’s declared environment and the per-agent posture intoresolveRunExecutionBinding— it does not pass the run’s pinned version snapshot, and the seam takes no packaged-manifest input at all. A packaged-manifest or pinned-snapshot declaration therefore still resolves absent on that path and the run proceeds on L0. - The gateway’s read-through package-registry cache is a later slice. Attribution, allowlisting, byte quotas and SSRF defense are enforced today.
- Durable artifact export/import from a sandbox rides the objects/artifacts bridge and lands with that work. Until then,
stdout/stderrare what a model brings back. - L3 named persistent workspaces are deferred to a second phase; stateful workflows round-trip through artifact export/import rather than a shared durable workspace.
The environment model
Section titled “The environment model”Tool persistence is answered by a tiered environment model. The layer vocabulary comes from the environment-layer store and the run profile, not from prose:
| Layer | What it is | Lifetime |
|---|---|---|
| L0 | The platform-owned base sandbox image every run gets. The only image the worker runs commands over. | Platform release |
| L1 | A declared per-agent environment layered on L0 — an immutable, content-addressed image built FROM the digest-pinned L0 base. |
Cached until the retention GC reaps it |
| L2 | The per-run writable workspace volume. | The run |
| L3 | Named persistent workspaces. Deferred to a second phase. | — |
L0 — the base image
Section titled “L0 — the base image”L0 is platform-owned, digest-pinned, and batteries-included. It ships a Python 3.12 base with bash, curl, git, jq, ripgrep, Node.js and npm, pip, and the common coreutils/findutils/grep/sed/unzip tooling, plus the fixed unprivileged runtime user (docker/sandbox/Dockerfile). Popular tools graduate into it over releases. OS-level dependencies enter only here, at build time, with root — a running sandbox has no root path at all. Production resolves the image by digest (resolveL0ImageRef reads CINATRA_SANDBOX_L0_IMAGE; the deployment owns the pin), and the worker records the digest that actually ran in every audit record, so the effective image is attributable even under a mutable local-dev tag.
L1 — declared environments
Section titled “L1 — declared environments”An agent’s runs often depend on a specific tool being present — “this agent does not work without pandoc”. L1 is the answer: an agent declares the packages its runs require once, a trusted builder turns the declaration into an immutable, content-addressed layer, and every later run — and every same-recipe agent — mounts that layer instead of re-installing.
Two authoring surfaces, one internal type. A packaged agent declares cinatra.execution.environment in its manifest; a project agent declares the same thing in its in-app definition, stored on agent_templates.execution_environment (JSON as text, alongside the nullable execution_enabled posture — see migrations/core/core__0085_agent-template-execution-config.mjs). Both normalize through one fail-closed parser, parseExecutionEnvironment, to one canonical spec with three optional package managers:
os— OS-level (Debian/apt) packages, installed only by the trusted builder as root at build time.pip— Python requirement specifiers; registry installs only — no direct-URL, VCS, or local-path forms, because the builder’s egress is registry-allowlisted.npm— npm specifiers, under the same registry-only restriction.
Only an agent manifest may declare an environment. The parser is deliberately fail-closed: an unknown key, a malformed entry, or a declaration root that is present but not an object rejects the whole declaration rather than dropping a package — a silently-dropped dependency would produce a layer missing exactly what the author asked for. The per-manager grammars allowlist a conservative charset (shell metacharacters, option-injection dashes, and path/URL forms are refused at parse, never sanitized later), and per-manager entry counts and lengths are bounded.
Package-declared vs instance-local. Authority is resolved, never merged (resolveAgentEnvironmentAuthority):
| Situation | Authority | What the configuration surface does |
|---|---|---|
| The package manifest declares an environment | manifest |
Renders read-only and names the review path: edit the manifest and publish a new version, riding the extension’s own review and lock choreography. |
| The package manifest could not be read | manifest |
Read-only, and the declaration is reported unknown — never as “declares nothing”, which would render a confident empty recipe for a package that may well declare one. |
| A project agent, or a packaged agent whose manifest declares nothing | config |
Editable in place, admin-gated; the next immutable version snapshot captures it. |
The third row is the instance-local case, and the surface says so plainly: a declaration added to a packaged agent that declares none of its own applies on this instance only, and a future package version that declares its own environment takes over. An operator must never read a local addition as something the package asked for.
Starter templates. Authoring a recipe from an empty box means guessing package names, so the configuration surface offers six named starting points — Empty, Document conversion (pandoc, poppler-utils, texlive-xetex), Data analysis (matplotlib, numpy, openpyxl, pandas), Web fetching (beautifulsoup4, httpx, lxml), Media processing (ffmpeg, imagemagick), and Node tooling (prettier, typescript). They carry no privilege and no special cache treatment: each is an ordinary declaration, and a saved one goes through exactly the same parseExecutionEnvironment call as a hand-typed entry. A test-time gate (assertStarterTemplatesValid) additionally holds every preset to that parser and to canonical form, so a preset can never hash differently from the same recipe typed by hand; it is a test, not a runtime check. Picking one prefills the editor; nothing is stored until a human saves.
Execution off plus a declared environment is a contradiction, and it is refused. The per-agent posture is three-valued — inherit (the stored column is NULL), on, off. An agent explicitly switched off cannot also declare an environment: parseAgentExecutionConfigSubmission refuses the save with an explanation rather than storing a recipe that could only be silently ignored, and resolveRunExecutionBinding carries the defence-in-depth arm — a run in that state is refused with environment_agent_disabled, never run without the packages it declared it cannot work without. An unrecognized posture value is refused too, never coerced to the permissive default.
Content-addressed identity. Canonicalization is identity-bearing — the spec is trimmed, deduped, and sorted — so two agents that declare the same packages in a different order share one cache entry. The cache key is the full effective build recipe: the canonical spec plus the L0 base digest, the builder version, the platform/arch, the resolved lock-manifest digests, and the build policy (computeEnvironmentRecipeKey). Any input change is a different layer, and same-recipe agents single-flight one build.
The durable layer store (src/lib/execution/environment-layer-schema.ts) makes the cache cross-process: environment_layers holds one row per (recipe_key, partition) with the image ref, image digest, signed provenance and last-used timestamp; environment_layer_references holds org-scoped references from the packages, templates and versions that hold a recipe. partition is instance (the shared sentinel) or org:<id> — the same recipe can exist as an instance-shared layer and as one or more org-partitioned layers for private packages, and one org’s write can never clobber another’s.
Immutable version snapshots. For a project agent the resolved spec is captured into the agent template’s immutable version snapshot in canonical form. A run pinned to a version resolves its environment exclusively from that snapshot, never the live template row, so editing the template cannot swap the environment under a pinned run; a new version is a new recipe and a new cache key.
The trusted builder. It renders an image FROM the digest-pinned L0 base, uses root only at build time, and pins the fixed non-root runtime UID back on the final layer. Its egress is registry-allowlisted through the same attributing gateway the sandbox uses, on a verified internal network — a build with no gateway fails closed. A build’s arguments are restricted to proxy plumbing. assertNoCredentialBuildArgs refuses any --build-arg whose name is outside the enumerated proxy set, and refuses --secret/--ssh forwarding outright. Read that as what it is — a name check, not a value check. It guarantees that nothing but proxy variables is passed; it cannot tell whether a credential was embedded inside an allowed proxy URL, so keeping one out of that value is the caller’s responsibility. Every layer carries signed provenance — an HMAC over the recipe, image digest, partition and builder identity — that is re-verified before the layer is mounted, and the mount contract is that signed digest, never a mutable tag.
Partitions, references, GC. Layers are instance-shared by default; a recipe that installs a private-scoped package is org-partitioned with an instance-level share toggle. References are org-scoped: archiving an org drops only that org’s references, never a shared layer (a restore is a cache hit or a lazy rebuild), and retention GC reaps only layers with no references left.
Promotion. When an agent repeatedly installs the same tool ad hoc — “pandoc on 6 of the last 10 runs” — the configuration surface renders the observation as a promotion candidate and offers one-click Add, which prefills the editor. The human still saves: an environment change is never silent and never model-driven, and an accepted promotion rides the agent’s existing review path, landing as a new version → new recipe → new cache key. With the plane off there are no runs and therefore no observations, and the affordance renders an honest empty state rather than an invented suggestion.
L2 — the per-run workspace
Section titled “L2 — the per-run workspace”The L2 workspace is a named Docker volume mounted read-write at /workspace — the only writable persistence a sandbox has. It follows the run: keyed on the run id when the session carries one (so pip install --user and npm install -g persist across steps and turns within an agent run), and on the broker job id otherwise (chat and deterministic tasks). A new key is a fresh, empty volume: a workspace never leaks into another agent or a future run. This is also how a skill-less assistant installs and uses a tool ad hoc within one conversation — L1 is an optimization, never a precondition.
Workspaces are labeled for retention GC. Hard removal of a run puts its workspace on immediate GC via the broker’s teardown hook; an in-flight run is not interrupted by an extension being archived, because the sandbox follows the run’s lifecycle, not the extension’s.
Where to go next
Section titled “Where to go next”- The platform security model, including the sandboxing summary and threat patterns: Security
- Authoring skill extensions: Authoring skill extensions
- Skills lifecycle and catalog: Skills lifecycle
Docs content licensed under CC-BY-4.0; embedded code snippets under Apache-2.0.