Skip to content

Sandbox and isolation

Every request runs in a sandbox built from Linux namespaces, cgroup v2, a seccomp filter, rlimits and an unprivileged identity. This page describes the sandbox from the point of view of the code running in it. The threat model lists what is verified and how.

faber (API process, holds the API key and the file store)
└── jailer one per request; the faber binary re-executed
├── supervisor one per task
│ ├── init PID 1 of the task's PID namespace
│ └── task PID 2 there
└── supervisor
├── init
└── task
  • The jailer is a fresh, single-threaded process started for each request. It receives only the request, not the service’s memory, environment or open files, and it is killed if the service dies.
  • Each task has a supervisor outside its PID namespace and an init (PID 1) inside it that reaps orphaned descendants. The supervisor starts the task after the init, in the same namespace; anything the task abandons is reparented to the init. The task cannot signal the init, and it cannot see or signal other tasks or host processes.
  • The jailer and supervisors run in a supervisor cgroup of the request with a fixed memory allowance and are the OOM killer’s last choice there.
NamespaceScopeEffect
MountRequest, plus a copy per taskThe task sees a minimal root built for the request, not the image’s filesystem. Its copy only replaces /proc and /sys
NetworkRequestNo interfaces that reach the host or the internet, no DNS configuration
UTS, IPCRequestOwn hostname and System V IPC objects
PIDTaskEach task sees only its own process tree
UserTaskInside: UID/GID 65534 (nobody). Outside: a host UID/GID leased to the request
PathWhat it is
/faberThe workspace: a 128 MiB tmpfs, writable, shared by every task of the request. Tasks start here unless they set working_dir
/tmpA 128 MiB tmpfs, writable, shared by every task of the request. Not captured by the step cache
/usr, /bin, /lib, /lib64Bind-mounted read-only from the image, so the toolchains you installed are available
/etcOnly alternatives, ld.so.cache, passwd and group by default. No resolver configuration, hosts file or service configuration
/procA procfs of the task’s own PID namespace, mounted with subset=pid: no /proc/cmdline, meminfo, stat, /proc/sys
/sysAn empty read-only tmpfs
/devnull, zero, full, random, urandom; the request’s mqueue; links fd, stdin, stdout, stderr, and shm pointing into /tmp

The read-only paths come from SANDBOX_READONLY_PATHS. If a toolchain keeps files elsewhere (the JDK under /etc/java-17-openjdk, for example), add the path there; see the configuration reference.

Both tmpfs mounts are nodev,nosuid. Files written by a task are owned by the task user. /faber and /tmp disappear with the request.

  • Each running request leases one host UID/GID from the range SANDBOX_IDENTITY_BASE .. + SANDBOX_IDENTITY_COUNT (100000-165535 by default). Concurrent requests never share one, so per-user kernel limits are charged to the request that caused them.
  • Tasks have no capabilities, no supplementary groups, and NoNewPrivs set. setuid binaries do not elevate.
  • chroot, mounting, changing the hostname, rewriting the user namespace map and creating new namespaces all fail.

Before exec, every task installs a seccomp filter chosen by its sandbox_profile:

  • A denied syscall (namespace creation, mounts, kernel modules, keyrings, BPF, ptrace and similar) kills the process that made it with SIGSYS, which it cannot catch. When that is the task’s own process, the result has outcome: policy_violation.
  • Under the v2 profiles (the default), a syscall that is neither listed nor denied fails with ENOSYS, as it would on an older kernel.
  • native profiles additionally deny clone, fork and sockets, so the task cannot start threads or processes.

Choosing sandbox profiles explains which to use.

A task’s environment is exactly the env map of the task, plus PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin if you do not set PATH. Nothing else is inherited: not the image’s ENV, not HOME, not the service’s variables.

Some toolchains need a writable home or cache directory. Point them at /tmp or the workspace:

{
"cmd": "go",
"args": ["build", "-o", "main", "main.go"],
"env": { "HOME": "/tmp", "GOCACHE": "/tmp/go-cache", "GOFLAGS": "-buildvcs=false" }
}

Tasks of one request share the workspace, /tmp, the network and IPC namespaces, and the request’s host identity (and so that identity’s per-user kernel limits). They do not share a PID namespace or a cgroup.

Different requests share none of these. A request cannot observe or affect another request’s files, processes or limits.

When the request ends, normally or not (timeout, OOM, client disconnect, service shutdown), every process of the request is killed through its cgroup, and the cgroups, mounts and root directory are removed. On startup, Faber also removes anything a crashed previous instance left behind.