Security

What every sandbox guarantees, its filtering proxy, and its known gaps. What a harness's sandbox opens, permission by permission, is in agents' security.

Closed by default

What every sandbox keeps closed

  • Deny by default. A sandbox reaches the system's programs and what its policy opens, nothing else.
  • 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. On Linux it also gets an empty home folder, an empty /run and a private /tmp, and sees only its own processes (below).
  • Link's settings (tokens, keys, the sandbox's rules), and the home exit's folder, ~/.config/lnk/vpn, wherever XDG_CONFIG_HOME puts the settings, stay closed to reading and writing, on macOS and Linux, even with read-home or a --read of a folder holding them. 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. So the rules of each macOS policy, ~/.config/lnk/sandbox/<name>.sb, are where nothing sandboxed can edit them. A hidden folder, such as other agents' folders, is closed too, even to read-home and a read of a folder holding it, but for the policy's own folders inside it.
  • 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 policy names the folder (--write-dot-files, [files] write_dot_files): .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 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. 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 are 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. An agent's own folder and a guest's are theirs to write; an agent's files folder is yours, and keeps them read only. check tries each.
  • 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 (by default ~/.local/bin) or anything else there.
  • Other programs. No sandbox reaches other programs' memory or environment, and on Linux not their processes at all.
  • macOS services. No sandbox reaches the clipboard, the screen, Apple Events to other apps, the Keychain, the privacy database or core services.
  • The Linux kernel. A seccomp filter refuses the riskiest system calls inside every sandbox (each).
  • 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), and on Linux 6.3 and newer every memory file the sandbox makes can never be run, so a program copied into one (memfd_create) can't be run, by execveat or through /proc/self/fd. The exec watch (below) 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. Under another program's seccomp listener (a container runtime's), the kernel gives the watch none: the sandbox still runs and says memory files aren't sealed, and check shows the /proc/self/fd way as a LEAK. On older kernels one still runs through /proc/self/fd, and check shows it as a known gap. memfd_create and mapping its memory executable (a JIT) still work.
  • 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. It is 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, the machine's abstract sockets). The sandbox sets TAR_OPTIONS=--no-same-owner, so tar keeps unpacked files as root's instead of failing to change their owner.

lnk sandbox check tries each of these (how).

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, /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 policy 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 policy names, and connecting only to those and its proxy's; with network, 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. Each policy's rules are a file in Link's settings, ~/.config/lnk/sandbox/<name>.sb, so a policy'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, /etc, /opt, /nix, /snap, /sys and /var/lib/ca-certificates), read-only: no other disks, /srv, the rest of /var or other homes.
  • It gets an empty home folder holding only its folders, an empty /run (no session bus), a private /tmp, and its own PID, IPC and UTS namespaces.
  • A hidden folder is mounted over in order: the folders holding it, then the hiding, then the policy's own folders inside it. So Link's settings stay closed under a --read of ~/.config too.
  • Its first program inside is lnk-sandbox sandbox confine, which installs a seccomp filter (x86_64 and arm64) and then becomes the program. Every process in the sandbox runs under it, and it can't be removed.
  • The filter refuses the system calls a sandboxed program doesn't need and kernel bugs are most often found in, as container runtimes do:
    • 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 (below)
  • Each refused call fails with an error; nothing is killed.
  • 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).
  • 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; a guest's session checks it, and refuses without asking.
  • The profile is 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, or with no terminal, lnk prints the two commands, and nothing runs unsandboxed on its own.

What can cross the sandbox

A sandbox is only as closed as what it may talk to: a service it may reach can be made to do more than it's for. So each way out is judged by what it can be made to do, and check tries each one.

  • network: any host and port on the internet. On Linux also every localhost port and the machine's abstract unix sockets, X11's among them. On macOS also every localhost port through ::ffff:127.0.0.1, and the Mac's own address. It writes what any service it reaches keeps.
  • [internet] (--internet-host): any host over HTTP CONNECT or plain http://, or only those listed. Never this machine, its networks or a cloud's metadata service. Agents with network get it unless they have lan; on a box, always.
  • A local model through the proxy: that runtime's inference only. Its admin API gets a 403 from Link, and on macOS its port itself is closed. What it can't reach writes nothing.
  • A hosted model through the proxy: that provider's inference only, with the key added outside. Its server-side tools are removed and URLs it would fetch are refused. The provider keeps what it keeps of requests.
  • A service's secret through the proxy (a Telegram bot's token): that service's calls on the paths its rule allows, with the secret added outside, but for those the rule refuses (Telegram's setWebhook, logOut and close). A bot can message any chat it can, so what it read can go to a chat of anyone's choosing.
  • [lan] (--lan-host), through the proxy: the machines of your network it lists, on any port, by name, address or range, * for every private one: only private addresses (10/8, 172.16/12, 192.168/16, fc00::/7), the carrier-grade range (100.64/10, Tailscale's) only by an exact address. Never this machine, its containers and VMs (the network of any bridge it has: Docker's, libvirt's, a VM app's), a link-local address or a cloud's metadata service, whatever is listed, and an IPv4 address written in IPv6 (mapped, compatible, NAT64, 6to4) or as a URL may write it (3232235777, 192.168.0x1.1) is judged as that address; a listed name that resolves outside your network reaches nothing. On a cloud's machine (AWS, Google Cloud, Azure, by its firmware) *, or a range holding a whole private block (0.0.0.0/0, 10.0.0.0/8, ::/0), is refused, whoever made the policy: the private ranges there are the cloud's network. Dialed from here, never through the upstream. Whatever serves on those machines can be made to do what it does for anyone on your network.
  • [browser] (--browser-host): its hosts are apart from the internet's, so a sandbox reaching one host may still ask to open a page on any host it lists. The page carries what the URL holds, so each one is asked, as open's is.
  • A tool at a URL: that server, called from outside with its headers, each call asking you as any tool's does.
  • A localhost port (--port, or a local model's without the proxy): whatever serves it, with its whole API, a model runtime's pull, create and delete included. check reports it as open.
  • Folders it writes (--write, a harness's home, your files folder): files only. You, lnk bucket and lnk agent send read them, as below.
  • read-home: reads your whole home folder, never Link's settings.
  • open, through the proxy: an https:// page in your browser, after you allow that one page, its whole URL shown. It is asked for at $LNK_SANDBOX_OPEN, behind the proxy's secret, which $BROWSER in the sandbox uses. The page opens with your cookies and logins, so a URL can carry what the program read to a site, or act as you on one you're signed in to: allow only a page you asked for. Never an app, a file, another scheme, or this machine or its network by name (localhost, localhost.), by a name that resolves there when it's asked (127.0.0.1.nip.io), or by address, however a browser reads one written (127.1, 2130706433, 0x7f000001, 0). The browser resolves the name again: one whose answer changes in between is its own.
  • A login's callback (Linux): for a page you allowed whose redirect_uri is a port on localhost, that port on this machine is passed into the sandbox until the first request arrives, or for five minutes at most. Meanwhile anything on this machine that connects to it reaches the program. A port another program already listens on is refused, so the login's answer never goes there.
  • Listening (macOS): without network, only on localhost and only on the ports its policy names, so it can't take another program's port while that one is stopped; with network, on any address and port.
  • developer-tools (macOS): Xcode and its preferences, read only.
  • macOS services every sandbox gets: users and groups, the log, notifications and the temp folder's helper, each only as far as it answers. It can write to the system log.

A sandbox with network can send anything it can read anywhere.

Specs and checks

What a spec can and can't open

A spec is a file, and a file can come from anyone, so lnk sandbox run --spec checks what it opens before it runs:

  • Shown first. A spec not run here before, or one that opens something else since, prints what it opens and asks. Link's settings keep, in ~/.config/lnk/specs/sandbox.toml, each spec's path and a hash of its grants with every path resolved through links and every program as PATH found it, so a changed text or a link moved under a folder it names asks again. A path a link makes elsewhere is shown with where it really is. --yes skips the question.
  • Refused in a spec's file, with the flags beside it: what would open Link's settings or agents' folders, let code the program writes run outside the sandbox (in your home's dot folders, on your PATH, in the system's folders or a project's dot files), widen the spec's own next run, or reach other programs' sockets (each).
  • Link's settings stay closed to every sandbox, spec or flags, to reading and writing: a grant inside them, or a write of a folder holding them, is refused.
  • [localhost] ports = ["*"] is refused: every port of this machine would include other sandboxes' proxies, each another one's way out. So is 40781, the port of main's proxy.
  • [commands] limits which files run, by the kernel: Seatbelt on macOS, Landlock on Linux (5.13 and newer; without it, a spec listing programs is refused). An entry in a folder it may write, or in /tmp, is refused, and so is a folder holding one of those. lnk sandbox check --spec tries a program it doesn't list, and each way a program might run code around the list (below).
  • A refused program is named (Linux). Where [commands] lists programs, and in lnk sandbox learn, 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: only Landlock decides what runs. The call goes on once its path is read, and the refusal is named after, so it may follow the program's own error. 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. learn runs every program, under the spec's other grants. 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.
  • A refused program is named (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. 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. It keeps 10,000 reports at most. A run that ends well with no refusal reported stops reading at once, so a refusal reported after that isn't named. Only Seatbelt decides what runs. learn gives its profile (allow process-exec (with report)), so every program it runs is reported. log stream takes an admin user; for another, nothing is named.

Checking a sandbox

lnk sandbox check runs probes inside a sandbox as the flags or spec given would make it, against bait it sets up:

  • a file with a random secret in the home folder, in Link's settings folder, in /var/tmp and in each hidden folder there is (agents' folders among them); and a listing of ~/.ssh
  • a write into the home folder, and a write into each folder the policy may write, which must work
  • a server on a random localhost port, tried as 127.0.0.1, ::ffff:127.0.0.1 and the machine's address
  • http://1.1.1.1, or behind a proxy, a tunnel to it through the proxy, and asking the proxy for this machine, your network, a host not listed, and IPv4 written in IPv6's forms
  • the folder lnk-sandbox is in, and each dot file that runs outside at the top of each folder it writes
  • a program holding the secret in its environment (ps -E on macOS, /proc/<pid>/environ on Linux)
  • each localhost port the policy allows: whatever serves it is reachable with its whole API, reported as a known gap
  • on macOS, open -a Finder, a search for a throwaway item Link adds to the login Keychain, and a lookup of each system service by name. Those every sandbox gets and those a permission opens count as allowed; the clipboard, the screen, Apple Events, the privacy database and core services never do. lnk-sandbox looks them up inside.

Anything that gets through without a permission allowing it is a LEAK, and the command fails. It removes its bait after. Tests also lnk sandbox run a program that tries to read ~/.ssh, Link's settings and its caller's environment, write the home folder and reach the network.

Your agent's exit: the filtering proxy

Every sandboxed agent with network runs behind the filtering proxy, unless it has lan. On this computer it leaves from here, upstream direct. On a box it's always behind the proxy, lan or not: lnk agent move sets its route, and a new box's setup makes it the box's default, which every agent made there starts with and one already there with no route takes. From a box it leaves either from the box itself (lnk agent exit direct, upstream direct) or only through this computer (lnk agent exit home). Any sandbox can have the proxy instead of the network with lnk sandbox run --internet-host (or a spec's [internet]): everything below but the home exit.

Its only way out

No network of its own

  • Linux: bubblewrap gives the harness a network namespace (--unshare-net): its own loopback, nothing else. Its only way out is a relay inside the sandbox, 127.0.0.1:40781 and its HTTPS_PROXY, to a unix socket where Link's proxy, outside the sandbox, takes HTTP CONNECT and plain http:// requests.
  • macOS: with no network namespaces, the proxy listens on a free port of the machine's loopback. The sandbox's Seatbelt rules, without network or localhost, allow connecting to that port and the policy's own, nothing else: not ::ffff:127.0.0.1, not the Mac's own address. Other programs of this machine, and other users, can connect to that port. Through it they reach the internet as they could directly. Other users get no key and no bot: those verbs take only the placeholder the sandbox was given, a tag of the real key that nobody without the key can make. Nor its tools, its local model or open: their URLs (LNK_TOOL_<NAME>, LNK_LOCAL_MODEL_<PORT>, LNK_SANDBOX_OPEN, and those the harness is set up with) carry a secret of the agent's, which the proxy checks, and the proxy writes neither in its log. So no other user asks you to open a page in the sandbox's name.
  • Programs you run yourself, outside a sandbox, are not kept out. The placeholder and the URL secret are written in the harness's home and Link's settings, readable by your user, and they never change. A program of yours that reads one can use it, as it could read the key itself in Link's settings. Every sandbox hides agents' folders (~/Link/Agents), read-home or not, so a sandboxed program reads no agent's placeholder but its own, or one in a folder you open to it with --read or --write.
  • The proxy refuses what a web page can send, whatever its path: a request to the proxy itself (not CONNECT or http://...) with a Host that isn't this machine's loopback, which a name pointed here (DNS rebinding) carries, or one a browser marks as from another site (Origin, Sec-Fetch-Site). It is refused before it counts toward a pause or the calls a minute, and logged as browser once a minute at most, so a page the user visits can't pause an agent or use up its calls.
  • Anything that doesn't use a proxy has no network at all, and no DNS but its proxy's where it names endpoints (below).
  • The proxy carries TCP, not UDP.

Its sockets, and reaching the harness

  • The sockets are in ~/.config/lnk/agents/.net/<agent id>/<harness> (700), in Link's settings folder. The sandbox opens that folder alone to the harness, and nothing else of the settings.
  • Link reaches the harness for its health checks through the reverse relay: Link listens on the harness's ports on the box's loopback and passes connections in through a second socket, as does a login's callback. The sandbox makes that socket in the folder it writes, so Link connects to it without following a link, and only if it is a socket of the user's: a link the sandbox puts in its place, to the session bus or Docker's socket, reaches nothing.

Home

  • The proxy connects through a SOCKS proxy on a Unix socket on the box, ~/.config/lnk/vpn/exit.sock (the agent's upstream, socks5://<that path>), that this computer offers with ssh -R over Link's SSH connection to the box. That is the vpn plugin's exit, lnk vpn exit <box> home, which lnk agent exit home holds for the agent and which stops once nobody holds it. For a machine that isn't a box, it's ssh -R with your own SSH setup. The socket is the box user's only, in a folder every sandbox hides as it hides Link's settings, even when XDG_CONFIG_HOME puts those elsewhere: the agent can't reach it but through its proxy.
  • An agent on a box whose lnk is from before the socket, and one routed to the exit before it, go through the box's loopback instead, 127.0.0.1:40780, which the exit keeps while any of them holds it.
  • Anything of your user on the machine can use that SOCKS proxy while it's up, and on the port anything at all, so it's your home address for whatever runs there, not only your agent.
  • The connection leaves from this computer; the harness's DNS lookups happen on the box.
  • Fails closed: with this computer off or the SSH connection down, nothing answers on the socket and nothing gets out. The proxy never falls back to the box's own network.
  • This computer as an exit serves only your own agents, through your own SSH connection. The SOCKS proxy on the box is reachable only by programs on your box, and forwards to one on this computer's loopback that reaches only the internet, never this computer or its networks (the VPN's security).
  • What stays direct: the harness's install and configuration, such as downloading a harness, leave from the box, through a proxy of their own limited to the hosts the adapter lists, and lnk itself uses the box's network. Only the running agent's traffic goes home.

Where it may connect

What the proxy refuses

  • It resolves each name itself and refuses loopback, private, link-local, carrier-grade NAT, unique-local, reserved (240.0.0.0/4), benchmarking (198.18.0.0/15) and 192.0.0.0/24 addresses, IPv4 ones written inside IPv6 (mapped, compatible, NAT64 64:ff9b::/96, 6to4 2002::/16) as the IPv4 address they reach (check asks it for this machine, a metadata service and a private address in each form, the compatible ::a.b.c.d too), and every address assigned to the machine whatever its range, such as a server's public address on its own interface. That covers the box, its network and the cloud's metadata service, 169.254.169.254, but not a public address the cloud's NAT holds for the box (known gaps). A name resolving there is refused the same way, so it can't point the exit at this computer either. It keeps a name's addresses for 30 seconds, and checks them on every connection.
  • A request's head must arrive within 30 seconds, and is at most 16 KiB. A call that carries nothing either way for 10 minutes is ended: a tunnel (CONNECT), a plain http:// request, a hosted or local model's, or a Telegram call.
  • It serves at most 512 connections at once on each of its listeners; the next waits for one to end, so a program can't use up the proxy's file descriptors.
  • CONNECT reaches port 443 only, and a plain http:// request port 80 only, but for the endpoints a policy names (host:port, lnk agent allow tcp, --tcp). A list of hosts still applies to them.
  • With a limit (per_minute, lnk agent allow calls, --per-minute), it takes at most that many calls within a minute, of every verb, and answers the rest with a 429 and Retry-After. Those refusals are logged and count toward no pause.
  • A host is only a name or an address.
  • A plain http:// request goes to its host alone: its Host header is set to the host checked, never the client's, the connection closes after one request, and a body in chunks is refused. So a CDN that routes by Host can't be pointed at another site past a list of hosts. Over CONNECT to port 443, with a list of hosts, the TLS ClientHello must ask for the host checked (its SNI), or the tunnel is closed. An encrypted ClientHello, or another Host inside the TLS, can still front another of a CDN's sites, which only the CDN can stop.

A request means one thing to every server

The proxy refuses a request head with a bare \r or \n, since every line must end in \r\n, or any other control character but tab. What follows a head is never read as its head. Some servers, such as Go's (which Ollama is) and Node's, end a line at a bare \n, so a header the proxy checked could otherwise hide another, or hide a second request, such as a pull behind a chat, that its filter never saw. A local model's request with a folded header, or two lengths, is refused too. The parsers are fuzzed: what they pass on is read back as such a server reads it.

Hosts, when listed

With [internet] hosts (--internet-host, lnk agent allow host), the proxy refuses by name any host not listed, before resolving it, with a 403 and no DNS query. Names are compared without case or a final dot. *.example.com covers the names under it, not example.com. An IP address is reached only if listed as one. Listed names still resolve here, and still never reach this machine or its networks.

Named TCP endpoints and git over SSH

  • Git over SSH gets out through GIT_SSH_COMMAND, whose ProxyCommand is lnk-sandbox sandbox connect. It runs inside the sandbox and tunnels through the proxy on its loopback, never anything else, once its host's port 22 is named.
  • On Linux, a named host with a port from 1024 also gets an address of its own in the sandbox, from 127.0.1.1 on, in the sandbox's own /etc/hosts. That file is written in a private folder beside its sockets' folder (<folder>.etc, which no other user can write or plant a link in), out of its reach. The proxy's end in the sandbox listens there and sends each connection to the proxy as CONNECT host:port, which the proxy checks and logs as any other. A port below 1024, an IP address, or macOS: only through a command such as ssh's ProxyCommand. For ssh itself, run $GIT_SSH_COMMAND user@host.
  • An endpoint may hold every name under one domain on one port (*.abc.mongodb.net:27017), never the domain itself; its hosts still have to allow each name. check warns of one over a domain a provider shares among its customers (*.mongodb.net, a cloud region's *.us-east-1.rds.amazonaws.com), which reaches anyone's database.

DNS where a sandbox names endpoints (Linux)

A sandbox whose policy names endpoints gets a resolver: its own /etc/resolv.conf (in the same private folder as /etc/hosts) names 127.0.0.1:53 in its network, where its proxy answers, outside.

  • To listen there, the proxy has bubblewrap wait (--block-fd) until a helper, lnk-sandbox sandbox dns-socket, joins the sandbox's network as the owner of the user namespace bubblewrap made for it, binds 127.0.0.1:53, hands the sockets over and exits. It runs nothing in the sandbox and reads nothing of it. Where joining fails, the sandbox runs without DNS, and says so.
  • A name an endpoint holds, and its hosts allow, gets an address of the sandbox's own: its /etc/hosts one, or one from 127.1.0.1 on for a name under a *. endpoint, where the proxy's end in the sandbox listens on the endpoint's ports (from 1024) on every loopback address. A connection there goes to the proxy as CONNECT to that address, which the proxy takes for the name it gave it, and checks as any. Such a name gets no IPv6 address.
  • A name its hosts allow gets its public addresses, never this machine's, its network's or a metadata service's; they reach nothing but through the proxy. A name whose only address refused is a metadata service's pauses the wire, as connecting to it does. SRV and TXT for such a name, or a service of it (_mongodb._tcp.<name>), get what this machine's resolver answers, as records of that name only. Never for a local network's own names: one label, or under .local, .localhost, .localdomain, .internal, .intranet, .private, .corp, .home, .lan or .arpa (home.arpa, reverse lookups), which this machine's resolver answers from its network (mDNS, a company's zone). With every host allowed, a name under a public domain that only your network's nameserver answers (a company's split view of corp.example.com, its _ldap._tcp records) still gets that answer. An SRV answer's targets are names the program then looks up and connects to, each checked as any. Every lookup, these and a name's addresses, goes to this machine's resolver, never through the upstream: on a box with lnk agent exit home, the box's nameservers see the names, as they do for the proxy's own.
  • Every other name is refused before anything is asked, so a query reaches only the nameservers of names the sandbox may reach anyway.
  • Each query is a dns line in the wire's log. What asks outside counts toward its calls a minute: SRV, TXT, and an A or AAAA query whose name's addresses aren't kept from a lookup in the last 30 seconds; not an endpoint's own name, nor a refused one. Over the limit it's refused (REFUSED). A refused one is logged once a minute at most and never counts toward a pause: looking up a name it may not reach gets the program nothing.
  • A name's addresses come from this machine's resolver as the proxy's connections do, kept 30 seconds and shared with them. At most 16 lookups that wait on a nameserver run at once, for 5 seconds each (SRV and TXT, 10), so slow ones neither stall the proxy's own nor hold up the sandbox's answers for its endpoints. It answers 256 queries over UDP at once and drops the rest, and gives at most 65,000 names under *. endpoints an address for as long as its proxy runs; a name past them gets SERVFAIL.
  • Queries are read as plain DNS of one question; anything else gets an error. An answer over UDP longer than 512 bytes is cut for the program to ask again over TCP. The reader is fuzzed.

The machine's own ports

The ports a policy names, such as a local model's, are reachable inside at the same port, relayed through a third socket. The port is checked outside, and no other port of the machine is reachable. The localhost permission opens nothing more. A port or model a sandbox names gives it its proxy, so on Linux the port is relayed in like any other.

Models and secrets

On the loopback alone

A policy with loopback (a harness's health check) reaches the ports of this machine it names, on its loopback, and nothing else: no proxy, so no key and no wire, no internet and no other port. On Linux it is a network namespace of its own, the ports relayed in through a socket in the folder loopback names (700), by a relay that checks each port outside and carries nothing else. On macOS it is the Seatbelt rules without network or localhost. lnk sandbox wrap refuses it beside a proxy, network, localhost, lan or open.

A local model's port

A model runtime's port (--model, an agent's local model) is filtered there:

  • Each request is read, and only inference passes, by exact method and path: Ollama's chat, generate, embed, tags, show, ps and version, and /v1's chat and completions, embeddings and models.
  • Each is rewritten to close its connection after its response, so the next one is read too, with no protocol switch and one way to tell its body's length.
  • Anything else gets a 403 and never reaches the runtime: pull, create, copy, push, delete, an encoded or relative path.
  • A request for a model's answer (Ollama's chat and generate, /v1's chat and completions) asks for it uncompressed, and its usage is counted as it passes, as for a hosted model (below).
  • The same filter answers on the proxy itself at /_link/s/<secret>/local/<port> ($LNK_LOCAL_MODEL_<PORT>), where the secret must be the proxy's and the port one of the policy's model ports, else a 403.
  • On macOS that's the only way to the runtime: Seatbelt can't filter a port, so the model's port is closed to the sandbox. An agent's harness is set up with the proxy's address, with Host set to the runtime's. Its proxy is on a loopback port fixed per agent, the last of its block: main's is 40781.

A hosted model's key, added by the proxy

An agent's sandbox has this with keys on the wire: by default for an agent whose harness takes the proxy's address (Link Harness, and a harness whose adapter says so), any agent's with lnk agent keys wire. lnk sandbox run adds no hosted model's key. The program reaches http://127.0.0.1:<its proxy's port>/_link/model with a placeholder key: the key's prefix (sk-ant-api03-, ...), link-wire- and a SHA-256 tag of the key, none of the key itself. The proxy:

  • refuses a request without that placeholder as its key, so another program on the machine gets no key added;
  • reads each request: a head of at most 16 KiB, a body with a length (never chunked), at most 32 MB, arriving within 2 minutes, and JSON;
  • passes only inference, by exact method and path: Anthropic's messages, token counts and models; OpenAI's chat completions, responses, embeddings and models; OpenRouter's the same and its completions, under /api;
  • drops whatever credentials, Host and hop-by-hop headers the program sent;
  • removes the provider's server-side tools by allow-list, with MCP servers, containers and OpenRouter's web plugins, :online on a model or any of its fallbacks (models), and refuses OpenRouter's presets (@preset/...), which can bring their own. With a list of hosts, it also refuses a model that searches by nature, or a fallback that does (Perplexity's, OpenAI's search and deep research models). A function or custom tool, Anthropic's bash, text editor, computer and memory, and OpenAI's local shell and patch tools stay, and Link turns none of the rest back on;
  • refuses a request asking the provider to fetch a URL: a web address, in any case, under any key ending in url or _data (image_url, OpenRouter's file_data), in any block. Only the input of a tool's call (tool_use, function_call and the like) is left out, never the request's own;
  • adds the real key and speaks TLS to the provider, checking its certificate against the machine's trust store;
  • answers one request per connection from the program, and keeps its own connection to the provider open for the next, as below;
  • for a request for a model's answer (messages, chat completions, completions, responses), asks for it uncompressed (Accept-Encoding: identity), and reads the usage the answer gives as it passes it on, unchanged and never held back: the numbers of its tokens, kept as six counts for lnk sandbox traffic, never a word of the answer.

The key is read from ~/.config/lnk/sandbox/<name>.model.json (600, in Link's settings, which no sandbox sees), written by wrap and removed when a policy has none. It is never in the sandbox, its environment or a command line. The provider is dialed like any host: never this machine, through the home exit if there is one, whatever hosts the policy lists. Providers: Anthropic, OpenAI and OpenRouter.

A service's secret, added by the proxy

An agent's sandbox has this with keys on the wire, for a Telegram bot's token when its harness speaks Telegram itself: of Link's channel plugins, Telegram's is the one that gives a rule. Each secret comes with its rule, data the plugin owning the service gives (lnk-telegram rule --json, contracts): one host, the paths a call may take, where the secret goes (in a path, or a header), and the paths refused. The sandbox knows no service. The harness is set up with http://127.0.0.1:<its proxy's port>/_link/secret/<name> as the service's root, and in place of the secret a placeholder: link-wire- and a SHA-256 tag of the secret, after the part the rule says isn't secret (a bot's id, digits only). The proxy:

  • refuses a call without that placeholder in its path or header, so another program on the machine can't use the secret;
  • reads each request's head, as for any request, and passes only GET and POST (or the rule's methods) of the rule's paths, taken as sent: a name of letters and digits (Telegram's /bot<token>/<method>), or a path of plain parts (/file/bot<token>/<path>), no encoded, relative or empty part;
  • refuses the rule's refused paths, whatever their case (Telegram's setWebhook, logOut and close, which would hand the bot's updates, or the bot, elsewhere);
  • puts the real secret in the path or header, drops whatever Host, Authorization and hop-by-hop headers the program sent, closes the program's connection after its answer, and passes the body as it comes, long polls and uploads too;
  • speaks TLS to the rule's one host (a name, never a wildcard or an address), checking its certificate against the machine's trust store, and keeps that connection open for the next call, as below, when the request's body has one length. A chunked upload is the last call on its connection.

Connections kept to a provider and to a secret's service

The proxy keeps its connections to a hosted model's provider and to a secret's service (Telegram's API) open between calls, so a call doesn't pay for DNS, TCP and TLS each time:

  • up to 4 per host, for 30 seconds, in the proxy of one sandbox, so never another sandbox's;
  • only once the answer was read to the end its head gave, with nothing after it; any other is closed;
  • only for a request whose end the proxy wrote itself: a hosted model's body, which it sends with its own length, or a secret's call whose one Content-Length it copies exactly. A request with a Transfer-Encoding, or a length said twice, closes its connection.

Each secret is read from ~/.config/lnk/sandbox/<name>.secret-<secret>.json (600), written by wrap and removed when a policy no longer has it. It is never in the sandbox, its environment or a command line. The host is dialed like any host, whatever hosts the policy lists. The harness starts an agent only once every channel's plugin has said whether it has a rule: a channel whose plugin can't say never hands its secret to the harness.

Watching and stopping the wire

The wire's log

lnk sandbox log reads ~/.config/lnk/sandbox/wire.log (600, in Link's settings, which no sandbox reads or writes). Every proxy writes a line per call: the time, the sandbox's name, the verb, the host and port (or the provider, or the machine's port), and whether it was allowed, refused or failed. When an allowed call ends, another line gives the bytes each way.

  • The verbs are connect (HTTPS), tcp (another port), http, mcp (a tool's call), model (hosted), local-model, secret (a service's call, its secret added, such as a Telegram bot's), port (this machine's, Linux), dns (a name looked up, Linux, where it names endpoints), open (a page in your browser), callback (a login's answer let in, Linux), browser (a web page's request, refused, once a minute at most), pause and unknown.
  • Never a path, a header or a body.
  • The names in it are what a sandbox asked for: cut at 300 characters, and shown without control characters.
  • Past 8 MB it becomes wire.log.1, the older ones move up to wire.log.4 and the oldest is dropped, so 40 MB of every sandbox's calls in all.
  • A wire refused while paused, stopped or over its limit logs that once a minute, with how many it left out, so a sandbox can't push its own history, or other agents', out with refusals that cost it nothing.
  • Whatever its calls end in, a wire logs at most 120 lines a minute of each outcome: allowed, ended, refused, failed. It leaves out the rest, and its next line of that outcome says how many (and 880 more failed since). A flood of calls that fail, or that go through, fills the log no faster than that, and never hides another outcome's first lines. What it leaves out still counts toward a pause.
  • Link tells each proxy where the log is, since the proxy runs with the program's environment, whose HOME is a harness's own folder.
  • It holds a proxy's calls, not what a sandbox with network does directly.
  • Beside it, each running proxy writes what it has carried, two byte counts and its sandbox's name, and for one that carries a model six counts of its requests and tokens, to traffic/<pid>.json, every 5 seconds while they change, and a model's counts as soon as they do (lnk sandbox traffic); a proxy's file goes once it's gone.
  • When its program exits, a proxy logs each call it still had open as ended, with the bytes it carried. A proxy killed outright, not stopped, writes no ended line for them.

The kill switch and pausing

  • lnk sandbox stop: while ~/.config/lnk/sandbox/stopped exists, every proxy refuses every call with a 403 and ends the calls it has open within a second. Each proxy looks at the file itself, for each call and every second while one is open, so no sandbox can hold it up or undo it. Then stop runs what asked to stop with it (lnk sandbox on-stop, kept in Link's settings, which no sandbox reaches): lnk agent stop for each agent here, which every agent's start registers, since an agent with lan has no proxy. stop --off removes the file; agents stay stopped until started.
  • stop closes the proxies, not a sandbox's direct network (an agent's lan, a guest's --allow network): those stop with their program.
  • lnk sandbox pause <name>, and the proxy itself: while ~/.config/lnk/sandbox/paused/<name> exists, that sandbox's proxy refuses every call and ends those open, as with the kill switch, until lnk sandbox resume <name> removes it.
  • Each proxy watches its own calls and writes that file on a pattern no normal work makes: 30 refusals within a minute, 20 different targets refused within 10 minutes, or a call to a cloud's metadata service (169.254.169.254, metadata.google.internal), however it's written: by the address a name or number resolves to. It logs a pause line and shows a notification where it can. Where it can't, on a box, lnk agent status and lnk sandbox paused say so.
  • A pause never fails open: a proxy tells it's paused by the file being there, even if it can't read why, and one it couldn't write (out of file descriptors, say) holds in the proxy until it's written. A proxy has a limit of connections on each listener, and a listener that fails to accept one waits a moment and goes on, so a program can't use up the proxy's descriptors or stop its listeners.
  • Nothing inside a sandbox can write the file or undo it. Pausing narrows an agent attacking what the internet reaches, and brings it to light; it doesn't prevent it.

Tools and asking

Tools that run outside

lnk sandbox tool add, lnk agent allow tool and --tool: an MCP server you add runs outside every sandbox, with your logins. That's its point, and what it can do is yours to choose.

  • Its definition is ~/.config/lnk/sandbox/tools/<name>.json (600), read by the proxies outside: its command, folder, a snapshot of a few variables of your environment, and --env, which may hold a token.
  • It runs with your PATH, HOME, user, locale, TMPDIR, SSH agent and desktop session (DISPLAY, WAYLAND_DISPLAY, D-Bus) as they were when added, and nothing else of your shell's.
  • A sandbox reaches only the tools its policy names, at /_link/s/<secret>/mcp/<name> on its proxy ($LNK_TOOL_<NAME>), the secret its proxy's, never logged: one request per connection, a body of 8 MB at most, one JSON-RPC message, no batches.
  • Each session a client starts is a server of its own; a sandbox keeps at most 8, and at most two start at once (the sandbox's settings' tool_sessions and tool_starts): the rest wait their turn, and one still waiting after 60 s gets a 429. A tool added again with another command, folder or --env (a token replaced) ends the sessions started before: their next request is answered as gone, and the client's next session runs as the tool is now.
  • The client's own capabilities are removed from initialize, and what the server asks of the client is refused outside, so no server reaches the sandbox's folders or a model through it. Its notifications stay outside. Everything else, such as listing tools, prompts and resources, passes as it is.
  • Each tools/call goes through only if the tool's rules allow it (--allow, or --ask none). A server's hints, such as MCP's readOnlyHint or destructiveHint, are shown and never skip asking. Otherwise the proxy asks you, below.
  • A message without an id reaches the server only if it's one of MCP's own notifications (initialized, cancelled, progress, roots/list_changed): a tools/call sent as one, which some servers would run without an id, is refused.
  • Removing a tool ends its sessions: the next call is refused.
  • Each request is a line in the wire's log (mcp, the tool and a call's name, never its arguments or answer), and refused calls count toward a pause.
  • A tool at a URL (--url) is a stdio server of Link's own, lnk-sandbox sandbox mcp-remote, run outside like any other. It reaches https://, or http:// on this machine only, and sends each message as streamable HTTP's POST with the server's session. Its headers, a token among them, are in its definition's environment (LNK_MCP_HEADER_<n>), never in a sandbox or on a command line.

Asking you

The proxy asks one question at a time, allows that call once, and two minutes without an answer is no. Unanswered, the refusal names the --allow that lets it through. It asks in the first way there is:

  1. A dialog: macOS's osascript, passed the text as an argument, never as script; or zenity --no-markup or kdialog with the text escaped. They get the desktop session's variables that wrap passes the proxy (--desktop), never the sandbox. They, a notification and your browser are found in the system's folders (/usr/bin, /bin, /usr/local/bin), never on the program's PATH, which can name a folder the sandbox writes.
  2. Its controlling terminal, with a random code of four letters to type back. Anything else refuses it.
  3. A waiting question in ~/.config/lnk/sandbox/asks/ (600, a random id), said in a notification where one can be shown and at the top of lnk sandbox log. lnk sandbox answer answers it. The first answer is final: it's linked in place, so never a second one or a half-written one. Both files are removed once it's over; those a gone proxy left are removed by the next asks.

Nothing in a sandbox draws on the screen or reaches Link's settings, so it can neither fake a dialog nor answer one or a waiting question. On a terminal, with no desktop or over SSH, the program shares it: it can type (macOS) but not read what's written, so it can't answer, but it can write over the question's text, so what the question names can be faked there. The code you type still allows only the call that asked. A dialog or lnk sandbox asks shows the question as it is, without control characters or those that hide text or turn it around (bidi, zero-width). Each line is cut at 600 characters, and a question cut short says so: allowing it allows what you can't see.

Link treats everything it reads back from a sandbox as hostile.

  • A harness's log (lnk agent logs), kept outside its home: a regular file only (a link in its place is refused), read from where Link recorded the start, at most its last 4 MiB, shown without control characters. With -f, a line without an end comes in 64 KiB pieces.
  • How to run the harness, which its adapter's command says, run in its home: only plain absolute paths that neither are nor hold its home, your files folder, your home folder, Link's settings, /tmp or a folder of other programs' sockets (/run, /var/run, $XDG_RUNTIME_DIR), checked as written and resolved. Like a spec's, none it writes is the system's (/usr, /opt, /etc, ...) or is, holds or is in a folder on your PATH. Service names that are only names; no HOME, TMPDIR, LD_* or DYLD_*.
  • What to move (lnk agent send) and back up (lnk agent backup), from its adapter's state and its home: paths under its home only, links carried as links. Each name is opened from its folder's handle without following a link, so one swapped in during the read isn't followed either.
  • A move's stream (lnk agent receive) and a backup (lnk agent restore), from another machine's send or the files folder's .lnk-backup: unpacked in a folder out of every agent's reach. An entry with .. is skipped, a leading / dropped, and one reaching out through a link, or a hard link to a file outside, refused. Links stay links, and are moved, never followed.
  • Your files folder (lnk bucket push): links are skipped, files copied as data.
  • Requests to its proxy: as above. Names are resolved outside.
  • A guest's folder: Link reads nothing there. guest remove --delete-folder deletes it without following links.
  • The wire's log: written outside the sandbox, names cut at 300 characters, shown without control characters.

Guests

  • A guest's key goes into ~/.ssh/authorized_keys as restrict,pty,command="<lnk> sandbox enter <name>" <key> lnk-guest-<name>. sshd runs nothing but that command: a shell, a command, scp or sftp all run in the guest's sandbox. restrict turns off port, agent and X11 forwarding, each a way around the sandbox, and ~/.ssh/rc.
  • A key that already opens this machine another way is refused, since sshd uses the first line that matches.
  • Guests run as your user account, confined by the sandbox alone.
  • On Linux a session with a terminal keeps it: bubblewrap's new session is for a terminal someone else reads after, and theirs is their own. scp, sftp or a command without one gets a new session.
  • They write only their folder (~/Link/Guests/<name>, 700, their HOME), read nothing of your home folder, and never an agent's folder (~/Link/Agents), even with read-home. They have no network unless allowed.
  • --folder can't be your home folder or hold it, be in a dot folder or Library of it or in Link's settings, or be, hold or be in agents' folders (~/Link/Agents).
  • With --spec, their sandbox is what the spec opens, besides their folder, refused and asked about as any spec is (spec.md). The spec is kept in guests.toml with its paths absolute and checked again at each session, so changing its file afterwards changes nothing.
  • Your guests are in ~/.config/lnk/sandbox/guests.toml (600).

Gaps and limits

Known gaps

check reports these as open:

  • On macOS, with network: every localhost port, through ::ffff:127.0.0.1, IPv4 localhost through an IPv6 socket. Seatbelt doesn't count it as localhost, so network's "any address" covers it, and a rule names only localhost or any host, with (remote ip6 ...) matching IPv4 too, so Seatbelt can't close it. Also the Mac's own LAN address, which isn't localhost to Seatbelt either: a server listening on every address is reachable through it.
  • On Linux, with network: every localhost port, since bubblewrap can't tell ports apart and the sandbox shares the machine's network. Also the machine's abstract unix sockets, which belong to the network namespace: X11's (@/tmp/.X11-unix/X0, which a desktop's X server may trust for the same user without a cookie, to read the screen and type keys) and some session buses'.
  • A localhost port its policy names (--port, a harness's own ports) is open to that service's whole API, when something serves it.
  • On Linux, a missing .envrc isn't held: a sandbox can make one. direnv runs a new or changed .envrc only after direnv allow, which shows it.
  • On Linux, where a spec lists programs ([commands]), code run around the list:
    • the program loader, which every program needs, runs any file the sandbox can read (ld.so ./program): it has to stay runnable for every dynamically linked program, so it can't be closed;
    • a listed program loads a library the sandbox wrote when LD_PRELOAD names it: the variable is cleared when the sandbox starts, but a program can set it for a child it runs;
    • on Linux older than 6.3, a program copied into a memory file (memfd_create), which is in no folder.

With [internet] instead of network, the first two are closed and the others remain. These check doesn't report:

  • [commands] limits which files are run, not what code runs: a listed interpreter (sh, node, python3) runs any script it's given, and on macOS a listed program that isn't Apple's loads a library the sandbox wrote when DYLD_INSERT_LIBRARIES names it (cleared when the sandbox starts, but a program can set it for a child). On Linux, running any file descriptor (execveat with AT_EMPTY_PATH) is closed where a spec lists programs, and so, on Linux 6.3 and newer, is running a program copied into a memory file; check shows which are blocked on this machine.
  • A project's dot files that run outside are read only at the top of each folder a sandbox writes, and nowhere else. A sandbox can make a .git in any folder inside (src/.git, with a core.fsmonitor), and git run there, by you or by a shell prompt that shows git's status when you cd in, runs what it wrote, outside. A repository it clones or makes in a folder inside is its own, hooks and settings: running git there runs what it wrote. A folder holding your projects (--write ~/code) keeps none of theirs. Where --write-dot-files names a folder, it writes them all. A script of the project that your git settings already run (a filter's, an alias's) is the project's, as its Makefile is.
  • On macOS, an [internet] tcp endpoint (--tcp) is reached only by a client that connects through a command, as ssh's ProxyCommand does (git over SSH). A database client such as psql, or a driver, can't reach one there: with no network of its own, a name can't be given an address of the sandbox's, and Seatbelt can't send a connection elsewhere.
  • A hook manager's settings that aren't there (.pre-commit-config.yaml, lefthook.yml) aren't held: a sandbox can make one. A hook manager runs one only where its stub is in .git/hooks, which is read only, and its install makes the settings when there are none.
  • On Linux, removing one of the empty placeholders made in a dot file's place while a sandbox runs (git clean -d does) lets that sandbox make it. A sandbox killed outright leaves its placeholders until the next sandbox on the folder ends.
  • On macOS, any program or user of the machine can send the proxy calls without a browser's marks, such as CONNECT to a host it refuses. Those count as the sandbox's: 30 refusals in a minute pause its wire until lnk sandbox resume, they use up its calls a minute and its 512 connections, and they are in the wire's log under its name.
  • A hosted model that searches by nature, such as Perplexity's sonar models or OpenAI's *-search-preview on OpenRouter, fetches pages for the sandbox whatever the proxy removes. With a list of hosts and the key on the wire, the proxy refuses it (below); with the key in the harness, it can't see it, and lnk agent start says so. Name a model that doesn't.
  • The proxy knows the ways a provider takes a URL to fetch by their keys (any ending in url or _data); a provider's new way under another name isn't refused until Link knows it.
  • A port [localhost] ports names (--port) is reached whatever listens on it, Link's own among them: on macOS another agent's proxy (each agent's on a fixed port), or a sandbox's proxy on a free port; an agent's harness served on this machine's loopback (its gateway); the home exit's SOCKS proxy on a box, when it's on the port (40780); a model lnk model serve serves. The sandbox can't tell those ports from others without reading other plugins' settings, so only 40781 is refused: name only ports you know.
  • A public address that isn't on one of the machine's interfaces isn't refused by the proxy. On a cloud box behind the cloud's NAT (AWS, Google Cloud), its public IPv4 is such an address, so a sandbox there reaches the box's own services on it where the box's firewall lets the internet in. So does a home router's public address, where the router answers from inside. Keep a box's firewall closed to what shouldn't be public.

Limits

  • macOS and Linux only. On Linux: bubblewrap, and user namespaces the kernel allows (Ubuntu's rule).
  • On Linux, debuggers and tracers (gdb, strace) don't work inside a sandbox, since ptrace is refused. Run by root, what needs one of root's capabilities fails inside, such as listening on a port below 1024, and a folder it's given that another user owns (a project mounted as uid 1000 in a container run as root) can't be written: its user namespace maps only root, so those files belong to nobody inside. Programs that need io_uring use their fallback, and nothing inside can make a container of its own: Docker, Podman, or bubblewrap again.