Wall confines the programs you run

link-sandbox-confine: the sandbox's wall, what confines a program as tightly as it can still run on each OS, behind one Backend. Only lnk-sandbox links it (Security).

What every sandbox keeps closed

A sandbox reaches the system's programs and what its spec opens, nothing else: deny by default. lnk sandbox check tries each of these (how).

A cleared environment

A sandbox's environment is cleared but for PATH, HOME, TERM, COLORTERM, TZ, the locale and who you are, so no token or SSH_AUTH_SOCK from your shell reaches it.

The machine folder, ~/.lnk

Link's settings, tokens, keys, the sandbox's rules and the home exit's socket stay closed to reading and writing, on macOS and Linux, even with --read ~ or a --read of a folder holding them, but for a Link program in ~/.lnk/bin read by the file alone. So the rules of each macOS sandbox, under ~/.lnk/sandbox/, are where nothing sandboxed can edit them.

Grants in or over it

A --read or --write inside them is refused, and so is a --write of a folder holding them (--write ~), whose program could move them aside and write in their place.

Hidden folders

A hidden folder, such as other agents' folders, is closed too, even to --read ~ and a read of a folder holding it, but for the spec's own folders inside it.

A grant reached through a symbolic link any user but root owns is refused, naming the real path, wherever the link sits: the grant's own root, a folder between, or one planted inside another grant. So a program that writes a team's shared folder or an agent's files folder can't put a link where another sandbox's grant is and bind ~/.ssh, another agent's keys or a folder on PATH, nor can a grant be such a link itself.

Only root owns the system's own links (/tmp to /private/tmp), which no other user can plant or swap, so those are followed. This is the one place a raw grant is resolved for a backend, so no wall follows a planted link, whichever caller reached it: a spec's run, or an agent's harness wrapped behind it.

A snapshot

A spec's file grants are captured before validation and the first-run permission question; the checks, remembered permissions and wrapper use that same snapshot, so a directory replaced after consent cannot redirect it, nor can a link make a protected path resolve elsewhere.

Pinned at launch on Linux

Every mount source is opened again at launch, component by component without following links, and held by descriptor until bubblewrap mounts it, so a source replaced or renamed before launch is refused, not redirected; missing optional sources are omitted, and these descriptors close before the target starts.

What runs outside from a project

At the top of each folder a sandbox writes, a project's dot files that run outside it are read only, unless its spec names the folder (--write-dot-files, [files] write_dot_files). An agent's own folder and a session's are theirs to write; an agent's files folder is yours, and keeps them read only. check tries each.

Which

.git, all of it (its settings run on any git command, its hooks on a commit or a checkout, and a file in it can point git at settings elsewhere), .envrc (direnv runs it on cd), .vscode and .idea (an editor's tasks, settings and run configurations), the hooks folder and included settings files git's settings name in the folder, and what a hook manager reads there (husky's .husky, .pre-commit-config.yaml, lefthook's lefthook.yml and its other names, .lefthook).

.husky, and folders below the top

.husky is kept whole, since the stubs in .husky/_ run the scripts beside them. A hooks folder or included file below the top is read only, and each folder above it, such as scripts for scripts/hooks, can't be renamed or removed, though what else is in it is written. Where one of those folders isn't there, the first part is kept whole instead.

Missing ones

A hook manager's settings are held only where they're there, and on Linux a missing .envrc isn't either (known gaps). On macOS, Seatbelt refuses writing each other path, there or not, in any case of its letters.

On Linux

Each is mounted read only; one that isn't there is made empty for the run (a folder, or a file for included settings) and removed when it ends, and a link in its place refuses the sandbox. A mount can't be renamed, removed or linked to, and a folder above one below the top is bound over itself for the run, so .git, .husky or scripts can't be moved aside and made again.

The placeholders

They're made and removed by name in their folder, opened without following a link, so another sandbox swapping a folder for a link meanwhile can't point them elsewhere.

What sits beside lnk-sandbox

Every sandbox runs lnk-sandbox (to start under the seccomp filter, and as ssh's ProxyCommand behind the proxy), so it reads that one file, never the folder it's in (~/.lnk/bin, hidden with the machine folder) or anything else there.

Other programs and macOS services

No sandbox reaches other programs' memory or environment, and on Linux not their processes at all. No sandbox reaches the clipboard, the screen, Apple Events to other apps, the Keychain, the privacy database or core services.

Descriptors left open (Linux)

Bubblewrap passes on every file descriptor what started it left open, such as a CI runner's pipes to its worker, which every step inherits. The sandbox's first program closes all but stdin, stdout and stderr before the program starts, so the program can't write or read them. On macOS they stay open (known gaps).

The Linux kernel

A seccomp filter refuses the riskiest system calls inside every sandbox on x86_64 and arm64 (each); elsewhere on Linux there is none (known gaps).

Running code the list doesn't name

Where [commands] lists programs, a program can't run code it wrote by giving exec a file descriptor instead of a path: the seccomp filter refuses running one (execveat with AT_EMPTY_PATH, fexecve). Mapping memory executable for a JIT stays open, and unrestricted executable policies keep memfd_create.

Memory files sealed

With a working listener and Linux 6.3 or newer, memory files are sealed non-executable, so a program copied into one (memfd_create) can't be run, by execveat or through /proc/self/fd. The exec watch makes each one itself, as asked but sealed non-executable (MFD_NOEXEC_SEAL), and hands it to the program as the call's answer. A memory file of huge pages (MFD_HUGETLB) is refused, since Linux doesn't keep its seal: its mode could be made executable again.

Without the seal

If the kernel cannot seal memory files, or the watch cannot obtain a listener, the base filter refuses memfd_create with ENOSYS and says why. This includes running under another listener without assuming that it seals memory. Applications using memory files can keep doing so with a sealing listener, and otherwise need a fallback.

Root's capabilities (Linux)

Run by root, the sandbox drops all capabilities, in a user namespace of its own, so its program can't override a file's permissions, reach into another process, or open the exec watch's descriptors. The sandbox sets TAR_OPTIONS=--no-same-owner, so tar keeps unpacked files as root's instead of failing to change their owner.

Still root's user

It reads and writes root's files among its grants, and a service that trusts its caller's user, such as a unix socket only root may use, takes it for root wherever the sandbox reaches it (with [network] mode = "host", the machine's abstract sockets).

The sandbox on macOS

A Seatbelt profile, run by /usr/bin/sandbox-exec, denies everything, then allows:

  • Reading the system's programs and libraries (/usr/bin, /usr/lib, /System, ...), selected public system settings from /private/etc, and Homebrew's programs, libraries and certificates. Not Homebrew's var/, with local databases and logs, nor its servers' settings in etc/.
  • The few devices programs use (/dev/null, /dev/urandom, /dev/tty, ...), not your other terminals.
  • Reading and writing the folders its spec names.
  • stat() on any folder: it can tell a folder exists, not what files are in it. Never stat() on files outside its folders, so it can't tell whether other files exist.
  • Unix sockets only in its folders.
  • Listening on localhost only, on the ports its spec names, and connecting only to those and its proxy's; with [network] mode = "host", any address (what can cross).

Nothing else

No other localhost server (databases, dev servers, other apps' APIs), no Keychain service, no LaunchServices, no other network. Named POSIX shared memory and semaphores are closed: their global names are outside the sandbox's file grants.

Public system settings

They use the same allowlist as Linux; /private/etc as a whole, SSH host keys, master.passwd, and private certificate keys are closed unless the spec explicitly grants a file.

Its port

A loopback proxy binds its port before starting its program. A dynamically assigned port is handed to that program's proxy and route variables only once the listener is owned. Its generated Seatbelt profile is private and unique to the run, so concurrent starts cannot substitute one another's proxy ports.

Launch files

Deferred launch files must be private regular files owned by the caller, with no symlink at the file and no ancestor another untrusted user can replace. Each initial profile and each profile prepared after binding is unique, so concurrent wraps with the same sandbox name stay independent.

Its process group

The proxy gives its program a process group of its own, forwards stop signals to that group, and kills remaining group members when the direct program ends, before releasing its listener; a terminal run gets the terminal and returns it.

A process that leaves it

A process that leaves that group, or outlives a proxy killed without cleanup, keeps Seatbelt's rules, and a later run's proxy at the same port takes no CONNECT or http:// request from it: each wrap's secret, in HTTPS_PROXY alone, is required of every one.

Its rules' file

Each sandbox's rules are a file in Link's settings, under ~/.lnk/sandbox/, so a sandbox's name is letters, digits and dashes only.

The sandbox on Linux

bubblewrap builds its root from the system's folders only (/usr, /bin, /sbin, /lib, /lib32, /lib64, /libx32, /opt, /nix, /snap, /sys and /var/lib/ca-certificates), read-only: no other disks, /srv, the rest of /var or other homes.

Of /etc

It shows only what programs read to load, find users and names, check certificates, tell the time and start a shell, read-only, a link as the same link. /etc is a folder of the sandbox's own, made read-only once all of it is there: nothing inside writes it, root included. A program that needs another file of /etc is given it by a spec's read.

What it shows

alternatives, bash.bashrc, bashrc, ca-certificates, ca-certificates.conf, crypto-policies, debian_version, fonts, gai.conf, gnutls, group, host.conf, hosts, inputrc, java-*, ld.so.cache, ld.so.conf, ld.so.conf.d, locale.alias, locale.conf, localtime, lsb-release, magic, magic.mime, mime.types, nanorc, networks, nsswitch.conf, os-release, passwd, perl, pki, profile, profiles, protocols, python*, redhat-release, resolv.conf, rpc, services, shells.

And

ssh/ssh_config, ssh/ssh_config.d, ssh/ssh_known_hosts, ssl, static, system-release, terminfo, timezone, vim, vimrc, xdg and xml. Anything named private in them, at any depth and a link so named too (ssl/private, pki/tls/private), isn't there.

What it doesn't

Not shadow, gshadow, SSH's host keys, /etc/link, WireGuard's or NetworkManager's settings, which a sandbox run by root could read whatever their modes; not what's put there by hand (environment, profile.d, pip.conf, npmrc, fstab), nor what can hold a token (gitconfig, wgetrc, nix); not machine-id.

Its home, /run and /tmp

It gets an empty home folder holding only its folders, an empty /run (no session bus), and its own PID, IPC and UTS namespaces. Every folder any user writes, /tmp, /var/tmp and /dev/shm, is its own and empty: it sees nothing another program left there, and leaves nothing for one. (On macOS /var/tmp is closed, and of /tmp only the sandbox's own names are open.)

Ports and hidden folders

It reaches none of this machine's loopback ports but those its spec's [localhost] names. A hidden folder is mounted over in order: the folders holding it, then the hiding, then the spec's own folders inside it. So Link's settings stay closed under a --read of ~/.config too.

Its first program

lnk-sandbox sandbox confine closes every descriptor past stderr that it was handed, installs a seccomp filter (x86_64 and arm64), and, where the kernel has Landlock, holds the file grants again inside the mounts, a second wall refusing any non-granted path even were a mount wide, then becomes the program. Every process runs under it, and it can't be removed.

The filter

It refuses the system calls a sandboxed program doesn't need and kernel bugs are most often found in, as container runtimes do. Each refused call fails with an error; nothing is killed.

  • io_uring (ENOSYS, so libuv and others fall back)
  • BPF, userfaultfd, perf events
  • the key store (keyctl, add_key)
  • ptrace and reading other processes' memory, so gdb and strace don't work
  • mounting and the new mount API
  • new namespaces: unshare, setns, clone with a namespace flag; clone3 gets ENOSYS so libc falls back to clone
  • files by handle, loading kernel code, the clock, rebooting, swap
  • iopl, ioperm and modify_ldt (x86_64)
  • TIOCSTI and TIOCLINUX, which type into a terminal
  • every call of another architecture (i386, x32)
  • a few more no program here needs, such as acct, quotactl, syslog and process_vm_writev; and, where [commands] lists programs, running a file descriptor (above)

User namespaces

Where bubblewrap is 0.8 or newer, it also refuses user namespaces itself (--disable-userns). lnk sandbox check tries the calls a program can make unprivileged, where python3 is there to make them: a user namespace, io_uring, userfaultfd, keyctl and ptrace.

Ubuntu's rule for user namespaces

Ubuntu 23.10 and newer let only programs AppArmor names make the user namespaces bubblewrap needs (kernel.apparmor_restrict_unprivileged_userns).

lnk sandbox ready

When that stops the sandbox, lnk sandbox ready offers, in a terminal, to add /etc/apparmor.d/lnk-bwrap with sudo. lnk agent start, lnk agent harness check, and lnk sandbox run, check and learn run it first; an SSH session (enter) checks it, and refuses without asking.

The profile

It's for bubblewrap alone: unconfined as before, with the right to make user namespaces added. Every local user's bubblewrap gains that right, as Ubuntu gives Flatpak's. The kernel attack surface the rule shields from programs in general stays shielded.

Declined

Declined, or with no terminal, lnk prints the two commands, and nothing runs unsandboxed on its own.

Naming a refused program

Where [commands] lists programs, and in lnk sandbox learn, the wall says which file the list refuses, or records it for learn. Naming is advice: only Landlock, or Seatbelt, decides what runs.

On Linux

The sandbox's first program (lnk-sandbox) stays as the parent of what it runs, and a seccomp filter hands it each exec before it happens. It reads the path from the calling program's memory, says which file the list refuses (or, for learn, records it), and lets the call go on unchanged. learn runs every program, under the spec's other grants.

After the program's own error

The call goes on once its path is read, and the refusal is named after, so it may follow the program's own error. Four threads take the calls, each one whole, so programs starting at once don't wait in turn; what learn lists of them may come in either order.

What it can't name

A program can show it one path and run another, which misleads only the message, and one that keeps its memory from being read (non-dumpable, as ssh makes itself) isn't named.

Not dumpable

The watch makes itself not dumpable, so no program in the sandbox opens its descriptors (the seccomp listener, learn's pipe) through /proc/<pid>/fd or pidfd_getfd, run by root too, since the sandbox has none of root's capabilities.

On macOS

Seatbelt reports each program it refuses in the system log, and lnk-sandbox, outside the sandbox as the parent of sandbox-exec, reads them with log stream while it runs, taking only Seatbelt's own lines (their sender), never one another program wrote in its words. log stream takes an admin user; for another, nothing is named.

Whose report

A report names a process and its id, not the sandbox, so it's taken for this run's when the process is in the run's tree or already gone: a program another sandbox refuses at the same moment may be named too.

How many

It keeps 10,000 reports at most, and once the run has started only those taken for its own, so other sandboxes' reports never take their room. A run that ends well with no refusal reported stops reading at once, so a refusal reported after that isn't named.

For learn

learn gives its profile (allow process-exec (with report)), so every program it runs is reported.

Docs

  • Decisions: Why the sandbox's wall, link-sandbox-confine, works the way it does (The Wall)