12 KiB
The sandbox for helper processes
Last updated: 2026-11-07 · Change: minor (no file changes by any route on Linux: Landlock's truncation right is required, and seccomp refuses what Landlock has no right for; earlier: more is refused on Linux: other processes, System V and POSIX queues, the terminal; earlier: first version: the
sandboxcrate andgridcraft-cli sandbox-check)
The sandbox is the layer GridCraft is to run code it doesn't trust in, such as a workbook's
Python cells: a separate process that confines itself before the code starts. Here only
gridcraft-cli sandbox-check starts it, and Python cells don't run (architecture.md,
Python cells). The crate is crates/sandbox, layer 0.
This page describes what the confinement denies, how it is
checked, and where it is available.
Threat model
The code is a program GridCraft doesn't trust, which may come from anyone. It runs with the person's user account. Without a sandbox it could read and change the person's files, use the network and start other programs. The sandbox's job is that it can do none of these:
- Files: it can read only the paths its policy lists and write no file except
/dev/null. Nor can it change a file any other way: not its length, permissions, owner, extended attributes, times or flags, even one it can read. The exception is a standard stream the parent made a file (see below). The home directory, documents and other workbooks are out of reach. Sotempfilefinds no folder it can use. - Programs: it can execute only the programs its policy lists, and it can't start a new process at all, so even those only replace itself.
- Network: no sockets, no connections and no listeners. The exception is a standard stream the parent made a socket (see below).
- Other processes and the system: no debugging or reading another process, no System V shared
memory, message queues or semaphores, no POSIX message queues, no signals to another process — on Linux not even indirectly, as the
fcntlcommands that have the kernel send one are refused — no typing into a terminal it inherited, no mounts or namespaces, no kernel modules, BPF, keyrings or performance counters. On Linux it also can't change another process's limits, priority or scheduling.
Data reaches the code only through its command-line arguments (which the parent chooses), its
standard input and output (which the parent holds), and the files its policy lets it read. Nothing
else is open: the launcher closes every descriptor it inherited, because the confinement doesn't
revoke what is already open. The three standard streams stay as the parent made them, with the rights
they were opened with: a stream that is a file can be written and truncated, and one that is a
socket can still send, since neither is a file or socket the program opened. A parent gives the
program pipes. sandbox-check passes its own streams on, so its output sent to a file is such a
stream. The
program inherits no environment: the launcher clears it and keeps only the locale and time zone
(LANG, LC_ALL, LC_CTYPE, TZ), with PATH set to /usr/bin:/bin, so tokens and credentials
in the parent's environment don't reach it.
The sandbox doesn't limit time, memory or output size; whoever starts the program owns those limits. The files it may read can be read, and the code can use the CPU while it runs.
How a launch works
- The parent builds a
Policy(read paths and executable paths), creates aCanary(a fresh file in the temporary directory, outside every allowed path) and spawnslaunch_command. That runs the application's own executable again, with--sandbox-launchand the policy. - The application hands its arguments to
gridcraft_sandbox::handle_argsbefore anything else, so a launcher run opens no window and writes no log. - The launcher closes every open descriptor besides standard input, output and error, and refuses
if one stays open. A desktop app's system libraries (Metal's shader cache on macOS) leave files
open without close-on-exec, so these reach every child.
nixholds the system call. - The launcher confines itself (Linux) or runs itself again under
sandbox-execwith the profile (macOS,--sandbox-confined). On macOS the descriptors are closed first because, under a deny-by-default profile, the system's libraries hold a close-on-exec socket of their own, which must not be taken for an inherited one. - The enforcement check. Before the program runs, each of these must be refused by the
system with a permission error:
- reading the canary;
- changing its permissions, to those it has, and truncating it, to the length it has, so nothing changes if the attempt works;
- creating a file next to it, and in the temporary directory;
- a TCP connection to the loopback address;
- starting a process (a copy of itself that would exit at once);
- signal 0 to the launcher's parent, which delivers nothing. Before it confines itself, the launcher checks that the same signal is allowed, and refuses otherwise: a parent that refuses anyone (PID 1, another user's process) would prove nothing. It refuses too if its parent has changed since. An attempt that works, or fails for another reason (a missing file, a refused connection), doesn't count as a denial.
- Only then does the launcher replace itself with the program (
exec), which inherits the confinement. When any step fails, the program never runs: the launcher printsgridcraft-sandbox: refused: <reason>on standard error and exits with code 97.
Linux
The launcher applies two layers, then runs the check.
-
Landlock (ABI 3, which is Linux 6.2 and later, enabled by default on current distributions).
- Filesystem rights: every right Landlock handles is denied except:
- reading files and listing directories under the read paths;
- executing and reading the executable paths;
- reading and writing
/dev/null.
- Required and best-effort rights: the filesystem rights up to ABI 3 are a hard requirement,
so without Landlock, or with an older one, the launch is refused. ABI 3 is the first with a right
for truncation: before it,
truncateandopenwithO_TRUNCempty a file whatever the rules say. seccomp can't take its place, sinceopenat2hides its flags from a filter, and refusingO_TRUNCwould also refuse opening/dev/nullto write. Newer rights are added where the kernel has them:- TCP bind and connect, as a second network layer (ABI 4);
- device ioctls (ABI 5);
- signals and abstract Unix sockets outside the sandbox (ABI 6).
no_new_privsis set, so no program can gain privileges.
- Filesystem rights: every right Landlock handles is denied except:
-
seccomp, three filters.
clone3fails withENOSYS. The C library then usesclone, whose flags the filter can read.- Refused with
EPERM:clonewithoutCLONE_THREAD(a new process; threads still work), plusforkandvfork;socketof every family (a Unix datagram socket can send withoutconnect), andsocketpair,connect,bind,listen,acceptandaccept4;- io_uring (it runs operations outside the filter);
ptrace,process_vm_readv/writev,pidfd_getfd,pidfd_open,pidfd_send_signal,kcmp,get_robust_list,migrate_pages,move_pages;- System V shared memory, message queues and semaphores (
shmget,shmat,shmctl,shmdt,msgget,msgsnd,msgrcv,msgctl,semget,semop,semctl,semtimedop), and POSIX message queues (mq_open,mq_unlink,mq_timedsend,mq_timedreceive,mq_notify,mq_getsetattr), since no path rule covers them; - signals to any process but the program itself, whose process ID
execkeeps (kill,tkill,tgkill,rt_sigqueueinfo,rt_tgsigqueueinfo), so this holds on a kernel without Landlock's signal scope; - changes to any process but the program itself (
prlimit64,setpriority,ioprio_set,sched_setaffinity,sched_setscheduler,sched_setparam,sched_setattr); - what changes a file where Landlock has no right for it, by path or by descriptor: permissions
(
chmod,fchmod,fchmodat,fchmodat2), owner (chown,fchown,lchown,fchownat), extended attributes (setxattr,lsetxattr,fsetxattr,setxattrat,removexattr,lremovexattr,fremovexattr,removexattrat), times (utime,utimes,futimesat,utimensat) and flags (file_setattr); ioctlwithTIOCSTIorTIOCLINUX, which type into a terminal the program inherited, as whensandbox-checkruns a program from a shell;- any
ioctlwhose number says it hands the kernel data, such asFS_IOC_SETFLAGSandFS_IOC_FSSETXATTR, which change a file's flags on a descriptor opened only to read, andFS_IOC_SET_ENCRYPTION_POLICY, whose number says it only reads. Those that ask, such asisatty's, and those that set a descriptor's own flags, such asFIONBIO, still work; - mounts, namespaces and file handles (
mount,umount2,pivot_root,chroot,unshare,setns,open_by_handle_at,name_to_handle_at, the new mount API); bpf,perf_event_open,userfaultfd, keyrings, kernel modules,kexec, swap,reboot,syslog, and port I/O on x86_64.
- x32 calls (x86_64 only) fail with
EPERM. They report the x86_64 architecture value with other numbers, so the rules above wouldn't match them.
Filters are built for x86_64 and aarch64; other architectures are refused. A filter allows what it doesn't name, so a call a later kernel adds is allowed until it is named here; the newest named is
file_setattr(Linux 6.17).Because no socket can be made, Python's
asynciodoesn't work in the sandbox: its event loop needs a local socket pair.
macOS
The launcher runs itself again under sandbox-exec with a profile built from the policy:
(deny default);- the base system profile (
system.sb: the system libraries, the dynamic loader's cache and a few system services a process needs to start); process-execfor the executable paths;file-read*for those and the read paths;- file metadata (for path resolution),
sysctlreads, and writing/dev/null.
There is no rule for process-fork, the network or writing any file but /dev/null, so those stay
denied.
A local socket pair isn't refused on macOS, so asyncio's event loop starts there; it still can't
reach the network.
Paths are quoted as profile strings, and a path that can't be quoted safely is refused.
sandbox-exec is deprecated by Apple; support needs /usr/bin/sandbox-exec, and without it
support() reports the system as unsupported. The check above is what proves the profile holds on a
given machine.
A framework build of Python (Homebrew, python.org) starts its real interpreter,
Python.app/Contents/MacOS/Python, as a new process from bin/python3. Inside the sandbox that
start is refused, so callers run the interpreter itself. Homebrew's Python also loads _ssl and
_sqlite3's libraries from opt and Cellar, so a policy for it needs those folders; Homebrew's
etc and var, which can hold a person's data, stay out of it.
Other systems
Windows, FreeBSD and the web build have no confinement here: support() reports it and
launch_command refuses, so code that needs the sandbox doesn't run there (Python cells show
#BLOCKED!).
Checking a machine
gridcraft-cli sandbox-check
gridcraft-cli sandbox-check --read /usr -- /usr/bin/python3 -I -c "print(1)"
Without a program it runs a confined copy of itself that tries each denied action, plus UDP and
listing the home directory, and two that must still work (a thread, reading an allowed file). It
prints one line per attempt. With a program it runs that program confined. Exit status 0 means
everything is as required, and 1 not, including a launch the launcher refused (for example on a
Linux kernel without Landlock, or older than 6.2). 2 means GridCraft has no sandbox for this kind of system at all
(Windows, FreeBSD, an architecture other than x86_64 or aarch64, macOS without sandbox-exec).