Decisions

Why the sandbox works the way it does.

Why its spec is what it is: Specs Decisions.

The plugin and what it closes

Why is the sandbox a plugin of its own?

One sandbox serves everything: lnk-sandbox. Every harness runs in it with a policy of its own, its folders, ports and permissions, and so can any program or person. What one harness needs that another doesn't is its policy, never a second sandbox. It is a plugin the parts share, like lnk cloud, not a part of its own.

How does a harness use the sandbox?

It asks lnk sandbox wrap for each command it runs, the installer, configure and the harness itself, and runs the command it gets back rather than running through the sandbox plugin. The background service then doesn't depend on the plugin at run time, and the caller keeps what runs, where, and with which environment. The sandbox adds what no policy chooses: Link's settings closed.

How does the sandbox get installed?

With every harness: harness needs sandbox, and lnk plugin add follows needs through. A harness plugin installed before the sandbox was its own adds it on first use.

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.

How does hiding a folder work?

A hidden folder is closed whatever else is open, but for what the policy 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 read-home, and the policy'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.

One wire out

Why is every way out a verb on one wire?

A sandbox is only as closed as what it's allowed to talk to. A hole left open on purpose, to a package mirror, a local API or an app launcher, turns a bug in that service into the way out, and a place to write to into a place to persist. So a sandbox gets one wire out, its proxy, and each thing it may do outside is a verb on it, implemented by small code of Link's, never raw access to a service: chat through the proxy rather than a tunnel to a model runtime's whole API. Verbs are HTTP paths on the proxy's one listener (/_link/model, /_link/s/<secret>/open, /_link/s/<secret>/mcp/<name>), since harnesses already speak HTTP to a base URL, and one listener keeps one log and one set of limits. Each verb is off unless the policy names it, has no admin form (nothing that installs, deletes or configures a service outside), fails closed, and has its parser fuzzed.

Why does a policy get the filtering proxy instead of the network?

Any policy can have the proxy instead of network. On Linux it gets a network of its own; on macOS the proxy's port is the only one Seatbelt allows. The proxy refuses this machine by any of its addresses, its networks and a cloud's metadata service. It tells the machine's addresses by binding, whatever the range, since a server's public address on its interface is the machine too. The policy's localhost ports are relayed in, so a local model still works.

Why are hosts refused by name, before any lookup?

A name not on the list gets a 403 and no DNS query, so a refused name leaks nothing. *.example.com covers only the names under it. Listed names still resolve here, and still never reach this machine.

Why is a request head with a bare line ending refused, not cleaned?

A bare \r or \n, or another control character, means the proxy and a server that ends a line at a bare \n, such as Go's or Node's, could see different requests. Refusing costs nothing, since real clients end lines with \r\n, and cleaning would be one more parser to get right. The parsers are fuzzed with proptest in cargo test, reading what goes out as such a server would.

Why does the proxy read a tunnel's ClientHello?

A CDN routes a TLS connection by the name in its ClientHello, so a tunnel to a listed host behind a CDN could ask for another of its sites. With a list of hosts, the proxy reads the ClientHello of each tunnel to port 443 with rustls, which it already uses, and closes a tunnel that asks for another name, or none. Other ports aren't read: there the server may speak first. An encrypted ClientHello still hides the name inside, which only terminating TLS would show, and a sandbox would then have to trust Link's own certificate.

Why does [lan] go through the proxy?

A harness's lan permission is the network as it is, this machine and its ports included. A spec's [lan] goes through the proxy instead, so a sandbox reaches only the machines it lists, never this one or a cloud's metadata service, and every call is on its wire's log.

Why is [localhost] ports = ["*"] refused?

On this machine's loopback listen other sandboxes' proxies, each another agent's way out with its own hosts, keys and exit. Every port would include them, so a spec names its ports.

Models and secrets on the wire

How is a local model filtered through the proxy?

Outside the sandbox, at the runtime's own address. The relay that carries its port in reads each request and passes only inference, by exact method and path. An encoded or relative path is refused, not decoded. It takes one request per connection so each one is read. The program keeps localhost:<port>, so no harness needs a Link-specific setting. It is an allow-list, not a deny-list: a runtime's new admin endpoint is refused until it's known.

On macOS, where Seatbelt allows a port whole or not at all, the runtime's port is closed and the program reaches the runtime on its proxy at /_link/s/<secret>/local/<port>, through the same filter. An agent's harness is set up with that address, on a proxy port fixed per agent, the last of its block (main's is 40781), so it's known before the harness starts.

Why does the proxy add a hosted model's key, rather than the program?

The program speaks plain HTTP to a path on its proxy, /_link/model, and the proxy speaks TLS to the provider. The alternative, a certificate authority of Link's trusted inside the sandbox to read its TLS, would be one more secret to protect; this way only providers the proxy knows get a key. It is a path on the proxy's own listener, not a port of its own: one way out, one set of limits.

Why are a provider's server-side tools removed?

A provider's web search, web fetch, code execution and MCP servers reach the internet for the program, so a program limited to its model would get around its hosts through them. They are removed by allow-list unless the policy allows them (server_tools): tools the program runs itself, a function or Anthropic's bash, text editor, computer and memory, stay, and any other type goes. A URL the provider would fetch, such as an image or a document, is refused rather than removed, so the program learns why.

How does the proxy add a Telegram bot's token?

As it adds a model's key: the harness is set up with the proxy as Telegram's Bot API and a placeholder, the bot's id with none of the secret. The proxy puts the real token in each call's path and speaks TLS to Telegram itself. The harnesses that speak Telegram take another address for the Bot API, so no harness needs a certificate of Link's. The token rides in the URL, where a harness's logs and errors can show it, and it was the one secret every agent with Telegram still held. The bot's own calls pass as they are, but for the three that hand its updates or the bot itself elsewhere: setWebhook, logOut and close. Only Telegram's token is added this way. Slack's and Discord's clients send theirs inside a connection they hold open, a WebSocket, which the proxy would have to speak in their place, so a harness that speaks them itself still holds them.

Why is a secret's rule data from the plugin that owns the service?

The sandbox's purpose is confinement, not channels: a verb that knew Telegram's paths made every sandbox carry Telegram, and a new service meant a new verb. So the proxy has one verb, /_link/secret/<name>/, and each secret comes with its rule: its one host, the shapes of the paths it may take (a name of letters and digits, or a plain relative path), where the secret goes (a path's {secret}, or a header), and the paths refused. The plugin that knows the service gives the rule (lnk-telegram rule --json), and the harness passes it on. The shapes are as narrow as the Telegram-only verb they replaced, so a method with a dot or a slash after it can't slip past a refused name. A rule too weak for a service would need a filter program of the plugin's own, run outside the sandbox as tools are; none needs one.

Why do a tool's and a local model's URLs carry a secret?

On macOS the proxy listens on the machine's loopback, where any program and any user of the Mac can connect. The key and bot verbs take only the placeholder the sandbox was given, but a tool or a local model has no key to tag, so their URLs carry a secret of the agent's instead: /_link/s/<secret>/mcp/<name> and /_link/s/<secret>/local/<port>. The harness plugin makes it once per agent and keeps it in the agent's settings, so the harness's configuration and its proxy agree across starts; lnk sandbox run makes one per run. It's in the path, which every MCP client and model client keeps when it's given a base URL, rather than in a header only some let you set. The proxy compares it in constant time and never logs it. Linux, whose proxy is on a unix socket in a 700 folder, takes the same URLs, so one form works everywhere.

Why does the proxy refuse models that search by nature?

Some models search the web whatever a request asks, so removing tools can't stop them. With a list of hosts, the proxy refuses them by name, from a list kept with the providers it adds keys for; without one, the sandbox reaches the web itself anyway. The harness, which knows the model, says so when the agent starts, and when it holds the key itself, that the proxy can't see it.

Limits, the log and the kill switch

Why does a wire take a limit of calls a minute?

A pause stops a wire that looks wrong; a limit caps one that looks right but does too much, such as a loop calling a model or a tool. It counts every call on the wire, of every verb, in the last minute, and answers the one over it with a 429 and when to try again, which clients already handle. Those refusals count toward no pause: the program is doing what its policy allows, only slower. It's off unless a policy sets it, since no one number suits every agent.

What does the wire's log keep, and where?

Every call on a proxy, as a host and port, never a path or body: a path can carry what an agent sends out, and the log is for seeing where it went. It writes a line when a call is allowed and another when it ends, so a connection held open for hours is in the log from its start. It lives in Link's settings, which no sandbox reads or writes. wrap gives the proxy the folder, since the proxy runs with the program's environment and a harness's HOME is its own folder.

How does the kill switch work?

It is a file in Link's settings that every proxy looks at, for each call and every second while one is open. There is no process to reach, no signal to send, and nothing a sandbox can undo. stop then stops the agents, since a sandbox with lan has no proxy to close: each agent's start registers lnk agent stop for it (lnk sandbox on-stop), and stop runs what's registered. The sandbox names no harness, and anything else running sandboxed in the background can register the same way.

What happens when a wire's calls look wrong?

Its proxy pauses that sandbox's wire and tells you. It never stops the program silently or only logs it: the program keeps running with no way out, and you look at the log and resume it. Only patterns no normal work makes count: a burst of refusals, many targets tried in turn, a cloud's metadata service. The thresholds are loose, since pausing a busy agent that did nothing wrong costs you its work. The proxy writes the pause outside, so nothing in the sandbox can undo it.

How does a wire say it's paused on a box?

lnk agent status asks lnk sandbox paused --json, and shows its harness's wire closed and why, as wire in --json too. On a box there's no desktop to show a notification, and status is where you look at your agent first. The pause itself is a file only Link writes, so reading it is all status needs.

Tools, questions and the browser

How is a tool at a URL reached?

Through a stdio server of Link's own, lnk-sandbox sandbox mcp-remote, defined as the tool's command. Everything a tool gets, asking you for each call, its sessions, the log, is the stdio tool's, so a remote server gets it with no second path through the proxy. It runs outside, so a server's token, sent as a header, is in the tool's definition in Link's settings and never in a sandbox. A URL is https://, or http:// only on this machine, so a token never crosses a network in the clear.

How is a tool that runs outside named and approved?

lnk sandbox tool add <name> -- <command>, and every call asks you but those --allow names; --ask none asks for none. A sandbox reaches only the tools it names, as it does endpoints. A server's own word on a call, MCP's destructiveHint or readOnlyHint, is shown but never skips asking, since the server sets it itself.

A dialog on your screen first, then the terminal with a code typed back, then a question waiting for lnk sandbox answer. Two minutes without an answer is no. Nothing in a sandbox draws on your screen or writes Link's settings, so it can neither fake a dialog nor answer one. On a terminal it can type but not read, so a code it never saw keeps it from answering. The queue is what makes a box, or an agent in the background, askable at all. Silence is never yes.

How does a sandbox open a page in your browser?

Through its proxy: open makes $BROWSER in the sandbox Link's own program, which asks the proxy, outside, to open the page. There is no LaunchServices and no desktop session inside. open takes the place of open-apps, which let a program open anything on a Mac and so amounted to no sandbox. A page opens in your browser with your cookies and logins, so every one asks you and shows its whole URL, in plain ASCII so what you read is what opens. No host is allowed in advance, since a URL on any site can carry data out.

How does a login's callback reach the sandbox?

A tool's login often waits on localhost:<port> for the browser's answer. On macOS the sandbox listens on this machine's loopback, so the answer arrives as it is. On Linux the sandbox's loopback is its own: the proxy listens on the port the page's redirect_uri names, only for a page you allowed, and passes the first request in. Then it closes, or after five minutes, one port per page and never a range. A port already taken is refused rather than shared, since the answer carries the login's code.

Guests

How do guests get in?

Through OpenSSH, with a forced command: restrict,pty,command="lnk sandbox enter <name>" in authorized_keys. A shell, a command, scp or sftp all run in the guest's sandbox, and no forwarding gets around it. Guests get no account of their own: they run as you, confined by the sandbox alone, which keeps it one command with no administrator rights. A key another line already lets in is refused, since sshd uses the first line that matches.

Why does a guest keep their terminal?

Their shell needs job control and Ctrl-C, so a guest's sandbox skips bubblewrap's --new-session: the terminal is their own SSH session, which nothing else reads after. lnk sandbox run keeps a new session, since the terminal it runs on is yours.

How does a guest get a spec?

lnk sandbox guest add --spec reads it as a run would, refusals and all, shows what it opens and asks, then keeps it in guests.toml with its paths absolute. A guest's sandbox is then what their spec says, not what its file says later, and a session checks it again, so a folder that became a link since is refused.

The programs it may run

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 stays open, and mapping its memory executable stays open, because Chromium and some JITs need them; only running a memory file as a program is closed. On a kernel too old to make memory non-executable (before Linux 6.3), the seccomp rule still closes the execveat route.

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. 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. 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.

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, 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.

DNS for named endpoints

Why does a sandbox get DNS only where it names endpoints?

A program that uses HTTPS_PROXY never looks a name up: the proxy does. A database driver does, and mongodb+srv:// asks for SRV and TXT records before it connects. So DNS comes with tcp, the endpoints a database needs, and a sandbox without them keeps having none, as before. Its proxy answers, so only names the sandbox may reach are asked of anyone, and each query is on the wire's log. SRV and TXT for a local network's names (.local, one label) are refused: this machine's resolver answers them from that network, which the sandbox never reaches. A query counts toward the calls a minute only when it asks outside, once a lookup, since the connection after it counts too.

Why do an endpoint's names get addresses of the sandbox's own?

The sandbox has no route out, so a name's real address reaches nothing. An address of its own, on its loopback, is where the proxy's end inside listens, and a connection there reaches the proxy as CONNECT to the name, checked and logged as any. A name under a *. endpoint isn't known until it's looked up, so its address is given then, and the proxy keeps which name it gave it.

Why does the proxy join the sandbox's network to listen on port 53?

Resolvers ask port 53, which only a privileged process may listen on, and bubblewrap keeps every privilege from the sandbox. Its user namespace is the user's own, so a helper of the proxy's, outside the sandbox, may join its network, listen on 127.0.0.1:53 and hand the sockets to the proxy, while bubblewrap waits to start the program. Nothing in the sandbox gains a privilege, and nothing of it runs in the helper.

A project's dot files

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 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. 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, 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. 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 guest'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 an empty folder is put there for the run, by lnk-sandbox standing in front of bubblewrap, and removed when the run ends, so nothing is left in your folders. Removing one another sandbox on the same folder has mounted over would undo that mount, so each placeholder is listed in Link's settings with the runs holding it, and goes only when none alive does, and only while it's still the empty one Link made. A link is refused, since 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.