Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
51eb2c9
fix: detect early blfs exit immediately instead of waiting 30s
drappier-charles Jun 13, 2026
f4f7dc9
feat: add Sentry error reporting and parallelize startup sequence
Joffref Jun 17, 2026
74659e6
feat: add startup telemetry banner and --disable-sentry flag
Joffref Jun 17, 2026
fcb16a9
refactor: rename flag to --disable-telemetry and use 'telemetry' wording
Joffref Jun 17, 2026
79d05a3
refactor: use official sentrygin middleware and add build-time enviro…
Joffref Jun 17, 2026
b334031
Fix CI/CD
Joffref Jun 18, 2026
1c753cd
rename SENTRY_ENABLED to TELEMETRY_ENABLED for consistent telemetry t…
Joffref Jun 18, 2026
a622591
add tracing (1%), custom startup spans, global tags, release info, an…
Joffref Jun 18, 2026
7c6eb8b
fix: use parent startup transaction for coherent tracing, fix Version…
Joffref Jun 18, 2026
2dae97f
increase trace sampling to 100% in dev, remove unused StartSpan helper
Joffref Jun 18, 2026
ce5fe15
remove SendDefaultPII and BeforeSend PII logic — never collect PII
Joffref Jun 18, 2026
9846281
Merge branch 'joffref/devin/sentry-integration-startup-cleanup' into …
Joffref Jun 18, 2026
96bdc7f
add custom metrics API (counter, gauge, distribution) and startup dur…
Joffref Jun 18, 2026
14bc512
strip all request context and user data from Sentry events and transa…
Joffref Jun 18, 2026
85bb39f
Merge branch 'joffref/devin/sentry-integration-startup-cleanup' into …
Joffref Jun 18, 2026
fdb9e4d
Merge tag 'main' of https://github.com/blaxel-ai/sandbox into cdrappi…
drappier-charles Jun 23, 2026
53eed80
Merge branch 'cdrappier/devin/drive-mount-fast-fail' of https://githu…
drappier-charles Jun 23, 2026
1870b77
feat: enhance drive mounting options with readDirectIO and evictPageC…
Joffref Jun 29, 2026
f090d8b
Merge branch 'majoffre/add-blfs-params' into develop
Joffref Jun 29, 2026
c87c9dc
fix(ci): only treat hub directories as sandboxes (#238)
cploujoux Jun 30, 2026
dc1fe44
fix(hub): make dev-server templates listen dual-stack (IPv4+IPv6)
Joffref Jul 4, 2026
b315bb2
Merge branch 'mjoffre/devin/hub-templates-dualstack' into develop
Joffref Jul 4, 2026
b47c041
fix(hub): normalize bare IPv6 Host in astro dev server to avoid Inval…
Joffref Jul 4, 2026
ee9f26f
Merge branch 'mjoffre/devin/hub-templates-dualstack' into develop
Joffref Jul 5, 2026
1654207
Add a dedicated onboarder-nextjs Sandbox Lab template (#245) (#246)
SystemSculpt Jul 8, 2026
641bea9
Sync main into develop: hostname (none) fix (#248)
SystemSculpt Jul 8, 2026
afee543
fix(proxy): inject identity token per request instead of baking it in…
Joffref Jul 24, 2026
913b1ad
fix(proxy): enforce loopback bypass in NO_PROXY
Joffref Jul 24, 2026
6907142
fix(proxy): move local proxy to the dynamic port range
Joffref Jul 24, 2026
30e8d32
fix(proxy): serve dual-stack loopback and reload the token on 407
Joffref Jul 24, 2026
541e08d
Merge branch 'mjoffre/devin/proxy-token-loopback-shim' into develop
Joffref Jul 24, 2026
e45194a
fix(drive): serialize mounts per path to prevent duplicate blfs proce…
Joffref Jul 30, 2026
036b343
Merge branch 'mjoffre/devin/prevent-double-drive-mount' into develop
Joffref Jul 30, 2026
8e41bb8
fix(sandbox): make waitForPorts mean 'serving on a routable interface…
drappier-charles Jul 30, 2026
9480d8d
sandbox-api: run user workloads as an unprivileged identity
drappier-charles Aug 2, 2026
5bbb879
sandbox-api: resolve the workload identity at startup
drappier-charles Aug 2, 2026
782cbd9
identity: verify setfsgid succeeded, like setfsuid
drappier-charles Aug 2, 2026
f292345
sandbox-api: accept the workload identity as --user, add entrypoint e…
drappier-charles Aug 2, 2026
7c6be14
Merge branch 'cdrappier/devin/sandbox-nonroot-exec' of github.com:bla…
drappier-charles Aug 2, 2026
2479065
fix(identity): add Linux build constraint for setfsuid/setfsgid syscalls
mendral-app[bot] Aug 2, 2026
775c4a4
fix(drive): only chown mount directories we create, not pre-existing …
mendral-app[bot] Aug 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ on:
- nextjs
- node
- node-slim
- nonroot
- onboarder-nextjs
- playwright-chromium
- playwright-firefox
Expand Down
11 changes: 11 additions & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,17 @@ services:
- "8080:8080"
- "3010:3010"

nonroot:
platform: linux/amd64
build:
context: .
dockerfile: hub/nonroot/Dockerfile
env_file:
- .env
ports:
- "8080:8080"
- "3010:3010"

app-runner:
platform: linux/amd64
build:
Expand Down
39 changes: 39 additions & 0 deletions hub/nonroot/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
ARG SANDBOX_VERSION=latest
FROM ghcr.io/blaxel-ai/sandbox:${SANDBOX_VERSION} AS sandbox-api

FROM node:24-alpine3.21

RUN apk update && apk add --no-cache \
bash \
git \
python3 \
py3-pip \
&& rm -rf /var/cache/apk/*

# The unprivileged identity every user-launched process runs as. There is
# deliberately no USER directive: the runtime would then setuid PID 1, which is
# sandbox-api itself, and it would lose the privileges it needs to mount agent
# drives (FUSE), bring up the egress tunnel and merge the CA bundle.
RUN adduser -D -u 10001 -h /blaxel app

WORKDIR /blaxel

COPY --from=sandbox-api /sandbox-api /usr/local/bin/sandbox-api
COPY hub/nonroot/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh

EXPOSE 8080

ENV HOME=/blaxel

# Consumed by the entrypoint, which passes it to sandbox-api as --user. Setting
# the variable alone is enough: sandbox-api reads it directly when no flag is
# given. The entrypoint exists so the image can also do root-only preparation
# (chown, mkdir, mounts) before the workload identity takes effect.
ENV BL_SANDBOX_USER=app

# Processes, terminals, codegen and the startup command run as that user, and
# the filesystem API enforces its permissions. Calling the API from inside a
# sandbox process therefore grants nothing extra: it hands back the same
# identity.
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
20 changes: 20 additions & 0 deletions hub/nonroot/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#!/bin/sh
# Entrypoint for a sandbox whose workload runs unprivileged.
#
# It runs as root (PID 1), does the root-only preparation an image needs, and
# then hands the workload identity to sandbox-api, which keeps its own
# privileges (drive mounts, WireGuard, CA bundle, keep-alive) but runs every
# process, terminal and filesystem operation as that user.
#
# The image must NOT have a USER directive: that would de-privilege PID 1 —
# sandbox-api itself — and break drive mounting.
set -eu

# Docker USER syntax: "app", "10001", "app:app", "10001:10001".
SANDBOX_USER="${BL_SANDBOX_USER:-app}"

# Root-only preparation goes here, before privileges are handed over.
# Anything the workload has to write to must belong to it.
chown -R "$SANDBOX_USER" "${HOME:-/blaxel}" 2>/dev/null || true

exec /usr/local/bin/sandbox-api --user "$SANDBOX_USER" "$@"
20 changes: 20 additions & 0 deletions hub/nonroot/template.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "nonroot",
"displayName": "Non-root",
"categories": ["backend"],
"description": "Sandbox API running privileged, user processes running unprivileged.",
"longDescription": "Reference image for the unprivileged execution model: the sandbox API keeps the privileges it needs for infrastructure work (agent drive FUSE mounts, egress tunnel, CA bundle, keep-alive), while every process, terminal and filesystem operation the user triggers runs as the unprivileged 'app' user configured through BL_SANDBOX_USER. Processes cannot regain privileges by calling the sandbox API back, because the API applies the same identity to anything it starts on their behalf.",
"url": "https://github.com/blaxel-ai/sandbox",
"icon": "https://blaxel.ai/logo.png",
"memory": 2048,
"ports": [
{
"name": "sandbox-api",
"target": 8080,
"protocol": "HTTP"
}
],
"enterprise": false,
"coming_soon": false,
"hidden": true
}
81 changes: 81 additions & 0 deletions sandbox-api/docs/UNPRIVILEGED_EXECUTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Unprivileged execution (`BL_SANDBOX_USER`)

The sandbox API is the image entrypoint, and it needs privileges: agent drive
mounts are FUSE mounts (`CAP_SYS_ADMIN`), the egress tunnel is WireGuard
(`CAP_NET_ADMIN`), the MITM CA is merged into the system trust store, keep-alive
toggles scale-to-zero, and port probing inspects other processes.

A Docker `USER` directive cannot express that split: the runtime applies it to
PID 1, which de-privileges the API itself — that is why images built with
`bl deploy --experimental` lose drive mounting.

`BL_SANDBOX_USER` expresses it instead. The API stays privileged; everything it
does *on behalf of the user* is dropped to an unprivileged identity.

## Enabling it

```dockerfile
RUN adduser -D -u 10001 -h /blaxel app

ENV BL_SANDBOX_USER=app # also accepts "10001", "app:app", "10001:10001"
```

Equivalently, `sandbox-api --user app` — the flag wins over the environment.
Use an entrypoint when the image also needs root-only preparation before the
workload identity applies:

```sh
#!/bin/sh
set -eu
SANDBOX_USER="${BL_SANDBOX_USER:-app}"
chown -R "$SANDBOX_USER" "${HOME:-/blaxel}"
exec /usr/local/bin/sandbox-api --user "$SANDBOX_USER" "$@"
```

Do **not** add a `USER` directive: that is the mechanism this replaces.
`hub/nonroot/` is a working example of both halves.

If the value cannot be resolved, or resolves to uid 0, the API refuses to start.
Failing open would hand every workload the privileges the feature exists to
remove.

## What runs as the workload user

| Surface | Mechanism |
|---|---|
| `POST /process`, `/process/{id}/exec`, restarts, MCP `processExecute`, codegen | `SysProcAttr.Credential` (uid, gid, supplementary groups) |
| `/ws/terminal` | same credential, plus the PTY slave is chowned to the user before the shell starts |
| `-c/--command` startup command | same credential |
| every `/filesystem` operation, including multipart completion | `setfsuid(2)`/`setfsgid(2)` around the operation |

`HOME`, `USER` and `LOGNAME` are rewritten to match the identity in all spawned
environments.

## What stays privileged

Drive mounts, WireGuard, CA bundle, keep-alive, port/network inspection,
process supervision and log files. So that a mounted drive is still usable:

- `-map.uid` / `-map.gid` default to the workload uid/gid (drive content is
owned by filer uid 0), overridable per request or with
`BLFS_UID_MAP`/`BLFS_GID_MAP`;
- the mount point is chowned to the workload user when it is created.

## No escalation by calling the API back

A process inside the sandbox can reach the API, and it may hold a valid token.
That grants it nothing extra: every execution surface applies the same
identity — there is no "run as root" parameter — and the filesystem endpoints
are checked by the kernel against the workload user, so the classic escalation
(overwrite a root-owned binary such as `blfs` or `sandbox-api`, wait for a
privileged component to run it) fails with `EACCES`.

Two things are worth stating plainly:

- **The microVM remains the security boundary.** This model contains what a
compromised workload process can do inside the VM; it is not a substitute for
the VM isolation, and API authentication still gates everything.
- **Supplementary groups are not applied to filesystem operations.**
`setfsgid(2)` covers the primary group only, so access granted exclusively
through a secondary group is denied inside `/filesystem` while it is allowed
for spawned processes (which do get the full group list).
9 changes: 9 additions & 0 deletions sandbox-api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/blaxel-ai/sandbox-api/src/handler"
"github.com/blaxel-ai/sandbox-api/src/handler/process"
"github.com/blaxel-ai/sandbox-api/src/lib/blaxel"
"github.com/blaxel-ai/sandbox-api/src/lib/identity"
"github.com/blaxel-ai/sandbox-api/src/lib/networking"
"github.com/blaxel-ai/sandbox-api/src/lib/proxy"
"github.com/blaxel-ai/sandbox-api/src/lib/sentrylib"
Expand Down Expand Up @@ -51,8 +52,14 @@ func main() {
command := flag.String("command", "", "Command to execute")
shortCommand := flag.String("c", "", "Command to execute (shorthand)")
disableTelemetry := flag.Bool("disable-telemetry", false, "Disable anonymous error reporting")
workloadUser := flag.String("user", "", "Run processes, terminals and filesystem operations as this user, in Docker USER syntax (also settable with "+identity.EnvUser+")")
flag.Parse()

// Resolve the workload identity before anything can spawn a process, so a
// misconfigured user fails at boot instead of at first exec.
identity.SetSpec(*workloadUser)
identity.Get()

sentrylib.Version = handler.Version
sentryFlush := sentrylib.Init(*disableTelemetry)
defer sentryFlush()
Expand Down Expand Up @@ -239,6 +246,8 @@ func startBackgroundCommand(ctx context.Context, command string) {
cmd.Stdout = logrus.StandardLogger().Out
cmd.Stderr = logrus.StandardLogger().Out
cmd.Dir = "/"
cmd.Env = identity.Get().DecorateEnv(os.Environ())
cmd.SysProcAttr = &syscall.SysProcAttr{Credential: identity.Get().Credential()}

// Start the command in a goroutine so it doesn't block the server
go func() {
Expand Down
37 changes: 32 additions & 5 deletions sandbox-api/src/handler/drive/mount.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"syscall"
"time"

"github.com/blaxel-ai/sandbox-api/src/lib/identity"
"github.com/sirupsen/logrus"
)

Expand Down Expand Up @@ -72,13 +73,20 @@ func validateLocalID(value, name string) error {

// resolveMapping returns the effective local UID/GID value.
// Priority: request parameter > environment variable > empty (no mapping).
func resolveMapping(reqValue, envKey, name string) (string, error) {
func resolveMapping(reqValue, envKey, name string, workloadID int) (string, error) {
value := reqValue
source := "request"
if value == "" {
value = os.Getenv(envKey)
source = "env"
}
// Drive content belongs to filer uid/gid 0. Mapping it onto the workload
// identity by default is what makes the mount writable by processes, which
// no longer run as root.
if value == "" && workloadID > 0 {
value = strconv.Itoa(workloadID)
source = "workload identity"
}
if value == "" {
return "", nil
}
Expand Down Expand Up @@ -123,12 +131,16 @@ func MountDrive(driveName, mountPath, drivePath string, readOnly bool, uidMap, g
lock.Lock()
defer lock.Unlock()

// Resolve UID/GID mappings (request param > env var > none).
effectiveUidMap, err := resolveMapping(uidMap, "BLFS_UID_MAP", "uidMap")
// Resolve UID/GID mappings (request param > env var > workload identity > none).
workloadUid, workloadGid := -1, -1
if id := identity.Get(); id != nil {
workloadUid, workloadGid = id.Uid, id.Gid
}
effectiveUidMap, err := resolveMapping(uidMap, "BLFS_UID_MAP", "uidMap", workloadUid)
if err != nil {
return "", "", fmt.Errorf("invalid uidMap: %w", err)
}
effectiveGidMap, err := resolveMapping(gidMap, "BLFS_GID_MAP", "gidMap")
effectiveGidMap, err := resolveMapping(gidMap, "BLFS_GID_MAP", "gidMap", workloadGid)
if err != nil {
return "", "", fmt.Errorf("invalid gidMap: %w", err)
}
Expand Down Expand Up @@ -169,10 +181,25 @@ func MountDrive(driveName, mountPath, drivePath string, readOnly bool, uidMap, g
return "", "", fmt.Errorf("failed to get filer address: %w", err)
}

// Create mount directory if it doesn't exist
// Create mount directory if it doesn't exist. It is created by the API
// (root) but handed to the workload user so it is usable while the drive
// is not yet mounted over it. Only chown directories we actually create:
// re-owning a pre-existing system directory (e.g. /usr/local/bin) would be
// a privilege-escalation vector if the subsequent mount fails.
created := false
if _, err := os.Stat(mountPath); os.IsNotExist(err) {
created = true
}
if err := os.MkdirAll(mountPath, 0755); err != nil {
return "", "", fmt.Errorf("failed to create mount directory: %w", err)
}
if created {
if id := identity.Get(); id != nil {
if err := os.Chown(mountPath, id.Uid, id.Gid); err != nil {
logrus.WithError(err).WithField("mount_path", mountPath).Warn("Failed to hand mount point to the workload user")
}
}
}

// Build the filer path: /buckets/{infrastructureId}{drivePath}
filerPath := fmt.Sprintf("/buckets/%s%s", infrastructureId, drivePath)
Expand Down
Loading