|
| 1 | +# Pluggable sandboxing API for external commands |
| 2 | + |
| 3 | +- Author: Christian Heimes, Pavan Kalyan |
| 4 | +- Created: 2026-05-26 |
| 5 | +- Status: Open |
| 6 | +- Issue: [#1019](https://github.com/python-wheel-build/fromager/issues/1019) |
| 7 | + |
| 8 | +## What |
| 9 | + |
| 10 | +A plugin API for running external processes that lets users plug in different |
| 11 | +sandboxing solutions. |
| 12 | + |
| 13 | +## Why |
| 14 | + |
| 15 | +Sandboxing prevents build processes from modifying the host system, reading |
| 16 | +sensitive data, or interfering with other packages. On Linux, Fromager has |
| 17 | +simple network isolation with `unshare`. Users need the ability to plug in |
| 18 | +their own sandboxing configuration to confine build processes with tools like |
| 19 | +[bubblewrap](https://github.com/containers/bubblewrap), |
| 20 | +[firejail](https://github.com/netblue30/firejail), |
| 21 | +[Landlock](https://landlock.io/integrations/) / |
| 22 | +[Landlock API](https://docs.kernel.org/userspace-api/landlock.html), or |
| 23 | +container runtimes. |
| 24 | + |
| 25 | +## Goals |
| 26 | + |
| 27 | +- Platform-agnostic API that supports a wide range of sandboxing tools and |
| 28 | + that does not assume a specific OS, container runtime, or privilege level. |
| 29 | +- Life cycle hooks to set up and tear down persistent sandboxing environments |
| 30 | + |
| 31 | +## Non-goals |
| 32 | + |
| 33 | +- Implementing sandboxing beyond the current network isolation. Sandboxing is |
| 34 | + hard to get right and there are many existing tools to choose from. Fromager |
| 35 | + should delegate to those tools rather than re-implement confinement. |
| 36 | + |
| 37 | +## How |
| 38 | + |
| 39 | +### Hooks |
| 40 | + |
| 41 | +Four hooks in global settings control sandboxing: |
| 42 | + |
| 43 | +```yaml |
| 44 | +external_commands: |
| 45 | + setup_sandbox: fromager.external_commands:default_setup_sandbox |
| 46 | + teardown_sandbox: fromager.external_commands:default_teardown_sandbox |
| 47 | + run_sandboxed: fromager.external_commands:default_run_sandboxed |
| 48 | + run_unconfined: fromager.external_commands:default_run_unconfined |
| 49 | +``` |
| 50 | +
|
| 51 | +All four hooks require `ctx`, `req`, and `sdist_root_dir` keyword arguments. |
| 52 | + |
| 53 | +The `run_sandboxed` and `run_unconfined` hooks accept a subset of |
| 54 | +[`subprocess.run`](https://docs.python.org/3/library/subprocess.html#subprocess.run) |
| 55 | +keyword arguments (`stdin`, `stdout`, `stderr`, `cwd`, `timeout`, `text`, |
| 56 | +`env`) and return a `subprocess.CompletedProcess` object. |
| 57 | + |
| 58 | +The `run_unconfined` hook is included so users can monitor and police |
| 59 | +unconfined calls. |
| 60 | + |
| 61 | +### Life cycle |
| 62 | + |
| 63 | +`setup_sandbox` runs after the `prepare_source` hook and before sdists and |
| 64 | +wheels are built. `teardown_sandbox` runs after `build_wheel`. These hooks |
| 65 | +exist for sandboxing solutions that require persistent state across multiple |
| 66 | +commands, such as creating namespaces, mounting filesystems, or adjusting |
| 67 | +file permissions. |
| 68 | + |
| 69 | +The sandbox is set up and torn down for each package+version |
| 70 | +combination. A failed `setup_sandbox` aborts the build. |
| 71 | +`teardown_sandbox` always runs (via `finally`) regardless of build |
| 72 | +success or failure. |
| 73 | + |
| 74 | +### Writable directories and isolation |
| 75 | + |
| 76 | +Only `sdist_root_dir.parent` (`work-dir/{name}-{version}`) should be |
| 77 | +writable. Sandboxing tools may create `tmp` and `home` subdirectories |
| 78 | +there for persistent temporary or home directories (e.g. XDG cache or |
| 79 | +config directories). |
| 80 | + |
| 81 | +The `build_sdist` and `build_wheel` hooks should write output to |
| 82 | +`sdist_root_dir.parent / "dist"`. Fromager will create this directory |
| 83 | +before the build and move the resulting files into the correct location |
| 84 | +afterwards. When moving output files, fromager must verify they are |
| 85 | +regular files and not symlinks or other special files (e.g. device |
| 86 | +nodes, FIFOs) to prevent symlink escape attacks where a build creates |
| 87 | +a symlink pointing outside the sandbox boundary. |
| 88 | + |
| 89 | +The `pyproject_hooks` library communicates with build backends via |
| 90 | +temporary files. These IPC files must be inside the writable |
| 91 | +`sdist_root_dir.parent` directory, not in `/tmp`, so that sandbox |
| 92 | +hooks can give each build a private `/tmp` without breaking the IPC |
| 93 | +channel. |
| 94 | + |
| 95 | +Other shared state like writing to the Fromager installation, modifying |
| 96 | +the host OS, or using shared caches like ccache, sccache, or the Rust |
| 97 | +cache defeats the purpose of sandboxing and isolation between builds. |
| 98 | + |
| 99 | +### API changes |
| 100 | + |
| 101 | +- `BuildEnvironment` gains `req` and `sdist_root_dir` arguments |
| 102 | +- New methods: `BuildEnvironment.run_sandboxed`, |
| 103 | + `WorkContext.run_sandboxed`, `WorkContext.run_unconfined` |
| 104 | +- `external_commands.run()` becomes internal (should no longer be used by |
| 105 | + external code) |
| 106 | +- `BuildEnvironment.run` is deprecated and will eventually be removed |
| 107 | + |
| 108 | +`BuildEnvironment.run_unconfined` is intentionally omitted until there is a |
| 109 | +valid use case. |
| 110 | + |
| 111 | +### CLI migration |
| 112 | + |
| 113 | +`--network-isolation` becomes an alias for `--enable-sandbox` / |
| 114 | +`--disable-sandbox`. The existing network isolation feature stays as the |
| 115 | +default sandboxing implementation. |
| 116 | + |
| 117 | +## Sandboxing considerations |
| 118 | + |
| 119 | +When implementing a sandbox hook, consider the following threat |
| 120 | +vectors (see [#1019] for the full analysis): |
| 121 | + |
| 122 | +- **Credential access:** Build backends can read files like |
| 123 | + `$HOME/.netrc` or environment variables containing tokens |
| 124 | + (`GITHUB_TOKEN`, `GITLAB_PRIVATE_TOKEN`, `NGC_API_KEY`, etc.). |
| 125 | + Hide the user's home directory (e.g. mount a tmpfs over `$HOME`) |
| 126 | + and scrub sensitive variables from the build environment. |
| 127 | +- **Network access:** A build can exfiltrate stolen data or |
| 128 | + download payloads. Block network access entirely and configure |
| 129 | + only a loopback device. |
| 130 | +- **Process and IPC visibility:** Builds can enumerate processes |
| 131 | + via `/proc` or interfere through shared memory and semaphores. |
| 132 | + Isolate PID, IPC, and UTS namespaces. |
| 133 | +- **Persistence:** A malicious build can leave backdoors (`.pth` |
| 134 | + files, shell profile entries, background daemons) that affect |
| 135 | + every subsequent build. Make `/usr`, `/var`, the fromager |
| 136 | + installation, and settings directories read-only. Only |
| 137 | + `sdist_root_dir.parent` should be writable. |
| 138 | +- **Shared temporary directories:** Parallel builds sharing `/tmp` |
| 139 | + can interfere with each other. Give each sandbox a private `TMP`, |
| 140 | + `HOME`, and `XDG` directories. |
| 141 | +- **Untrusted source tree:** Sdist contents are untrusted. Tar |
| 142 | + unpacking should block device files, FIFOs, and the setuid bit. |
| 143 | + Mount the source tree (`sdist_root_dir`) with `nodev` and |
| 144 | + `nosuid` as an additional safeguard. |
| 145 | +- **Syscall filtering:** Even inside namespaces, a build process can |
| 146 | + attempt dangerous syscalls (`ptrace`, `mount`, `personality`, |
| 147 | + `keyctl`). Consider restricting syscalls with seccomp-bpf where |
| 148 | + the sandbox tool supports it (nsjail, firejail). |
| 149 | +- **Resource exhaustion:** A malicious build can fork-bomb, exhaust |
| 150 | + memory, or fill writable directories. Apply cgroups or rlimits |
| 151 | + to constrain CPU, memory, and process count. Tools like nsjail |
| 152 | + and systemd-run support this; bubblewrap and Landlock do not. |
| 153 | + |
| 154 | +Available tools each cover a different subset of these vectors: |
| 155 | + |
| 156 | +| Tool | Filesystem | Network | PID/IPC | Needs root | Notes | |
| 157 | +| -- | -- | -- | -- | -- | -- | |
| 158 | +| `unshare` | No | Yes | Yes | No | Current default; works in unprivileged Podman | |
| 159 | +| bubblewrap | Yes | Yes | Yes | In containers | Requires `CAP_SYS_ADMIN` inside containers | |
| 160 | +| firejail | Yes | Yes | Yes | No | Feature-rich; available on most distributions | |
| 161 | +| Landlock | Yes | Yes (6.7+) | Partial (6.11+) | No | Filesystem since 5.13, TCP since 6.7, signal/socket scoping since 6.11 | |
| 162 | +| systemd-run | Yes | Yes | Yes | No | Requires systemd as PID 1 | |
| 163 | +| nsjail | Yes | Yes | Yes | No | Namespaces + cgroups + seccomp | |
| 164 | + |
| 165 | +No single tool covers all deployment models (root in a container, |
| 166 | +unprivileged user on a workstation, CI without root). The pluggable |
| 167 | +hook API lets each deployment choose the appropriate solution. |
| 168 | + |
| 169 | +[#1019]: https://github.com/python-wheel-build/fromager/issues/1019 |
| 170 | + |
| 171 | +## Future work |
| 172 | + |
| 173 | +Fromager may ship hook implementations for popular sandboxing tools such as |
| 174 | +bubblewrap, firejail, or Podman in the future. |
0 commit comments