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.
How does Link ask you?
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.
