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 orSSH_AUTH_SOCKfrom your shell reaches it. On Linux it also gets an empty home folder, an empty/runand 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, whereverXDG_CONFIG_HOMEputs the settings, stay closed to reading and writing, on macOS and Linux, even withread-homeor a--readof a folder holding them. A--reador--writeinside them is refused, and so is a--writeof 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 toread-homeand 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 oncd),.vscodeand.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'slefthook.ymland its other names,.lefthook)..huskyis 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 asscriptsforscripts/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.envrcisn'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,.huskyorscriptscan'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.checktries each. - What sits beside
lnk-sandbox. Every sandbox runslnk-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 givingexeca file descriptor instead of a path: the seccomp filter refuses running one (execveatwithAT_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, byexecveator 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, andcheckshows the/proc/self/fdway as a LEAK. On older kernels one still runs through/proc/self/fd, andcheckshows it as a known gap.memfd_createand 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 setsTAR_OPTIONS=--no-same-owner, sotarkeeps 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'svar/, with local databases and logs, nor its servers' settings inetc/. - 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. Neverstat()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,/sysand/var/lib/ca-certificates), read-only: no other disks,/srv, the rest of/varor 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
--readof~/.configtoo. - 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) ptraceand reading other processes' memory, sogdbandstracedon't work- mounting and the new mount API
- new namespaces:
unshare,setns,clonewith a namespace flag;clone3getsENOSYSso libc falls back toclone - files by handle, loading kernel code, the clock, rebooting, swap
iopl,iopermandmodify_ldt(x86_64)TIOCSTIandTIOCLINUX, which type into a terminal- every call of another architecture (i386, x32)
- a few more no program here needs, such as
acct,quotactl,syslogandprocess_vm_writev; and, where[commands]lists programs, running a file descriptor (below)
- io_uring (
- 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 checktries the calls a program can make unprivileged, wherepython3is there to make them: a user namespace, io_uring, userfaultfd,keyctlandptrace.
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 readyoffers, in a terminal, to add/etc/apparmor.d/lnk-bwrapwith sudo.lnk agent start,lnk agent <harness> check, andlnk sandbox run,checkandlearnrun 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,
lnkprints 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 HTTPCONNECTor plainhttp://, or only those listed. Never this machine, its networks or a cloud's metadata service. Agents withnetworkget it unless they havelan; 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,logOutandclose). 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, asopen'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.checkreports it asopen. - Folders it writes (
--write, a harness's home, your files folder): files only. You,lnk bucketandlnk agent sendread them, as below. read-home: reads your whole home folder, never Link's settings.open, through the proxy: anhttps://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$BROWSERin 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_uriis a port onlocalhost, 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; withnetwork, 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 asPATHfound 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.--yesskips 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 is40781, the port ofmain'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 --spectries 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 inlnk sandbox learn, the sandbox's first program (lnk-sandbox) stays as the parent of what it runs, and a seccomp filter hands it eachexecbefore it happens. It reads the path from the calling program's memory, says which file the list refuses (or, forlearn, 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, assshmakes itself) isn't named.learnruns 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>/fdorpidfd_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 ofsandbox-exec, reads them withlog streamwhile 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.learngives its profile(allow process-exec (with report)), so every program it runs is reported.log streamtakes 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/tmpand 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-sandboxis 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 -Eon macOS,/proc/<pid>/environon 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-sandboxlooks 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:40781and itsHTTPS_PROXY, to a unix socket where Link's proxy, outside the sandbox, takes HTTPCONNECTand plainhttp://requests. - macOS: with no network namespaces, the proxy listens on a free
port of the machine's loopback. The sandbox's Seatbelt rules, without
networkorlocalhost, 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 oropen: 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-homeor not, so a sandboxed program reads no agent's placeholder but its own, or one in a folder you open to it with--reador--write. - The proxy refuses what a web page can send, whatever its path: a
request to the proxy itself (not
CONNECTorhttp://...) with aHostthat 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 asbrowseronce 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 withssh -Rover Link's SSH connection to the box. That is the vpn plugin's exit,lnk vpn exit <box> home, whichlnk agent exit homeholds for the agent and which stops once nobody holds it. For a machine that isn't a box, it'sssh -Rwith 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 whenXDG_CONFIG_HOMEputs those elsewhere: the agent can't reach it but through its proxy. - An agent on a box whose
lnkis 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
lnkitself 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) and192.0.0.0/24addresses, IPv4 ones written inside IPv6 (mapped, compatible, NAT6464:ff9b::/96, 6to42002::/16) as the IPv4 address they reach (checkasks it for this machine, a metadata service and a private address in each form, the compatible::a.b.c.dtoo), 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 plainhttp://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.
CONNECTreaches port 443 only, and a plainhttp://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 andRetry-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: itsHostheader 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 byHostcan't be pointed at another site past a list of hosts. OverCONNECTto 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 anotherHostinside 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 islnk-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.1on, 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 asCONNECT 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. Forsshitself, 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.checkwarns 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, binds127.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/hostsone, or one from127.1.0.1on 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 asCONNECTto 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,.lanor.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 ofcorp.example.com, its_ldap._tcprecords) 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 withlnk 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
dnsline 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 getsSERVFAIL. - 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
Hostset to the runtime's. Its proxy is on a loopback port fixed per agent, the last of its block:main's is40781.
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,
Hostand 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,
:onlineon 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
urlor_data(image_url, OpenRouter'sfile_data), in any block. Only theinputof a tool's call (tool_use,function_calland 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 forlnk 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
GETandPOST(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,logOutandclose, which would hand the bot's updates, or the bot, elsewhere); - puts the real secret in the path or header, drops whatever
Host,Authorizationand 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-Lengthit copies exactly. A request with aTransfer-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),pauseandunknown. - 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 towire.log.4and 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
HOMEis a harness's own folder. - It holds a proxy's calls, not what a sandbox with
networkdoes 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 noendedline for them.
The kill switch and pausing
lnk sandbox stop: while~/.config/lnk/sandbox/stoppedexists, 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. Thenstopruns what asked to stop with it (lnk sandbox on-stop, kept in Link's settings, which no sandbox reaches):lnk agent stopfor each agent here, which every agent's start registers, since an agent withlanhas no proxy.stop --offremoves the file; agents stay stopped until started.stopcloses the proxies, not a sandbox's direct network (an agent'slan, 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, untillnk 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 apauseline and shows a notification where it can. Where it can't, on a box,lnk agent statusandlnk sandbox pausedsay 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_sessionsandtool_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/callgoes through only if the tool's rules allow it (--allow, or--ask none). A server's hints, such as MCP'sreadOnlyHintordestructiveHint, 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): atools/callsent 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 reacheshttps://, orhttp://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:
- A dialog: macOS's
osascript, passed the text as an argument, never as script; orzenity --no-markuporkdialogwith the text escaped. They get the desktop session's variables thatwrappasses 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'sPATH, which can name a folder the sandbox writes. - Its controlling terminal, with a random code of four letters to type back. Anything else refuses it.
- 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 oflnk sandbox log.lnk sandbox answeranswers 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 nextasks.
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.
What Link reads back from a sandbox
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
commandsays, run in its home: only plain absolute paths that neither are nor hold its home, your files folder, your home folder, Link's settings,/tmpor 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 yourPATH. Service names that are only names; noHOME,TMPDIR,LD_*orDYLD_*. - What to move (
lnk agent send) and back up (lnk agent backup), from its adapter'sstateand 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'ssendor 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-folderdeletes 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_keysasrestrict,pty,command="<lnk> sandbox enter <name>" <key> lnk-guest-<name>. sshd runs nothing but that command: a shell, a command,scporsftpall run in the guest's sandbox.restrictturns 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,sftpor a command without one gets a new session. - They write only their folder (
~/Link/Guests/<name>, 700, theirHOME), read nothing of your home folder, and never an agent's folder (~/Link/Agents), even withread-home. They have no network unless allowed. --foldercan't be your home folder or hold it, be in a dot folder orLibraryof 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 inguests.tomlwith 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, sonetwork's "any address" covers it, and a rule names onlylocalhostor 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
.envrcisn't held: a sandbox can make one. direnv runs a new or changed.envrconly afterdirenv 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_PRELOADnames 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.
- the program loader, which every program needs, runs any file the
sandbox can read (
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 whenDYLD_INSERT_LIBRARIESnames it (cleared when the sandbox starts, but a program can set it for a child). On Linux, running any file descriptor (execveatwithAT_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;checkshows 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
.gitin any folder inside (src/.git, with acore.fsmonitor), and git run there, by you or by a shell prompt that shows git's status when youcdin, 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-filesnames 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] tcpendpoint (--tcp) is reached only by a client that connects through a command, as ssh'sProxyCommanddoes (git over SSH). A database client such aspsql, 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 -ddoes) 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
CONNECTto a host it refuses. Those count as the sandbox's: 30 refusals in a minute pause its wire untillnk 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
sonarmodels or OpenAI's*-search-previewon 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, andlnk agent startsays 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
urlor_data); a provider's new way under another name isn't refused until Link knows it. - A port
[localhost] portsnames (--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 modellnk model serveserves. The sandbox can't tell those ports from others without reading other plugins' settings, so only40781is 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, sinceptraceis 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.
