mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-11 16:30:53 +00:00
## Stack The Python cells work is split by layer. It merges in this order, each with a merge commit rather than a squash. Nobody can switch Python on before #3481. 1. #3475 depends on `main` 2. #3476 depends on `main` **(this pull request)** 3. #3477 depends on [#3475](https://github.com/storytold/gridcraft/pull/3475) and [#3476](https://github.com/storytold/gridcraft/pull/3476) 4. #3478 depends on [#3477](https://github.com/storytold/gridcraft/pull/3477) 5. #3479 depends on [#3478](https://github.com/storytold/gridcraft/pull/3478) 6. #3480 depends on [#3479](https://github.com/storytold/gridcraft/pull/3479) 7. #3481 depends on [#3480](https://github.com/storytold/gridcraft/pull/3480) ## Summary `crates/sandbox` runs a program that can read only the paths its policy lists, write nothing except `/dev/null`, start no process, and use no network. An application re-executes itself with a launcher flag, closes every inherited descriptor above standard error, applies the confinement, checks that it holds, and only then runs the program. If any of that fails, the program does not run. ## Changes - **`crates/sandbox`:** the policy, the Linux backend (Landlock, and seccomp that denies all sockets, with an x32 filter), the macOS backend (`/usr/bin/sandbox-exec` with a deny-default profile), the probes and a canary. - **`apps/gridcraft`, `apps/gridcraft-cli`:** the launcher flag comes first in `main`, before any window or log. - **`gridcraft-cli sandbox-check`:** says whether this system confines programs, and can run a confined self-test. - **`docs/sandbox.md`:** what is refused and how. - **`nix`:** a new dependency on Linux and macOS, for closing descriptors without `unsafe`. ## Compatibility Windows and FreeBSD have no backend here. There the crate reports that it can't confine, and nothing that needs it runs. On Linux a launch needs Landlock ABI 3, which is Linux 6.2 and later, so an older kernel is treated the same way: this excludes, for example, Debian 12's kernel (6.1) and Ubuntu 22.04's original kernel (5.15). Nothing changes for a process started without the launcher flag. ## Review fixes - **File metadata on Linux.** Landlock has no right for a file's permissions, owner, extended attributes, times or flags, and the seccomp filter allows what it doesn't name, so a confined program could change them on any file its user owns, outside the policy too. The filter now refuses the `chmod`, `chown`, `setxattr`, `removexattr` and timestamp families with their `*at` variants, by path and by descriptor, and any `ioctl` whose number says it hands the kernel data, as `FS_IOC_SETFLAGS` and `FS_IOC_FSSETXATTR` do on a descriptor opened only to read. A filter allows what it doesn't name, so a call a later kernel adds is allowed until it is named; the newest named is `file_setattr` (Linux 6.17). - **Truncation on Linux.** Landlock covers truncation only from ABI 3, so on ABI 1 or 2 `os.truncate(path, 0)` emptied any file its user can write, and `open` with `O_RDONLY | O_TRUNC` emptied one it may read. A launch now requires that right. A filter can't take its place: `openat2` hides its flags from seccomp, and refusing `O_TRUNC` would refuse opening `/dev/null` to write, as `open(os.devnull, "w")` does. - **Standard streams.** They stay open with the rights they were opened with, so a stream that is a file can be written and truncated, and one that is a socket can still send. The page and `launch_command` say so, and that a parent gives the program pipes. - **The enforcement check, again.** It now also tries to change the canary's permissions and to truncate it, asking for what it already has, so nothing changes if one works. - **Other processes on Linux.** On a kernel without Landlock's signal scope, a confined program could signal any process of the same user, and `kill(-1, 9)` would reach them all. The seccomp filter now refuses signals to any process but the program itself, whose ID `exec` keeps; changes to the limits, priority or scheduling of any other process; `pidfd_open`, `pidfd_send_signal`, `kcmp`, `get_robust_list`, `migrate_pages` and `move_pages`; every System V shared memory, message queue and semaphore call and every POSIX message queue call, which no path rule covers; and `syslog`. - **The terminal on Linux.** A program started from a shell could type into its terminal. `TIOCSTI` and `TIOCLINUX` are refused, compared in 32 bits as the kernel reads them, so high bits set on the argument don't get past the rule. - **The enforcement check.** It now also tries signal 0 on the launcher's parent, which delivers nothing, so `sandbox-check` reports a gap rather than assuming none. The launcher tries the same signal before it confines itself and refuses if that fails, and the check refuses if its parent has changed since, since a parent that refuses anyone would prove nothing. A launcher's arguments must include the current executable, which that check starts; otherwise the file rules refused the start and the check proved nothing about new processes. - **A refused launch.** The launcher read its arguments only after closing every inherited descriptor, so a refused launch had already closed them. It reads them first. - **A kernel without Landlock.** The refusal says so in that case too, which the tests that skip on such a machine look for. Only the first ABI's rights being refused, or `ENOSYS` or `EOPNOTSUPP` from creating the ruleset, says so. Any other failure, such as `EINVAL` or too many layers, is reported as what it is and doesn't skip the tests. - **Folders in the exec list on macOS.** A folder got a rule that matches only the folder, so a launcher under it failed to start where the same policy works on Linux. It now covers what is in it, in both rules. - **The sandbox page** names only what this layer has: only `gridcraft-cli sandbox-check` starts the launcher here. ## Testing All 10 steps of `cargo xtask ci` pass on macOS (arm64), and `cargo clippy` passes for this crate for both Linux architectures. The gate on ubuntu-24.04 is the hosted one on this pull request, and it executes the Linux backend through the real binary, including `a_confined_program_leaves_other_processes_alone` and `a_confined_program_changes_no_file`, in which a confined Python tries each refused call on a file inside the policy, one outside it and a descriptor, and the test then compares contents, permissions and modification time. A separate run on the fork, on Linux 7.0 with Landlock ABI 8, covered the same commit's code with parts of the fix taken out. Without the metadata rules, the launch check refuses with `change the canary's permissions: it worked`. Without those rules or the new checks, `a_confined_program_changes_no_file` fails with `permissions of ...: it worked`. With the Landlock rights limited to ABI 2, as on an older kernel, it fails with `truncate ...: it worked`. A kernel that is actually older than ABI 3, or that has no Landlock at all, has not been run. On macOS, terminal input was checked by hand on a pseudo-terminal: `TIOCSTI` is refused when confined and works when not. The commits group the change by area for reading. Only the last commit of the branch is expected to build. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> <!-- coderabbit-ai-generated:v1 --> ## Summary by CodeRabbit * **New Features** * Added sandbox support for running commands with policy-limited file access and restrictions on network and process operations. The sandbox is available on supported Linux and macOS systems; launches are refused if confinement cannot be established or verified. * Added the `sandbox-check` command to check sandbox support and enforcement, or run a specified program in the sandbox. It reports when sandboxing is unsupported. * **Documentation** * Added guidance on sandbox behavior, platform support, access restrictions, launch checks, and `sandbox-check` usage. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
187 lines
12 KiB
Markdown
187 lines
12 KiB
Markdown
# 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](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, 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
|
|
|
|
```text
|
|
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`).
|