Lambda
The default sandbox provider. It runs a single uniform container image with real
bash, native python3, Node 22, and uv all on PATH — there is no emulated shell, no
WASM Python, and no separate Python function. SST deploys the image as four Lambda
functions plus one AWS S3 Files filesystem backed by the workspace S3 bucket.
4-function topology
The same image is deployed across two axes; the harness auto-selects one per run:
network allow-all | network deny-all / restricted | |
|---|---|---|
| workspace mounted | SandboxMountNet (VPC + NAT + S3 mount) | SandboxMountNoNet (VPC, S3 mount) |
| no workspace | SandboxNoMountNet (no VPC, fastest) | SandboxNoMountNoNet (VPC, no mount) |
- The mount axis comes from whether the run has a workspace namespace.
- The network axis comes from
sandbox.network.mode. - All four are ARM64, 512 MB (minimum for the S3 mount), 5-minute timeout, pulled from the
latest-arm64ECR image tag.
harness-processing invokes them by function name via four env vars
(SANDBOX_FN_{MOUNT,NOMOUNT}_{NET,NONET}) and never via a public Function URL.
Image & ECR
Lambda can only pull a private ECR image in the function's own region — public
ECR and cross-region are rejected. So the repo is region-scoped and owned by this app
(sst.config.ts creates aws.ecr.Repository beeblast-lambda-sandbox-<account>-<region>),
not the infra repo. The latest-arm64 image is built and pushed by the
lambda-sandbox custom image CI.
sst deploy ──creates──▶ ECR repo (per region) ◀──pushes── lambda-sandbox CI
│
imageUri ▼
4 sandbox Lambda functions
- Multi-region: every region you deploy to needs its own repo + pushed image. The CI
mirrors the image to each region in
ECR_REGIONSand skips (with a warning) any region whose repo doesn't exist yet. - Bootstrap a region (two passes), gated by
SANDBOX_IMAGE_READY: the 4 functions are created only when this flag istrue. Without it the firstsst deploycreates the empty repo and succeeds (functions skipped, deploy not blocked) → lambda-sanbdox CI mirrors the image into the now-existing repo → re-deploy with the flagtrueand the functions create. Harness env/IAM always carry the deterministic function names/ARNs, so flipping the flag is the only change needed on the second pass. - The flag is per-stage in
deploy.yaml, becausedevandproductiondeploy to different regions that bootstrap independently. The resolve step picksSANDBOX_IMAGE_READY_DEV(falls back to the legacy repo-wideSANDBOX_IMAGE_READY) fordevandSANDBOX_IMAGE_READY_PRODUCTION(defaultfalse) forproduction. So a region whose image isn't mirrored yet can keep its flagfalsewhile the other staystrue— setting one global flagtruewould otherwise break the unbootstrapped region's deploy.
Config
// POST /accounts/me/sandboxes
{
"name": "default",
"config": {
"provider": "lambda",
"network": { "mode": "allow-all" },
"permissionMode": "ask",
"envVars": { "MY_API_BASE": "https://api.example.com" }
}
}
Function names are service-managed. Account sandbox config cannot override them.
Environment variables
config.envVars is a flat object of string key/value pairs injected into every run:
| Runtime | How the value is read |
|---|---|
| Shell | echo $MY_API_BASE |
| Node | process.env.MY_API_BASE |
| Python | os.environ["MY_API_BASE"] |
Rules:
- The child env is
env_clear()ed first — the host Lambda'sprocess.env(including AWS credentials) is never inherited. Only the keys you declare reach the run. - Reserved runtime vars (
PATH,HOME,TMPDIR, ...) are set by the image and win. - Values must be strings. Sandbox config (and therefore
envVars) is encrypted at rest.
Runtimes
bash, python3, and node are all real binaries on PATH. The harness tools all compile
to bash, so you can run programs directly:
python3 script.py # native CPython, full stdlib
node app.js # Node 22
uv run tool.py # uv is available
config.runtimes is a best-effort allow-list. The bash tool rejects obvious
disallowed runtime invocations and surfaces the allowed list in its description; a
general shell VM cannot make this a hard isolation boundary.
Workspace mount
AWS S3 Files is mounted into the mounted functions at /mnt/workspaces, rooted at the
sandbox/ access-point prefix (load-bearing — keep in sync with WORKSPACE_MOUNT_PREFIX).
A workspace run is rooted at /mnt/workspaces/<namespace>, where the namespace is derived
from accountId:workspaceId. The no-mount functions run statelessly in /tmp.
What the model sees
For workspace-backed runs, the model should see a normal project directory. The sandbox
image starts bash in the selected workspace directory, so relative paths are enough:
pwd # current workspace directory
ls # files in this workspace
python3 script.py
Provider mount paths are implementation details for debugging.
Skill bundles are still staged into the workspace namespace at /.claude/skills/<name> by
load_skill (S3 server-side copy), so the agent can read and run skill scripts without a
second mount.
Read-only workspaces: mount (default) vs S3-direct
A workspace with no effective sandbox is read-only — write/edit/grep/bash
are not exposed; only read/glob. How those reads are served depends on the existing
per-workspace sandbox reference value (no new config — it reuses the null opt-out):
| Workspace ref | Read path | Sees committed writes | Cost |
|---|---|---|---|
| sandbox omitted / inherited, no effective sandbox (default) | service-managed read-only mount (SandboxMountNoNet) | immediately (hop 1) | a mounted Lambda invocation |
sandbox: null (explicit opt-out) | S3-direct — reads sandbox/<namespace>/ keys via the S3 API | only after the S3 export (hop 2, ≥60s) | no VPC/mount/Lambda cold start (cheapest) |
// agent config — reader on a shared workspace
"workspaces": [
{ "name": "shared", "workspaceId": "ws_…" }, // default: read-only mount (fresh reads)
{ "name": "shared", "workspaceId": "ws_…", "sandbox": null } // opt out: read straight from S3 (cheaper, lagged)
]
The default mount path reads the same filesystem a writer mounts, so a reader sees a
writer's committed file right away (subject only to NFS close-to-open consistency, seconds).
The null opt-out trades that freshness for skipping the mount entirely — use it for reads
that tolerate the S3-export lag and want the lowest cost.
Write durability & S3 sync
The mount is AWS S3 Files (NFS over S3), which has two distinct flush boundaries. Getting a write to a plain S3 object is a two-hop journey, and each hop is asynchronous by default:
tool write ──fsync──▶ S3 Files server ──async export (~60s idle)──▶ plain S3 object
(hop 1: durability) (hop 2: S3 visibility)
- Hop 1 — durability across cold containers. Closing a file does not force an NFS
COMMIT; the data sits in the container's page cache. A Lambda is frozen the instant the
handler returns, so a write that was never committed is silently lost on the next cold
container. The
writeandedittools thereforefsyncthe file (sync <file>/fs.fsyncSync) before returning — their writes are durable across cold mounts. - Hop 2 — visibility to the S3-direct opt-out path. S3 Files exports committed data to
plain S3 objects only asynchronously, roughly after 60s of write inactivity, and only
if S3 Versioning is enabled on the bucket. This only matters for the
sandbox: nullopt-out (which reads S3 objects directly): it sees a freshly written file only after that export. The default read-only path reads through the mount (hop 1), so it — like any mounted sandbox — sees committed writes immediately and never waits on hop 2.
bash writes are flushed automatically
The sandbox runtime calls sync(2) after every persistent run, flushing all dirty
page-cache writes — including raw bash redirection (echo > f, cmd > out, >>,
in-script writes) — to the S3 Files mount before the Lambda freezes. So bash-written
files are durable across the next cold container without any explicit flush, just like
the dedicated write/edit tools (which also fsync per file). No sync report.txt
incantation is needed in agent scripts anymore.
Note — this is a stopgap. The per-run
sync(2)only prevents silent data loss on cold containers. It does not solve cross-provider durability, the hop-2 visibility lag, or multi-agent write conflicts on a shared workspace. The intended final solution is a unified shared-data layer (an Archil-style elastic POSIX filesystem mountable across sandboxes) that owns durability and conflict resolution in one place, replacing the per-provider mount/flush mechanics. Tracked in #64.
Known limitations
- S3-direct opt-out visibility lag. Because of hop 2, a write is not immediately visible
to a reader using the
sandbox: nullopt-out. Expect a delay (≥60s) and verify the bucket has versioning on. The default read-only mount and any mounted sandbox are unaffected. - No mount-level
syncoption. Lambda's managedfileSystemConfigmount does not expose NFS mount options, so durability is enforced by the runtime (sync(2)after each persistent run) and per-write in the dedicated tools, rather than globally at mount time.
Security
- functions have no public Function URLs and need no account-management permissions
- child processes run with no AWS credentials (
env_clear()) - only the mounted functions can reach the workspace bucket (bucket policy principals)
- internet access is gated by
sandbox.network.modeselecting the on/off function - the Lambda mount is rooted at the shared
sandbox/access-point prefix, so arbitrarybashshould be treated as privileged workspace compute rather than a hard cross-workspace filesystem isolation boundary; dedicated file tools still reject path traversal, but full isolation requires an image/infra-level chroot or per-namespace mount