Security
How Link protects the people who use it, what it doesn't protect against, and where each part's details are.
Report a vulnerability
Report it privately (Report a Vulnerability): GitHub's "Report a vulnerability" button, or so@local.link. Never in a public issue.
Each part's page
Each part's guarantees and gaps are on its own security page:
- Agents: the harness, its sandbox, moving an agent
- The exit proxy: what an agent's traffic may reach
- Models
- Tunnels: accounts, tunnels, limits
- Buckets: your files, share links
- Clouds and boxes
- Releases: the core and its updates
- The relay server: hosting it, the operator checklist
Who is involved, and what they can do
Who takes part, what each is trusted with, and what each must not be
able to do. A visitor reaches a tunnel through the relay, a chat sender
reaches the harness through their chat, and lnk reaches the user's
clouds from the user's machine:
visitor ──HTTPS──▶ Caddy ──HTTP──▶ link-relay ◀──WSS── lnk (user's machine) ──HTTP──▶ localhost:<port>
(anyone) └──────── relay server ────────┘ (outbound only)
Telegram, Slack, Discord, Signal sender ──▶ Telegram, Slack, Discord, Signal ──▶ harness (sandboxed, on the user's machine) ──▶ model provider, the internet
lnk bucket (user's machine) ──HTTPS──▶ the user's clouds
lnk cloud, lnk box (user's machine) ──HTTPS, SSH──▶ the user's clouds, their boxesOperator
The operator runs the relay, and is trusted with tunnel traffic and users' tokens and accounts. TLS ends at Caddy, so the relay sees tunnel traffic in plaintext. The operator can't reach anything on a user's machine but the port that user exposed, and can't push code to users or servers without the release signing key. A user's agent, buckets, cloud accounts and boxes never touch the relay.
User
A user runs lnk, and is trusted with their own tunnels, agent, clouds
and boxes. A user can't answer for another user's tunnels, read their
traffic, set cookies for them, or exhaust the relay.
Visitor
A visitor is anyone on the internet, trusted with what the user chose to expose. A visitor can't get past a tunnel's password or its list of the people it lets in, reach other ports, stall others, or exhaust the relay.
Harness
The harness may be steered by a prompt injection, from a web page, a message or a file it reads. It's trusted with its home, the files folder, the model key, the bot and its permissions. It can't read the user's other files, SSH keys, browser data or Link's settings. It can't write outside its home and the files folder, reach other servers on the machine, or outlive the sandbox through a background service of its own.
Chat sender
A sender on Telegram, Slack, Discord or Signal is refused unless they are the user or member id paired with the bot or app, or in a chat the owner shared the agent with: there they can make it do all it can, and are never taken as the owner. Only the owner shares a chat, unshares it or approves what the agent asks.
Plugin from outside Link
A harness's adapter from its own repository, signed by its own key, runs as the user, like any program they install. It can't install an update not signed by the key the user trusted, or take one of Link's plugins' names.
Vendor installers
A harness's own installer and rclone's releases are trusted with installing their program, and can't write outside the harness's home or change rclone. They run sandboxed, and rclone is installed only if its checksum matches the one pinned in Local Link's source.
Cloud providers
Cloud providers, and anyone with the account or bucket, hold the user's
files and boxes. They can read files not pushed with --encrypt, and
run the user's boxes, so everything on one, its own lnk login
included. A box holds no key to the user's own machine, and is never
handed the user's SSH agent.
What clean keeps
They can't make clean delete a file here that has no good copy, short
of deliberately forging MD5 checksums as the provider; on S3, clean
reads back any copy whose MD5 its uploader set (what clean
guarantees).
Release pipeline
GitHub Actions and the public so-local/link-releases repo publish
builds, and can't get unsigned code onto servers or users' machines:
nothing reaches them unless it's signed with the release key. The key
itself comes from Local Link's source, never from a release: built into
lnk, embedded in install.sh, and on servers installed by
bootstrap.sh, which the operator runs from their own checkout (what
a server
installs).
A shared memory engine
A shared memory engine proves each agent's embedding requests with a purpose-bound credential on a Unix socket. The receiver resolves the owner from the kernel peer and current binding, refusing an asserted owner, another slot's user and a replaced binding. The proof gives its receiver no agent key (credentials).
What the design addresses
The threats the design is built against:
- anonymous internet traffic;
- a hostile visitor aimed at someone's tunnel;
- a hostile logged-in user running a modified agent;
- a hostile or compromised relay, as seen from a user's machine;
- a compromised releases repo or its token;
- a harness turned against its user by what it reads;
- a stranger messaging the user's bot;
- a compromised harness installer;
- a cloud that loses or corrupts copies.
The network and local users
TLS protects every connection except loopback against a network attacker between the parties. Other local users on the same machine are kept out of Link's files (mode 600 and 700) but can see command lines, so secrets are asked for or read from variables, never taken as flags where that can be avoided.
Link's own calls
Every call Link's own code makes over HTTP goes out on one client,
link-fetch (link_plugin::fetch): to a service of Link's (the account
service, a relay's API) and to one it uses (a cloud's API, a chat's, a
release's files). What an agent fetches (a page, an MCP server) and a
model's provider have rules of their own, in the parts that call them.
Each call gets the same rules:
https://only. Plainhttp://goes only to this machine (localhost, a name under it, or a loopback address), a relay or an account service of your own for development. Anywhere else nothing is sent, so a credential never crosses the network in the clear.- Plain
http://stays on this machine. Its names are never asked of DNS:localhostand every name under it are127.0.0.1and::1, so a network's DNS can't answerdev.localhostwith an address elsewhere. And it goes through no proxy, whateverHTTP_PROXYorALL_PROXYsay, since a proxy would carry it off the machine in the clear. https://is any program's. It uses the machine's DNS and proxies as any program does: TLS keeps a credential from them.- No redirect is followed. An answer that sends the call elsewhere is an error, so a credential, or a call, goes only where it was sent.
- An answer's size is capped, 1 MiB unless a call says more (a share's list of files), and read so: a longer one is refused, never read whole first. A call takes 30 seconds at most unless it says another, and 10 to connect.
- Only typed data crosses. A request is a type, and an answer is read as one: a field it doesn't name, or a value of the wrong type, is refused, naming the call, never guessed at.
- Errors map once. 401 is signed out, 429 is try again later (with how long, when the service says), a 5xx is the service's fault. An error names the call by its method and URL without its user, password, query or fragment, and never repeats a credential or a value from an answer; what a service said is shown in printable text, at most 512 bytes of it.
- Credentials go in as secrets, as
Authorizationheaders marked sensitive, so nothing prints them. - Every call says it's Link's:
lnk/<version>as its user agent.
A client says where it's made when a call needs other than these, one named rule each, so a review sees it:
- A service that isn't Link's (
outside): its answers are read open, a field their type doesn't name passed over, since such a service adds fields without a new version. A call that reads some of a route's fields says so where it calls (answer_part). - A cloud's metadata server (
metadata_server), which has no TLS: plainhttp://to169.254.169.254, or tometadata.google.internalfound at that address alone, never by DNS, carrying no credential. Any other plainhttp://beyond this machine is refused. Through no proxy. - Plain
http://anywhere (plain_http): an endpoint you named so (lnk measure export --url http://...), what it carries, its credential too, crossing the network in the clear as you chose. Through no proxy. - Public addresses alone (
public_only): an address someone else gave, a share's file, reaches no address of this machine or its network, and through no proxy, which would find the name itself. - Redirects followed (
follow_redirects): a release's public files, whose host moves them, each hop tohttps://alone. A call on such a client carries no credential. - The fleet's own authority (
trusting),https://alone (https_only, this machine too), no proxy (direct), and as long as a stream takes (streaming): given up only once nothing moves for its stall's length, a body sent while it goes, an answer while it comes. - A URL that holds a secret (a bot's token in its path) is named by what it is ("Telegram's getMe"), never by itself.
Every answer's bytes are held as a secret while they're read, and wiped
once they are. Each folder's tests run link_fetch::audit over its
code: an HTTP client built anywhere else fails, unless its list says
why it isn't one of Link's own calls.
Secrets in the Keychain
On a Mac, Link keeps its secrets in the login Keychain instead of its
settings files: this machine's key (or a relay operator's token), the
secrets lnk secret keeps (each agent's model key and its connections'
secrets among them), cloud accounts' secret keys, and the cloud keys and
encryption passwords lnk bucket writes. So they aren't in the plain
files that backups, dotfile syncs and copies of ~/.config carry.
- Through Apple's
securitytool. Items are generic passwords of the servicelink, one account each:relay-token,secret/<NAME>(an agent's own assecret/AGENT_<AGENT>_MODEL_KEY,secret/AGENT_<AGENT>_<CONNECTION>_<FIELD>),cloud/<account>/<variable>,bucket/<section>/<key>. - Made by
security.securitymakes the items, so each item trusts it and reading one never asks. Link's programs aren't signed by Apple, so an item they made would ask again after every upgrade. - Any program of the user's can read one. In exchange, any program
of the user's can read an item through
security, as it could read the file: the Keychain doesn't keep one of the user's programs from another. - No secret on a command line. Secrets reach
securityon its stdin, hex-encoded, and each is read back after it's saved. If the Keychain refuses one, it stays in the file, with a warning. - Recorded. Settings files record which secrets are in the Keychain:
accounts.toml'skeychain, andrclone-keychain.toml. - Read only when used. An agent's settings file keeps its Telegram
bot's id, the token's public first part, so
lnk agent listandlnk agent connectname every agent's bots without reading their keys. - Moving out. An agent's items go when it leaves the machine, an
account's when it's disconnected, and a bucket's with its section.
lnk uninstallremoves only those of the parts you pick. It lists Link's items by name withsecurity dump-keychain, never their secrets. - Not everywhere. Linux and boxes have no Keychain: secrets stay in
files (600).
LNK_KEYCHAIN=offkeeps them in files on a Mac too, and a config file named withLNK_CONFIGkeeps its own token.
Privacy and logs
What each part keeps of who did what, and where it keeps it:
- The relay's logs hold agent connections and disconnections (with
how many requests the relay gave up on while connected, and, once its
token is checked, the
lnkversion, at most 32 characters of letters, digits,.,-and+), with the username. Never anything per public request: no bodies, headers, paths or visitor addresses, and never tokens or passwords. - Caddy keeps no access log. The conventions make this a rule, and the terms page promises it.
- In the relay's memory only: connected tunnels (name, the agent's
address,
lnkversion, request count); tunnel passwords and the accounts and emails tunnels let in; visitor addresses for rate limits and password-guess limits, up to a day; sign-ins waiting on the account service (the tunnel, the page, the visitor's address), up to 10 minutes; and share links' wrong-password counters. - A share link's password, once given right, is kept in the relay's memory as an HMAC of the link's salt and the password under a key made at each start, beside the key, until the relay stops. A visitor's account id and verified email go into their sign-in cookie, not onto the relay's disk or into its logs.
- On the relay's disk, in its state folder and its backups: blocks, and share links (who made each, until when, its files' names, sizes and signed URLs, its password's hash). A share expired is dropped the next time the relay saves its links, at any link made or removed.
- A backup keeps an expired share until the backup is deleted, and its URLs expire with it. The relay keeps no accounts: the account service does.
- The local app sees visitors' addresses in
X-Forwarded-For. - The website's waitlist (
/waitlist) links to cloud.local.link, where the pre-sale is bought at Stripe's Checkout, and to a form service, Tally, which keeps what people enter in the free waitlist. Nothing about either reaches the relay or the website's own server; the privacy page says so. - On the user's machine and nowhere else: the harness's log (what
it prints), the harness's own state in its home, the bucket manifests
(every file's name, size and which cloud holds it), and the cloud
accounts and boxes.
lnksends none of them to the relay or to Local Link. - Who each part talks to. The harness talks to its model provider
and to Telegram itself, and
lnk bucketandlnk boxtalk to the user's clouds.lnk model servelogs nothing per request. The sandboxes' proxies log where each call went, never what it carried (lnk sandbox log).
Known limitations
Each gap is followed by what limits it.
- The operator can read all tunnel traffic, since TLS ends at Caddy. It's needed to route by hostname and check passwords at the relay.
- The release key is a GitHub environment's secret, so repository
admins, who can change the environment's rules, or a compromised
first-party action in the signing job could sign. Only the
releaseenvironment holds it: its jobs run only frommain, and each waits for its required reviewer's approval. - A crate's build script could alter what gets signed, since it runs in the build jobs. Locked, reviewed dependencies limit it. The builds restore no cache, so a poisoned cache can't alter them.
- Signing stands apart from the builds, which limits the two gaps
above: actions are pinned and crates locked; signing runs in its
own job with only
actions/*, and signs only the build jobs' artifacts, after checking they hold exactly the expected files; the harnesses' installers, unpinned, run only on pull requests, never in a release's run. - A relay's domain isn't on the Public Suffix List until it's given an entry (the entry it would take). Tunnels on one relay share a registrable domain for cookies. Limited by: host-only cookies cover the most direct abuse.
- Many addresses can use up a
--authtunnel's shared wrong-password allowance, so new visitors wait. Limited by: visitors who already logged in are unaffected, and--accounttunnels have no such allowance. --accounttunnels let in browsers only: a program needs a browser's sign-in cookie. Programs use--auth.- Some secrets are in files. On Linux and boxes, all of them (mode
600). On a Mac: the harness's
.env(its connections' tokens, but for Telegram's when it's on the wire, and its model key unless it's on the wire), a wired key or bot token for the proxy, Link's SSH key for boxes, and sign-ins rclone saves itself. - Those files are the user's alone, which limits the gap above: they're readable only by the user, and secrets reach adapters on stdin and cloud CLIs in their environment, never on a command line.
- Keychain items are readable by any program of the user's through
security(Secrets in the Keychain). - A plugin from outside Link runs as you.
lnk plugin add --url <url>trusts the key its repository offers the first time, after showing it and asking, and the adapter's own answers run outside the sandbox. Limited by: only a harness adapter is added, every later release must be signed by the pinned key, and the harness it installs and runs is in the sandbox like any other (Plugins Security). - A harness's installer may fetch some things unpinned, such as a package's dependencies resolved at install time. Limited by: its adapter pins and checks the installer and the harness's own code, and everything runs over HTTPS, inside the sandbox, able to write only the harness's home, reaching only the hosts its adapter lists (a registry, GitHub) through its proxy.
- One connected cloud key reaches both buckets, your files' and your agent's. Limited by: the buckets are separate, and the agent's is always encrypted.
- Link's SSH certificate authority for boxes signs for every box a computer reaches. Limited by: its secret is in the Keychain on a Mac, else in a 700 folder readable only by the user, and a copied key alone reaches nothing an hour later.
- A box's SSH port is open to the whole internet. Limited by: it's key-only (Ubuntu's cloud images refuse passwords), and nothing else is open.
- A new box's host key may be trusted on first contact
(
accept-new) when its console doesn't show the host keys within 5 minutes, or its adapter can't read it. That contact then trusts the network between you and the cloud. Limited by: usually the keys are read from the console and pinned first; otherwise the key is pinned after, in Link's ownknown_hosts. - With
--network-mode host, a harness reaches more than it should. On macOS it reaches every localhost port through::ffff:127.0.0.1, and a server listening on every address through the Mac's own address. On Linux it reaches every localhost port. See the harness section. [network] mode = "host"is off by default, which limits the gap above: an agent without it has no network but its filtering proxy, which closes this.lnk agent harness checkreports it asopen.- A harness's own ports are on a Mac's loopback, where every user and program of the Mac can reach them, and a connection daemon there that asks for no credential would let them send as the agent.
- Each adapter guards its harness's ports, which limits the gap above: each adapter keeps its harness's daemons on a unix socket in its home or behind a relay that asks for a secret, where its harness lets it, and says on its security page what it can't; on Linux behind its proxy, those ports stay in the sandbox's own network.
- A program can take an agent's port between the start's check that
it's free and the harness's bind, or while the harness's service
restarts. Limited by: a harness whose adapter says
proofproves it holds its endpoint's key before Link's connections send it the key or a message (Agents Security); an older adapter's endpoint is sent them unchecked. --authis HTTP Basic only, while OpenAI SDKs sendAuthorization: Bearer <key>. SDK users set the Basic header themselves;curl -uworks.- Login phishing. Someone can start
lnk auth loginand talk a person into approving it in the browser, getting a token for their account. Limited by: the browser shows the code and the machine's name to check against whatlnkprinted, and a machine signed in that way can be signed out from the account, which the relay honours within 5 minutes, its tunnels open included (Accounts security). - One relay, holding tunnel state in memory. Limited by: servers are disposable and rebuild in minutes.
- Ending a share link (
lnk bucket link delete) doesn't end the signed URLs already handed out. Limited by: they expire with the share, in at most 7 days, and the cloud's own controls (deleting the object, or the key that signed it) end them sooner. - Share links come only from S3 storage (aws, s3). Other clouds' files are refused, never shared with a link that doesn't expire.
- A question asked on a terminal can be faked. With no desktop, the
sandboxed program shares the terminal and can write over the question.
Limited by: the code typed back allows only the call that asked; with
a desktop it's a dialog, and
lnk sandbox asksshows a waiting question as it is. - A question answered on a chat is answered by whoever holds it. An agent's sandbox asks its owner's direct chats too, and the reply with the question's code allows the call. Limited by: the code is in a store only the owner reads, so the model can't hand it on; only the owner's account, on a chat the question was asked on, answers it; and it allows that one call, once.