Files
gridcraft/docs/sandbox.md
T

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 sandbox crate and gridcraft-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. So tempfile finds 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 fcntl commands 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

  1. The parent builds a Policy (read paths and executable paths), creates a Canary (a fresh file in the temporary directory, outside every allowed path) and spawns launch_command. That runs the application's own executable again, with --sandbox-launch and the policy.
  2. The application hands its arguments to gridcraft_sandbox::handle_args before anything else, so a launcher run opens no window and writes no log.
  3. 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. nix holds the system call.
  4. The launcher confines itself (Linux) or runs itself again under sandbox-exec with 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.
  5. 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.
  6. 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 prints gridcraft-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, truncate and open with O_TRUNC empty a file whatever the rules say. seccomp can't take its place, since openat2 hides its flags from a filter, and refusing O_TRUNC would also refuse opening /dev/null to 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_privs is set, so no program can gain privileges.
  • seccomp, three filters.

    • clone3 fails with ENOSYS. The C library then uses clone, whose flags the filter can read.
    • Refused with EPERM:
      • clone without CLONE_THREAD (a new process; threads still work), plus fork and vfork;
      • socket of every family (a Unix datagram socket can send without connect), and socketpair, connect, bind, listen, accept and accept4;
      • 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 exec keeps (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);
      • ioctl with TIOCSTI or TIOCLINUX, which type into a terminal the program inherited, as when sandbox-check runs a program from a shell;
      • any ioctl whose number says it hands the kernel data, such as FS_IOC_SETFLAGS and FS_IOC_FSSETXATTR, which change a file's flags on a descriptor opened only to read, and FS_IOC_SET_ENCRYPTION_POLICY, whose number says it only reads. Those that ask, such as isatty's, and those that set a descriptor's own flags, such as FIONBIO, 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 asyncio doesn'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-exec for the executable paths;
  • file-read* for those and the read paths;
  • file metadata (for path resolution), sysctl reads, 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).