Decisions

Why the sandbox's filtering proxy, link-sandbox-proxy, works the way it does (The Filtering Proxy).

One wire out

Why a sandbox has one way out, its proxy, and what the proxy refuses on it.

Why is every way out a route 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 route on it, served 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.

Paths on one listener

Routes 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 route is off unless the spec names it, has no admin form (nothing that installs, deletes or configures a service outside), fails closed, and has its parser fuzzed.

No route of its own

The proxy itself (link-sandbox-proxy) names no route. Each is one in the one bind its caller hands it (link_sandbox_route::Route), mounted at a prefix of /_link/: the proxy matches a request's target to a route, refuses it without the URL secret if the route needs it, and hands it, proved as the agent that calls, to the route's bridge outside, of the plugin that owns what the route does (a model's to the models plugin, a secret's to the secret plugin, tools and pages to the sandbox's), and anything else at /_link/ is refused.

Its verdict

The bridge says its verdict, which the proxy logs by the route's name, then answers. A route's bridge reads its request's head with the one parser (link_sandbox_route::http), so no two readers take one head two ways. A new way out is a route and its plugin's bridge, never a change to the proxy's loop.

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

Any sandbox can have the proxy instead of the network as it is ([network] mode = "host"). 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 spec'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?

[network] mode = "host" 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.

Why does a sandbox with only [localhost] ports reach them on Linux too?

A spec's named ports are reachable independently of other network grants. On Linux the loopback relay provides access only to those ports; on macOS Seatbelt grants the corresponding local access. No general network access is required. Settings migration reports the ports it carries into a spec.

DNS for named endpoints

Why a sandbox gets DNS only beside endpoints it names, and how its proxy answers.

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.

Only names it may reach

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.

Models and secrets on the wire

How the proxy filters a local model and adds a hosted model's key or a service's secret outside the sandbox.

How is a local model filtered through the proxy?

Outside the sandbox, at the runtime's own address. The proxy's local-model route hands each request to the models plugin's bridge, which reads it 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

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 models plugin 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 spec 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.

Why is a placeholder random for each run, and not a hash of its secret?

A hash of the secret is the same wherever it's given: one read from a harness's home works for every agent holding that key, for good, and what runs can test guesses at a short sealed value against it. A placeholder is an HMAC-SHA256 of what it stands for, under a secret made for each run and holder (link_sandbox_route::Placeholders), so it says nothing of the secret, and one seen is no use to another agent or after the next start.

Who makes it

Whoever makes the confinement makes it and hands it to the proxy beside the secret; the proxy refuses one that isn't Link's shape, so an empty or guessable placeholder adds nothing.

Across a start

An agent keeps its run's secret in its settings, so its harness's configuration, the run itself and its proxy agree until it starts again. A proxy kept running across a start without being given the new placeholders (one restarted from its old files, a blank proxy never bound again) holds the old ones, so it refuses the harness's calls: a mismatch fails closed, never open.

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

The harness receives a placeholder and the proxy's Bot API address. The proxy substitutes the real token into the request path and establishes TLS with Telegram, keeping the token out of the harness's logs and errors. Calls pass through except setWebhook, logOut and close, which could hand updates or the bot to another program. Slack and Discord clients that maintain their own authenticated WebSockets still hold their tokens.

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

The proxy applies one secret operation, /_link/secret/<name>/. The service's plugin supplies the host, allowed path shapes, token placement and refused paths through its rule --json answer. The harness passes that rule to the proxy. The sandbox implements confinement without implementing each service's API. A method containing a dot or slash cannot bypass a refused method name.

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 routes 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>, /_link/s/<secret>/local/<port>. The harness plugin 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.

In the path

It's in the path, which every client keeps from a base URL, compared in constant time and never logged. Linux 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 model route refuses them by name, from the models plugin's table of providers; without one, the sandbox reaches the web itself anyway. The harness, which knows the model, says so when the agent starts, and that the proxy can't see a key it holds itself.

Limits, the log and the kill switch

Why a wire takes a limit, what its log keeps, and how it's stopped or pauses itself.

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 spec allows, only slower. It's off unless a spec 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 [network] mode = "host" 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.

Its port

How a proxy on this machine's loopback takes its port, and keeps the way out to its own run.

A loopback proxy owns its port, and takes a way out only with its run's secret

A proxy that needs a dynamic loopback port binds port zero itself, then fills its child's generated URLs and, on macOS, writes that run's own Seatbelt profile; briefly opening and closing a listener reserves nothing. On macOS, and for a program with no wall, that port is on the machine's loopback, and a process that left its run's group outlives the proxy, so a later run's proxy may get the same port.

The run's secret

Each wrap makes a new secret, only in HTTP_PROXY and HTTPS_PROXY (http://lnk:<secret>@127.0.0.1:<port>), where the proxy reads it too, so a service runs it again as it is; it answers a CONNECT or plain http:// request without it 407. A program with no wall not pointed at it reaches the network itself, and gets none. A route keeps its agent's URL secret. A Linux sandbox's proxy is in a network no later run shares and takes none, so Java's clients (signal-cli) work.