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 /Filesystem and COW

Filesystem and Copy-on-Write

Two mechanisms with different jobs. Landlock decides what the workload may reach, enforced by the kernel and immune to TOCTOU. Copy-on-write decides what happens to the writes it makes.

Access rules

Three lists describe filesystem access. All of them take paths, all of them are recursive, and all of them are compiled into a Landlock ruleset applied before the workload executes.

Python TOML CLI Meaning
fs_readableread-rReadable, in addition to anything writable
fs_writablewrite-wReadable and writable
fs_denieddeny--fs-denyNeither, even where a broader rule would allow it

The default for all three is empty, and empty means no access at all. There is no implicit grant for /usr or /lib, which is why every example names them: without them, the dynamic linker cannot load and nothing runs.

A working envelope
$ sandlock run \
    -r /usr -r /lib -r /lib64 -r /bin -r /etc \
    -w /tmp/work \
    -- ./task.sh

Grants are recursive

Each rule becomes a Landlock PATH_BENEATH grant, so -r /usr covers everything under /usr. Listing both an ancestor and a descendant is redundant, and a generated profile removes the descendant during its deduplication step for exactly this reason.

Denials override grants

fs_denied exists for the case where a workload legitimately needs a broad grant but must not reach one thing inside it.

Broad read, one hole punched out
from sandlock import Sandbox

sandbox = Sandbox(
    fs_readable=["/usr", "/lib", "/proc"],
    fs_denied=["/proc/kcore"],
)

Why this is TOCTOU-safe

The kernel resolves the path itself, at access time, against a ruleset it holds. Nothing in the decision depends on a string that userspace read out of the workload's memory, so there is no window in which the workload can swap the target after the check. This is the reason Sandlock puts path-based control in Landlock and refuses to expose path strings to policy callbacks.

chroot and per-sandbox mounts

chroot changes the root the workload sees before the rest of the confinement is applied. fs_mount then maps virtual paths inside that root onto host directories, which gives you Docker-style volume mapping without a kernel bind mount and without root: the supervisor mediates the mapping on the intercepted open path.

CLI
$ sandlock run --chroot ./rootfs \
    --fs-mount /work:/tmp/sandbox/work \
    -- /bin/sh
python
chrooted = Sandbox(
    chroot="/opt/rootfs",
    fs_mount={"/work": "/tmp/sandbox-1/work"},
    fs_readable=["/usr", "/bin", "/lib", "/etc"],
    cwd="/work",
)
result = chrooted.run(["python3", "task.py"])

Read-only mounts are CLI and TOML only. A trailing :ro (the default being :rw) selects a read-only mount, and sandlock inspect --toml writes it back out. The Python SDK rejects such entries with PolicyError, because its mapping type cannot express the flag. Load the profile through the CLI with --profile-file, or use the C ABI's sandlock_sandbox_builder_fs_mount_ro.

Copy-on-write

Setting workdir puts a copy-on-write layer over that directory. Writes underneath it are intercepted through seccomp notification and staged in an upper layer; reads resolve upper first, then fall through to the real directory. There is no mount namespace, no user namespace, and no root involved.

workdir is not cwd. workdir chooses which directory COW tracks. cwd chooses the child's working directory. They are independent, and a policy commonly sets one without the other.

Branch actions

What happens to the staged writes when the sandbox ends is a policy decision, taken separately for the success and the failure case.

Field Default When it applies
on_exitcommitNormal sandbox exit
on_errorabortSandbox error or exception

The three actions are commit, which merges the branch's writes into the parent; abort, which discards them; and keep, which leaves the branch in place for the caller to deal with.

The defaults give transactional semantics for free: a build that fails leaves the tree exactly as it found it.

Storage and quota

fs_storage puts the upper layer and its deltas somewhere other than the default location, which matters when the working directory lives on a small or slow filesystem. max_disk caps how large the upper layer may grow; exceeding it surfaces to the workload as ENOSPC, the same error a full disk would produce.

Staged writes, capped and kept off the source volume
[config]
workdir    = "/opt/project"
fs_storage = "/var/lib/sandlock"

[filesystem]
on_exit  = "commit"
on_error = "abort"

[limits]
disk = "1G"

Dry-run

Dry-run runs the command for real, inspects the COW layer for what changed, prints a summary, and then aborts the branch. The working directory is left completely untouched, which makes it a way to answer "what would this actually do" without trusting the answer to a description.

CLI
$ sandlock run --dry-run --workdir . \
    -w . -r /usr -r /lib -r /bin -r /etc -- make build
A  build/out.o
M  Cargo.lock
D  build/stale.o
python
sandbox = Sandbox(
    fs_writable=["."],
    workdir=".",
    fs_readable=["/usr", "/lib", "/bin", "/etc"],
)
result = sandbox.dry_run(["make", "build"])

for c in result.changes:
    print(f"{c.kind}  {c.path}")  # A=added, M=modified, D=deleted

A Change carries a kind of "A", "M", or "D", and a path relative to the working directory. DryRunResult also carries success, exit_code, stdout, stderr, and error.

Which syscalls COW intercepts

Syscall Why
openatRedirect a write open into the upper layer; resolve reads upper-then-lower
unlinkatRecord a deletion in the branch instead of removing the real file
mkdiratCreate the directory in the upper layer
renameat2Apply the rename within the branch
getdents64Merge upper and lower directory listings so the view is consistent

COW is only active when workdir is set. Without it, none of these syscalls are intercepted for filesystem purposes and writes go straight to disk under the Landlock rules.

Related

Two topics that used to live on this page have their own now, because neither is really filesystem policy: