Compilers · Toolchain Engineering
Hermetic Build Actions: Enforcing That Compilation Reads Only Declared Inputs
A declared input list is a promise. Making the promise enforceable means restricting the file system, the clock, and the network, and choosing which of those restrictions is worth its cost.
A build action is hermetic when its output depends only on inputs that were declared to it. That is a property of the action’s behaviour, not of the build system’s bookkeeping, and the gap between the two is where builds become unreproducible. A rule can declare a source file and a header, and the compiler can still read a configuration file from /etc, a generated header from a path outside the declared set, or a timestamp from the system clock. Nothing in the rule’s declaration records that, and the output silently depends on the machine.
Enforcing hermeticity means making the undeclared reads fail. That requires restricting what the action can see, and the restrictions available differ in cost, portability, and the amount of build-system surgery they demand.
Three resources an action can leak through
The file system is the obvious one. An action that can open any path can read anything: a header from a directory not in its inputs, a library from a system location, a previously built artifact that happened to be there. The read may be intentional, as when a compiler reads its own private headers, or accidental, as when a build script falls back to a default path.
The clock is the second. Anything that records the current time, from a compiler’s __DATE__ expansion to an archiver writing member timestamps, makes the output depend on when the action ran. This is not a file system leak and cannot be closed by restricting paths; it needs the action’s environment to supply a fixed time instead.
The network is the third, and it is the one that makes builds fail in ways that look random. A build step that fetches a dependency at build time, or a compiler plugin that downloads a resource, produces an output that depends on a server’s state at a moment in time. A hermetic build moves that fetch out of the action and into a separately modelled download step whose result is an input.
There is a fourth that is easy to overlook: the process environment. A compiler that reads TMPDIR, a locale variable, or a search path from the environment has an input that is invisible in the command line. Environment variables are part of the action’s input set whether or not the build system records them.
The sandbox mechanisms and what each one costs
On Linux the available mechanisms form a ladder.
The simplest is a per-action working directory with a curated tree of symlinks or bind mounts. The action sees only the declared inputs, plus whatever the toolchain needs. This is a chroot-like arrangement built from mount namespaces, and it costs one mount setup per action. The advantage is that the action’s view is exactly the declared set, so an undeclared read fails immediately with a clear error naming the path.
The next rung adds user namespaces so that the action runs as an unprivileged identity inside the sandbox, which prevents it from touching anything outside the mount view even if it tries. This requires kernel support that is present on modern kernels but is often disabled in container environments, which is why build systems that rely on it have a documented fallback path.
The strictest rung removes the network namespace entirely, so the action has no route to anything. This is cheap to add once namespaces are in use, and it catches a build step that downloads by making the download fail rather than silently succeed.
A different approach avoids kernel features altogether: run the action with a preload library that intercepts file system calls and rejects paths outside the declared set. It works on any Unix and needs no privileges, which makes it usable where namespaces are unavailable. It is weaker, because a program that issues raw system calls rather than going through the C library bypasses it, and because the interception adds cost to every file operation.
/* Shape of an interposer: reject opens outside the declared input tree.
Real implementations must handle openat, stat, exec, and relative paths. */
#define _GNU_SOURCE
#include <dlfcn.h>
#include <errno.h>
#include <string.h>
static int (*real_open)(const char *, int, ...);
int open(const char *path, int flags, ...) {
if (path[0] == '/' && strncmp(path, "/sandbox/", 9) != 0) {
errno = EACCES;
return -1;
}
if (!real_open) *(void **)(&real_open) = dlsym(RTLD_NEXT, "open");
return real_open(path, flags, 0644);
}
The snippet is deliberately incomplete: it ignores variadic mode handling, does not cover openat, and would break any program that opens a device or a pipe by absolute path. It shows the mechanism, not a usable implementation.
Declaring inputs precisely enough to be checkable
Sandboxing is only useful if the declared input set is right. Two failure modes are common.
The first is over-declaration: a rule declares an entire source directory rather than the files it reads. The build passes, the sandbox is satisfied, and the action’s true dependencies remain unknown. The consequence appears later, when a file inside the declared directory changes and the cache key, computed over the declared set, changes as well, forcing a rebuild of everything that declared the directory. Over-declaration costs cache hits, not correctness.
The second is under-declaration, which the sandbox converts from a silent bug into a loud failure. This is the main argument for sandboxing even when reproducibility is not the goal: it is a test of the declared input set, run on every build.
Getting declarations precise requires the toolchain to cooperate. A compiler that opens headers dynamically, based on macro expansion, cannot have its inputs predicted from the command line. The dependency file it writes after the fact is the only accurate record, and that record is produced after the action has already read the files. A hermetic build system therefore either preprocesses in a separate action to discover the set, or declares a conservative superset and accepts the cache cost. The same trade-off that appears in incremental dependency tracking reappears here, because it is the same underlying problem.
Toolchains themselves need declared inputs too. The compiler binary, its private headers, the assembler, the linker, the startup objects, and the sysroot are all read by the action. A hermetic build models the toolchain as an input tree with its own identity, which is also what makes the cache key correct: the toolchain’s hash is the toolchain’s declared content, not its version string.
The environment as a declared input
Once the file system is closed, the environment becomes the dominant leak. Two actions with identical files and different environments can produce different outputs, and the difference is often not visible in the command line.
The practical approach is to run actions with an environment that is constructed rather than inherited. The build system supplies a fixed set of variables, and anything the action needs beyond that must be passed explicitly. This removes a whole class of “works in my shell” failures, and it also makes the action’s behaviour independent of the invoking user’s shell configuration, which matters when the same build runs in a developer’s terminal and in a continuous integration job.
Time is the special case. Supplying a fixed epoch through the conventional variable makes date-dependent compilations reproducible, but only if every tool in the action honours it. A tool that ignores the variable and reads the clock directly reintroduces the leak, and the sandbox does not catch it because reading the clock is not a file system operation. Detecting that requires comparing outputs across runs with different clocks, which is a reproducibility check rather than a sandbox check.
When hermeticity is not worth the cost
Sandboxing has real costs: process startup for the sandbox itself, mount operations per action, and the engineering effort of making a toolchain work inside a restricted view. The overhead is per action, so a build of many short actions pays it many times, while a build of a few long compilations amortises it away. Build systems mitigate it by reusing sandboxes across actions and by grouping actions that share inputs, both of which reduce isolation slightly.
There is also a class of build that cannot be made hermetic without changing what it does. A build step that probes the host for available features, compiles and runs a test program to detect a library’s behaviour, or generates code using a tool that reads the host’s locale is producing output that depends on the host by design. The honest response is to model the probe as a separate action with the host as an explicit input, or to replace the probe with a configured answer. Leaving the probe inside a nominally hermetic action produces a build that passes the sandbox and fails the reproducibility check, which is worse than admitting the dependency.
The final consideration is that hermeticity and caching are the same requirement seen from two sides. A cache hit is a claim that the inputs determine the output; hermeticity is the property that makes the claim true. A build that enforces hermeticity can share a cache across machines without trusting them, and a build that does not enforce it needs a cache scoped to machines it controls. The enforcement is what converts the cache from an optimisation into an assumption. The key that assumption rests on is worked out in Content-Addressed Build Cache Keys, and the cost of the extra per-action setup shows up in Scheduling a Parallel Build from a Dependency Graph as a larger constant per node.
References
- Bazel Documentation — Sandboxing — Bazel project
- Linux man-pages — namespaces(7) — Linux man-pages project
- Linux man-pages — mount_namespaces(7) — Linux man-pages project