Determinism
Four knobs that remove the usual reasons two identical runs produce different output: the clock, the random number generator, directory order, and address-space layout.
These are not confinement. Nothing here makes a sandbox safer, and a workload that ignores all four is no less contained. They exist because a sandbox is a controlled environment, and a controlled environment is the natural place to also control the inputs that make builds and tests irreproducible.
| Field | CLI | Default | Pins |
|---|---|---|---|
time_start | --time-start | none | The clock |
random_seed | --random-seed | none | getrandom() |
deterministic_dirs | --deterministic-dirs | false | Directory order |
no_randomize_memory | --no-randomize-memory | false | Address layout |
$ sandlock run \
--time-start "2000-01-01T00:00:00Z" \
--random-seed 42 \
--deterministic-dirs \
--no-randomize-memory \
-- ./build.sh
Frozen time
time_start takes a Unix timestamp or an RFC 3339 / ISO 8601 string, and sets the sandbox's clock to that instant. Time then advances at real speed from that epoch. It is a shifted clock, not a stopped one, so a program that measures elapsed time still sees it elapse.
The offset is computed once at start as desired - actual, and every clock the sandbox reads has it added.
Why this needs vDSO patching
Most clock reads never reach the kernel. clock_gettime and friends are served from the vDSO, a small shared object the kernel maps into every process precisely so that reading the time does not cost a syscall. A supervisor that intercepts syscalls therefore sees almost none of a program's clock reads.
So Sandlock patches the vDSO in the child's address space: it locates the mapping in /proc/<pid>/maps, parses the symbols, and rewrites the time functions to return the shifted value. On x86-64 the stubs are short and the gaps between symbols are wide, so the patch is applied inline; on arm64 and riscv64 the full stubs go in slack space at the tail of the mapping and each function entry gets a single four-byte jump to its stub.
Patching happens before exec, and is re-applied on every execve, since a fresh executable image gets a fresh vDSO mapping. If the pre-exec attempt fails, Sandlock warns and retries after exec rather than giving up.
Absolute timers
Shifting the clock creates a problem the supervisor has to undo. When a program arms an absolute timer, it computes the deadline from the clock it just read, which is the shifted one. Passing that deadline straight to the kernel would arm the timer at the wrong real time, off by exactly the offset.
So the supervisor intercepts clock_nanosleep, timer_settime, and timerfd_settime when they carry TIMER_ABSTIME, and subtracts the offset before the kernel sees the deadline. The affected clocks are CLOCK_MONOTONIC, CLOCK_MONOTONIC_RAW, CLOCK_MONOTONIC_COARSE, and CLOCK_BOOTTIME.
The net effect is that sleep(5) sleeps five real seconds and an absolute deadline fires when the shifted clock reaches it, which is what a program expects in both cases.
Seeded randomness
random_seed makes getrandom() deterministic. The supervisor intercepts the call and writes bytes from a seeded ChaCha8 generator into the guest's buffer instead of letting the kernel supply entropy. Identical seeds produce identical byte streams.
A single interception fills up to 1 MiB, which covers the practical cases: OpenSSL initialization, key generation, a language runtime seeding its own PRNG at startup.
This is a reproducibility tool, not a security one, and it fails open. If the supervisor cannot deliver deterministic bytes, whether from an allocation failure or a failed write into guest memory, the call falls through and the child receives real kernel entropy. That is a determinism failure rather than a security failure, and it is the right trade: the alternative would be failing a program's entropy request outright. It does mean you should never treat a seeded run as guaranteed byte-identical without checking.
What it does not cover: anything the program does not source from getrandom(). A workload reading /dev/urandom through an already-open descriptor, or seeding from the clock, needs the clock pinned too.
Directory order
Directory order is the most common invisible source of irreproducible builds. The kernel returns entries in whatever order the filesystem yields, which varies with creation history and differs between machines. A build that globs a source directory and compiles in the order returned produces a different link order, and sometimes a different binary, on a different host.
deterministic_dirs sorts readdir() results lexicographically, so ls, shell globs, os.listdir, and anything else built on getdents64 return a stable order across runs and across machines.
Address-space layout
no_randomize_memory disables ASLR for the child via personality(ADDR_NO_RANDOMIZE). Addresses become stable across runs, which is what you want when comparing crash dumps, diffing memory profiles, or chasing a bug that only reproduces at a particular layout.
This weakens a hardening measure. ASLR exists to make memory-corruption exploits harder. Turning it off inside a sandbox is a reasonable trade for a build or a debugging session; leaving it off for a workload whose job is to run untrusted code is not. Enable it for the run that needs reproducibility, not as a default.
Putting it together
For a reproducible build, the four combine with the copy-on-write layer: pin the inputs, and let COW show you exactly what came out.
[determinism]
random_seed = 42
time_start = "2000-01-01T00:00:00Z"
deterministic_dirs = true
no_randomize_memory = true
[config]
workdir = "/src"
[program]
exec = "make"
args = ["-j4"]
clean_env = true
env = { CC = "gcc", LANG = "C.UTF-8", TZ = "UTC" }
[filesystem]
read = ["/usr", "/lib", "/lib64", "/bin", "/etc"]
write = ["/src"]
clean_env belongs in that list as much as the determinism knobs do. An inherited environment carries hostnames, paths, and locale settings that leak into build output, and starting from a minimal one removes a whole class of differences the four knobs above never touch.
# Run twice under dry-run and compare the reported changes.
# Neither run touches the source tree.
$ sandlock run -p repro --dry-run -- make build > run-a.txt
$ sandlock run -p repro --dry-run -- make build > run-b.txt
$ diff run-a.txt run-b.txt
What is not pinned
The four knobs cover the sources Sandlock can reach. Several common ones remain your responsibility:
- Process and thread scheduling. Interleaving still varies. A build whose output depends on which worker finished first is not made reproducible by anything here.
- PIDs and timing-derived identifiers. A program that embeds its own PID, or a name derived from wall-clock nanoseconds, still varies.
- Hostname and environment. Use
--namefor a stable virtual hostname and--clean-envfor the environment. - Filesystem timestamps. Existing files keep their real mtimes; the frozen clock applies to what the sandbox reads, not to what is already on disk.
- Network responses. Obviously. A reproducible build should have no network at all, which is the default.
See the reference for the field types and defaults, and Profiles for saving a configuration like the one above.