Decisions
Why the sandbox's wall, link-sandbox-confine, works the way it does
(The Wall).
What it closes
Why the wall closes what it closes on each OS, and how it holds a folder's grants.
Why can't a sandbox reach the Keychain?
No harness needed it. A sandbox that can ask securityd for items can
be handed a saved password, such as git's for GitHub, and send it out;
a tool that runs outside gives a program what it needs of your logins,
asking you. So the keychain permission went, and a stored one is read
as nothing.
Why does Linux use a seccomp filter?
The kernel is a wall too. The filter refuses by name the calls a
sandboxed program doesn't need and container runtimes refuse too, each
with an error rather than killing the program, so it can fall back.
lnk-sandbox installs it as the first thing inside, rather than passing
it to bubblewrap as a file descriptor, because a harness's sandboxed
command is kept and run later by the background service, which can't
carry one. It is built by hand from libc, a few dozen BPF
instructions, with no new dependency.
Why can a sandbox run without Landlock?
Capabilities follow the effective policy after OS overrides and harness requirements. Bubblewrap and the syscall filter enforce file and network restrictions with or without Landlock; where the kernel has it, Landlock holds the file grants again inside the mounts — a second wall it keeps even were a mount wide — and absent it the mounts stand alone.
What needs it
A finite allowlist, or a policy granting no programs, needs Landlock and refuses the launch without it. The sandbox never substitutes a classifier or unrestricted execution; an allowed interpreter still runs any code, but only within the file and network boundaries it inherited.
Why do executable tests prove their target started?
A refused launch establishes that unsupported policy fails closed. It does not prove that the kernel restricted a running process. Executable enforcement tests therefore require the capability and verify startup inside the sandbox before counting a refused executable. File and network tests ask for unrestricted programs so their own boundaries still run without Landlock. Full coverage requires both capability environments. Containers share their host kernel and cannot add missing Landlock; a separate virtual-machine backend is independent of this capability check.
How does hiding a folder work?
A hidden folder is closed whatever else is open, but for what the
spec names in it. An agent's sandbox hides every agent's folder and
reopens its own, so an agent made later is hidden too. On Linux the
folders holding a hidden one are mounted before it's hidden, and the
ones inside after. On macOS the deny follows [files] read = ["~"], and the
spec's own folders in there are allowed again after it.
Why does a sandbox run by root drop every capability?
Run by root, bubblewrap keeps root's capabilities inside, user
namespace or not. With them a program reads and writes any file it's
given whatever its permissions, and opens other processes' descriptors
and memory, the exec watch's included, which not being dumpable doesn't
stop. A sandbox run by root, on a box, needs none of them: it writes
root's own folders, and the proxy's DNS helper joins its network from
outside. So it drops them all (--cap-drop ALL) and always gets a user
namespace of its own, which the helper joins as its owner. It keeps
root's user, so files it writes stay root's.
Checked file grants are a snapshot
A spec's file grants are resolved before they're checked and asked for, and an opaque snapshot carries those names through remembered permissions, description and wrapper, never resolved again. Validation holds both the name and its current target; below another grant only its root is resolved, the suffix literal, so a link planted after validation redirects nothing. Linux pins the sources at launch too.
Linux mount sources are pinned at launch
A wrapped command may be saved and run later, and a writable ancestor replaced before bubblewrap starts. So the launch stage opens every source one path component at a time without following links and hands bubblewrap a held descriptor; a missing optional source is left out. The inner confiner closes inherited descriptors before the target runs.
Named IPC on macOS stays closed
POSIX shared memory and semaphores have host-wide names, not a spec's paths, so both stay denied: another program's memory stays outside.
The programs it may run
Why [commands] limits files, and how each OS keeps a program it
doesn't list from running.
Why does [commands] limit files and not code?
The kernel sees which file is run, not what an interpreter is told to do: Seatbelt and Landlock both check the file executed. Checking a program's arguments would need something outside watching each start, which Linux does only with a race and macOS only with an entitlement. So the list says which programs may start, and the docs say an interpreter runs anything.
Why is running a file descriptor refused, but not memfd_create?
[commands] lists which files may run, by the file exec is given. A
program can still run code it wrote by handing exec a descriptor
instead of a path: execveat with AT_EMPTY_PATH (fexecve), or a
/proc/self/fd entry of a program copied into a memory file
(memfd_create), which is in no folder for Landlock to judge. The
seccomp filter refuses running a descriptor, and the sandbox makes memory
files non-executable, so neither runs.
memfd_create itself
memfd_create itself stays open,
and mapping its memory executable stays open, because Chromium and some
JITs need them, when the kernel can seal the files and a listener can
apply that seal. If either is unavailable, the base seccomp filter
refuses memfd_create with ENOSYS instead.
What that keeps
This preserves the executable restriction on older kernels and under another listener, without assuming what that listener enforces. Unrestricted programs keep memory files, while applications under an executable allowlist need a memory-file fallback when sealing is unavailable.
Why does the exec watch make the sandbox's memory files?
The kernel's own control, vm.memfd_noexec, can be set per PID
namespace, but only by a process with CAP_SYS_ADMIN there whose user
is the machine's root: a sandbox run by anyone else never can, and one
run by root no longer has the capability. There is no per-process
switch.
Handed to the exec watch
Refusing memfd_create without MFD_NOEXEC_SEAL in the seccomp
filter would break every program that makes one plainly, JITs among
them, and a filter can't change a call's flags. So the filter hands such
a call to the exec watch, already there where programs are listed,
which makes the file with MFD_NOEXEC_SEAL added and puts it in the
program as the call's answer (SECCOMP_ADDFD_FLAG_SEND). The program
gets a memory file it can write, seal and map executable, but never
run, and the kernel refuses making it runnable later.
Sealed memory and huge pages
A call that asks
for sealed memory itself, the watch's own among them, runs as it is. A
memory file of huge pages (MFD_HUGETLB) is refused with EINVAL, as
on a kernel without huge pages: hugetlbfs takes the seal but doesn't
enforce it, so fchmod makes the file executable again and it runs.
Programs that want huge pages fall back to plain memory.
Without the seal
Without the
seal or the listener, creation is refused: a warning alone would let a
memory file run around the allowlist through /proc/self/fd.
Why can't the loader and LD_PRELOAD be closed?
Every dynamically linked program needs its loader run on its behalf, and
Landlock's execute right applies to it, so the loader must stay runnable
where programs are listed; a program can then hand the loader any file it
can read (ld.so ./x). The loader's injection variables are cleared when
the sandbox starts, but a program can set LD_PRELOAD again for a child
it runs. Both stay a stated gap, which check reports as open.
Why does the sandbox name a refused program itself, not Landlock's audit log?
Landlock logs what it refuses to the kernel's audit log (Linux 6.15 and
newer), but only where auditing is on, and only a process with
CAP_AUDIT_READ reads it: almost never a person's own lnk. So the
sandbox watches each exec itself, through a seccomp filter that hands
it the call before it happens, and judges the file against the same
list Landlock enforces. It never answers for Landlock: every call goes
on, and Landlock alone refuses. The same watch is what lnk sandbox learn records, so finding the programs a command runs needs no
privilege either.
How does macOS name a refused program?
From Seatbelt's own reports: it writes a line to the system log for each
program it refuses (deny(1) process-exec*), and nothing else on a Mac
sees an exec without privileges a person's lnk doesn't have. So
lnk-sandbox stays outside as the parent of sandbox-exec, reads them
with log stream while the program runs, and says which file the list
refused, as on Linux.
For learn, and its limits
For learn, the profile allows every program
(with report), so each is logged as it runs. A report names a process,
not a sandbox: one whose process is still there and not of this run is
left out, but one that's gone can't be told apart, so another
sandbox's refusal at the same moment may be named too. Naming is advice,
never a decision: Seatbelt alone decides what runs.
Why does lnk sandbox learn run every program, once?
A list written by hand misses the helper a program starts only now and
then (git's git-remote-https, a compiler's linker), and a run that
fails on it says only "Permission denied". learn runs the command once
under the spec's other grants with every program open, and prints what
to add, writing nothing: the person adds the lines, so what a spec runs
is still what they wrote. It never offers a program the sandbox could
replace (in a folder it writes, or a temp folder), and marks
interpreters, which run any code.
A project's dot files
Why the files of a project that run as you outside the sandbox are read only at the top of a folder it writes.
Why are a project's dot files read only?
A folder a sandbox writes is one you work in afterwards, outside it. In
a project, some files run as you without your running the project:
git's settings and hooks on any git command, .envrc when direnv sees
you cd in, an editor's tasks and settings when it opens the folder.
So at the top of every folder a sandbox writes, these are read only by
default, the least that still does the job, as a spec's missing key is.
Reading, and committing
Reading a repository (git status, diff, log) still works. A
sandbox that commits, or changes git's settings, gets them with a key in
[files], write_dot_files, listing the folders of write it opens,
so each is written down, and a spec not run before shows it.
Which dot files, and why all of .git?
The ones that run on what you'd do anyway, not on running the project:
.git, .envrc, .vscode and .idea, the hooks folder and included
settings files git's settings name in the folder, and what a hook
manager run from .git/hooks reads (pre-commit's and lefthook's
settings). .husky is kept whole: husky's stubs in .husky/_ run its
scripts in .husky.
Below the top
A hooks folder or included file below the top is
read only, and the folders above it can't be renamed, since a folder
above a mount could be renamed and made again; they stay writable
otherwise, so scripts/hooks doesn't close all of scripts.
All of .git
All of .git, not only its settings and
hooks: a file git reads there, commondir, points it at settings
anywhere, and closing its settings alone would leave that open. That is
also why a commit needs the key.
What stays writable
A project's own build files (a
Makefile, package.json's scripts) stay writable: they run when you
build the project, which runs whatever the sandbox wrote anyway. A dev
container's settings stay too: their commands run when you choose to
open the project in a container.
Why only at the top of a folder it writes?
That's the folder you gave it and will work in. A repository it clones or makes in a folder inside is its work, like any file it writes, and cloning one is common work for an agent. On Linux nothing short of a mount closes a path, a mount needs something there, and a sandbox makes new folders as it runs, so a rule for any depth couldn't hold there; the same rule holds on macOS, so a spec does the same on both.
Why does an agent write its own folder's dot files, but not its files folder's?
An agent's own folder holds only what its harness keeps, out of every other sandbox's reach, and a harness may keep a repository there (a workspace it commits). Nothing of yours runs from it. Its files folder is yours: you open it, and may run git in it. So an agent commits in a repository it makes in a folder inside, not in one at the top of your files folder. A session's folder is theirs, as an agent's own is.
How does Linux keep a missing dot file from being made?
A mount covers only what's there, and a write grant otherwise lets the
program make a .git that wasn't. So the lnk-sandbox that starts
bubblewrap puts an empty folder there for the run, and removes it after,
unless a run still alive holds it: removing one another sandbox has
mounted over undoes that mount. Each is listed in Link's settings with
the runs holding it, and goes only while it's the empty one Link made. A
link is refused: the program could point it elsewhere.
Why isn't a missing .envrc held on Linux?
direnv runs an .envrc only once you've allowed it with direnv allow, and again only after you allow a changed one, showing it each
time: a new one runs nothing by itself. A placeholder there would be a
folder where direnv and you expect a file, so a missing .envrc is left
alone on Linux; one that's there is read only, and on macOS Seatbelt
refuses making one anyway.
What a start costs
A start's checks of the machine are kept for the boot, passes only, opening nothing: bubblewrap, the filter and Landlock still refuse what they can't do. bubblewrap keeps its pid 1: as pid 1, a program gets no signal it has no handler for from inside its namespace, Ctrl-C too, and must reap orphans.
The exec watch and the placeholder lock
On 4 CPUs the exec watch adds about 0.2 ms to an exec and 6 to 13% to throughput at 8 to 16 concurrent loops (four receivers; eight gain nothing), and the placeholder lock about 1 ms to a start, a median 25 µs wait among 16 at once: narrowing either adds states to the code pinning mounts and removing placeholders, for no visible gain.