Home
Why Sandlock
How It Works Use Cases Comparison Security Model
Docs
Documentation Home Getting Started CLI Reference Python SDK Sandbox Reference
Products
Overview Sandbox HTTP API Sandbox Scheduler
GitHub Schedule a Demo
Sandlock / Docs /Determinism

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-startnoneThe clock
random_seed--random-seednonegetrandom()
deterministic_dirs--deterministic-dirsfalseDirectory order
no_randomize_memory--no-randomize-memoryfalseAddress layout
All four at once
$ 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.

A reproducible build profile
[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.

Verify it: same inputs, same tree
# 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 --name for a stable virtual hostname and --clean-env for 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.