Security
Harnesses
Your AI, the harness
What's protected: your machine from the harness you run (Link Harness, one of the others that come with Link, or one from outside Link). It acts for you with a model key and, once connected, a channel's bot, and anything it reads may try to steer it. Where its files live: README.
What the sandbox can't protect is what you give the harness: the files
folder, its own state (memory, conversations), the model key, which it
can spend, and the bot. It has the network, so it can send what it can
read anywhere. A harness's built-in tools (a shell) run
inside the same sandbox; tools added with lnk sandbox tool add run
outside it.
Gaps, in short: the sandbox's known gaps, secrets in files and unpinned installers. See Known limitations. What every sandbox guarantees, a harness's included, is the sandbox's security page; this one is what an agent adds.
Link Harness
- It runs no code: it reads the conversation, calls the model, makes the model's tool calls and writes the answer. It runs in the same sandbox as every harness, with the same permissions.
- Its tools are the MCP servers Link runs outside the sandbox for its
agent (
$LNK_MEMORY,$LNK_TOOL_<NAME>). It reaches them only through its proxy, which asks you before each call you didn't allow by name and logs every call (lnk sandbox log). It calls only the tools its environment names, never a URL a model or a tool gives it. - A tool's answer goes to the model as the tool's words, never as yours or as instructions. A tool that returns text written by someone else, like a web page or a shared file, can still try to steer the model. What that steering can do is bounded by the tools you allowed without asking.
- A turn makes at most eight rounds of calls, at most 16 calls each (the model's others are dropped), and keeps at most 32 KiB of each answer. A turn opens a session with a tool only to call it, and ends it with the turn, so what one conversation left in a tool (a browser's pages, a shell's folder) isn't there for another's. Memory's alone is kept for the turns after, one for all the agent's conversations, since memory is the agent's own in each of them: Link Harness ends it once no turn has used it for 10 minutes, or an hour after it started however busy, and at once if a call to it fails other than with the tool's own error. Link Harness keeps, in memory, only that and the list of each tool's calls. A tool that fails to start, or takes over 30 s to, is left out of the turn and, if it never listed its tools, of every turn for 5 minutes. One that listed them before is offered what it listed, and so is one that takes over 30 s to list them again. One that lists more than 16 pages, or a page twice, is offered what it listed by then.
- What the tools offer goes into every prompt, so it has a size: at
most 128 calls from each tool, 2 KiB of each call's description (the
rest cut), a schema of at most 16 KiB, and 128 KiB for every call
together. A call past a limit is left out, and said so in the agent's
log (
lnk agent logs). - No tool can take another's call's name. A tool added with
--in its name, or ending with-, isn't offered. A call whose name is longer than a provider takes is left out rather than cut, and of two calls whose names come out the same, only the first is offered. - It holds no keys by default: behind its proxy it gets a placeholder,
and the proxy adds the model's key outside the sandbox. Without the
sandbox, or with
lan, itsconfig.jsonholds the key, mode 600. - It speaks no channel, so it never holds a channel's token or a
linked device's keys. Link's channel plugin (
lnk-<channel> bridge) carries the messages, outside the sandbox, beside the agent. It passes on the allowed users' direct messages, and their messages in a group that mention the bot, only. Every channel is a connection the plugin opens, so nothing listens for a channel on your machine: Slack in Socket Mode, Discord on its Gateway,signal-cliover its stdin and stdout, never a local port. - An answer can't mention anyone: on Slack
&,<and>are escaped, so no@channelor disguised link; on Discord every message is sent allowing no mentions, so no@everyone. - WhatsApp and Signal link as a device into
~/.config/lnk/agents/<agent>/channels/<channel>(700), which no harness reaches: the device's keys, andsignal-cliitself.lnk agent disconnectdeletes the folder; unlink the device on the phone too. - Its endpoint listens on 127.0.0.1 only, at a port of its agent's
block, where other users of this machine can reach it too. It refuses
any request without the key Link makes at each start, which it gets
in
config.jsonand the bridge inbridge.json, both mode 600, compared in constant time. - It proves it holds that key before the bridge sends it, on the same connection: it answers the bridge's random challenge with an HMAC of it made with the key, never the key itself. A program that took its port while it started or restarted can't, and gets neither the key nor your message.
- A thread's name is letters, digits,
-,_,.and one:, never a path, so a message can't write outside the agent'sconversations/. Each agent's conversations are in its own files folder; a test shows one agent can't read or write another's. - It keeps each message as a file, never rewritten, readable only by
you (600, folders 700): the text, times, model, tokens and cost. They
are in your files, so a backup, a move and the next harness have them.
Beside them,
window.json(600) holds only where the model's window starts: a message's number and time, and token counts. One that doesn't fit the messages it names is made anew from them. Its readable copy,conversation.md, which memory searches, puts a\before any line of a message that would start a heading or HTML, so an answer can't write a turn there as yours. - The files its prompt is made of (
instructions/instruction.md, or those its spec names) are read at each message, at most 64 KiB each, only inside the agent's files: a path with.., or through a link, is refused. - It reads and writes a conversation only in its own folder, and
refuses one that is a link. It never follows a link or waits on a
pipe there, at a file of its prompt or at its
config.json, and refuses a message's file over 16 MiB and aconfig.jsonover 1 MiB. - Each request names its agent (
Link-Agent). It answers only the agent it runs, with that agent's key: a request naming another is refused before its key is checked or the model sees it, and another agent's key proves nothing. A test shows one process answering two agents keeps each one's key, instructions and conversations its own. - Everything of an agent's it keeps is keyed by the agent's ID and
owner, never its name. A request naming by name alone an agent two
owners share is refused (409); one whose
Link-Agent-Idand name disagree, too (404). An ID two owners' agents share (the same backup restored by both) is the agent whose key the request carries. A test runs one process for two owners'mains, with the same ID, instructions, model and tool server: each answers from its own conversation, settings and tool sessions only. - A move carries the agent's owner. A machine signed in as another account refuses the agent before anything of it is put in place.
Installing a harness
- Link Harness installs nothing from a third party: each agent runs its adapter's own program, as installed, which its sandbox reads (that file, not the folder it's in) and can't change.
- Any other harness, OpenClaw, Hermes and DeepSeek Harness, which Link ships, or one from outside Link, is installed by its adapter, which runs its harness's own installer pinned (the script from the project's repository at a release's commit, run only if its SHA-256 is the one in the adapter's source), or downloads what it needs checked against a SHA-256 of its own. What that installer fetches itself may not all be pinned: the adapter's page says (OpenClaw, Hermes, DeepSeek Harness).
- It runs over HTTPS, in the home and the sandbox: it can download anything, but write only the home, reaching only the hosts the adapter lists.
- A new harness release comes with a release of its adapter.
A harness from outside Link
- Its adapter,
lnk-<name>, comes from its own repository's releases, signed by that repository's key, whichlnkpinned when you added it (Plugins Security). - The adapter itself runs as you:
lnk-harnessruns itscontract,channels,hosts,endpoint,needs,tracesandstateoutside the sandbox. Itsinstall,configure,link,commandandhealth, and the harness, run in the sandbox like Link Harness's. - What it asks for is checked as for any harness: how it says to run its harness can't widen the sandbox, its ports must be in its agent's block, and it waits at most ten minutes for its harness to answer.
One home per harness
- Each harness, its installer and its configuration steps (
config set, a harness's own repairs) run inside the sandbox withHOMEset to the harness's own home,~/Link/Agents/<agent>/Harnesses/<name>(only yours, mode 700). None of them can spread across the machine. lnk agent <harness> removedeletes the home and what the adapter names outside it. The adapter'slnk-<name> tracesruns outside the home and the sandbox, and only prints a list.- What merely carries the harness's name as a whole word is deleted
only after asking, never with
--yes.
A harness's own services
A harness's own background service (a launchd agent or systemd unit it
installs for itself) would run outside the sandbox, so lnk agent start
stops it and moves its plist aside (*.lnk-off). The names come from
the adapter's command, which runs in the harness's home, so none may
be Link's own: every name starting link. or lnk-, such as an
agent's service or the exit's, is refused.
Sandbox
One sandbox, the sandbox plugin's
- Every harness runs in the sandbox plugin's sandbox (
lnk-sandbox, installed with them), on by default. The harness plugin askslnk sandbox wrapfor each step it runs (the installer,configure, the harness), with a policy naming the folders, ports and permissions below. How the sandbox is built on each system, and what it keeps closed whatever a policy says (Link's settings among them), is the sandbox's security. - A harness's policy writes its home and the files folder (the
harness itself only, not its installer), and on macOS
/tmp/<name>*, where a harness may keep locks. It hides~/Link/Agentsbut for its own agent's folders (Agents kept apart). - The localhost ports it may connect to are its own, the ones its adapter names (its gateway or API, and the ports it derives from it), and its local model's.
Agents kept apart
A machine runs any number of agents, all as your user. The sandbox keeps each out of the others:
- Every harness's policy hides
~/Link/Agentsexcept its own agent's folders, even withread-home. - What an adapter says to run can't name a folder in there, and a files folder
can't be another agent's, nor one in its own agent's folder outside
its
Home(where Link keeps its scheduler's jobs and its memory's index, out of the harness's reach). lnk agent <harness> checktries reading what's hidden.- Each agent but
maingets its own block of ports (LNK_PORT), as many as its harness takes and one more (200 for a harness that doesn't say). A harness whose adapter names a port outside it is refused. On macOS, withoutnetwork, a harness may listen only on its own ports. - An agent doesn't start while another program listens on one of its harness's ports on this machine's loopback: that program would be taken for part of the agent, such as its channel's daemon. On macOS that's every port of the harness, behind its proxy too, since its channel daemons listen on the machine's loopback there. On Linux behind its proxy, it's the ports the proxy serves: the others are in the sandbox's own network.
- A program can still take one of those ports in the moment between
that check and the harness's bind, or while the harness's service
restarts. So a harness whose adapter says
proofin its contract (Link Harness among them) proves it holds its endpoint's key before Link's channel plugins send it anything: each message goes on a connection of its own, which first answers a random challenge with an HMAC of it made with the key. Whatever else answers on the port gets neither the key nor the message, the owner is told the agent couldn't answer, and the bridge's log says why. An adapter withoutproofgets the key and the message on whatever answers its port. - The proof shows only that the challenge reached a harness holding
the key: the harness answers it before any other check. A program on
the port could relay the challenge to the harness, were the harness
also reachable at another address. Link Harness and DeepSeek Harness
listen only on
127.0.0.1, and so do the ports a sandbox's proxy serves, so a program holding the port leaves the harness nowhere else to be asked. - The bridge that carries a channel's messages runs outside the sandbox, and reads an answer, streamed or whole, up to 16 MiB, and a line of a stream up to 16 MiB; past that, the answer fails.
What stays shared:
- Other agents' localhost ports, where the sandbox lets localhost
through. A harness's gateway asks for its token. On Linux behind its
proxy, only that gateway is served on this machine's loopback. On
macOS, and on Linux with
lanor without the sandbox, the harness's other ports are there too. A channel daemon that asks for nothing there lets another program or user of this machine send as the agent and read its messages, so each harness's adapter keeps its daemons off them where its harness can: on a unix socket in the harness's home (700), or behind a relay that asks for a secret the harness is given. What each adapter does, and what it can't, is on its own security page. - On macOS,
/tmp/<harness>*between agents running the same harness; a harness may keep its locks there. Linux gives each sandbox its own/tmp.
Each agent needs its own bot or app:
- Telegram:
lnk agent connect telegramrefuses a bot another agent here has, by the bot's id (the token's public part), one that an agent running on a box has, or one Telegram says another program reads. - Slack:
connect slackrefuses an app another agent here has, by its bot's member id or either token, or one that an agent running on a box has. - Discord: the same, by the bot's user id or its token.
Permissions
Permissions open the sandbox further, in two layers, each a file of Link's settings (600, where no sandbox reaches), and a harness runs with both:
- The agent's, what you grant it:
lnk agent allow|deny <permission>, or a file withlnk agent start --sandbox, saved in~/.config/lnk/agents/<agent>/sandbox.toml. They follow the agent to any harness, and no other agent has them. Neverunsandboxed. - A harness's, only what it said it needs (
lnk-<harness> needs) and you allowed: saved in~/.config/lnk/harnesses/<harness>.toml, held only while that harness runs, never given to another, and the same for every agent here that runs it.lnk agent <harness> allowanddenychange one it declares, and nothing else: a harness only ever holds what it asked for.
lnk agent permissions shows both, each marked whose.
-
Asked, not assumed. A harness says what it needs beyond the defaults, and nothing of it is granted until you answer yes, in a terminal, by
lnk agent <harness> allow, or by--yes, which never allowsunsandboxed. What the agent has of its own asks nothing. Switching an agent to a harness whose needs open more than its old harness's is asked the same way, each line with what it opens; a no keeps it on the harness it had. The question names every agent here it holds for. No changes nothing. The answer is kept with the needs it answered, so a later start asks only for a new one; a need you took back isn't asked again untillnk agent <harness> repermit. A need Link doesn't know is refused, and the harness doesn't start. A risk a harness names itself (a port of its own with no credential) is asked the same way; one it can run without is off until a yes in a terminal, never--yes, and the harness is told which it may open. It's told too whether it runs behind its proxy, which on Linux keeps its other ports off the machine's loopback, so it names such a risk wherever they're there: on a Mac, and anywhere without its proxy (no sandbox, orlan). On a move to a box, the question is asked on this computer from the box's answer: a need there that isn't a name Link takes, or an agent's name that isn't one, is refused before anything is shown. -
Always shown. Every start of a harness with more than the defaults prints what, and
lnk agent statusshows it. -
needsgets no keys. It runs outside the sandbox, so its settings come with the model's key and the channels' tokens left empty, and its environment holds onlyPATH,HOME,LANGandTMPDIR. -
Removed with it.
lnk agent <harness> removedeletes the harness's grants and answers, unless another agent here runs it. -
Moved with the agent. A move asks on the machine running it: leaving it, before anything moves, and of the harness's grants it gives, only what the harness asks for on the other machine (its
needsthere) is added to the harness's there: not the rest of this machine's file, so neverunsandboxedit doesn't ask for; going anywhere, before the agent starts where it goes, and the other machine grants only what its harness asks for there (lnk agent needs --allow). Refused, or failing to start there, its copy there is removed again and it starts where it was, so two never answer one bot. The agent's own grants go with it: whole when it leaves the machine running the move, kept as they are when it comes back to one that has them, and otherwise withoutread-home,localhost,lan,open, its endpoints (tcp) or its tools that run outside (tools). A stream never writes a harness's grants. -
Each agent's own tools are its own. Its memory and scheduler are tools Link defines for it alone (
memory-<agent id>,scheduler-<agent id>) and hands only to its harness: no agent's grants name one, whether fromallow tool,--sandboxor a move. -
network(on): the internet, through its filtering proxy, for hosted models and channels. Its installer and setup steps go through a proxy of their own, which leaves from this machine, and the installer andconfigurereach only the hosts its adapter lists (lnk-<harness> hosts: typically its installer, GitHub and a package registry). Either way, macOS's certificate checks (trustd), which TLS through the Security framework needs, as Python'struststoredoes. Withoutnetwork: its localhost ports only on macOS, no network at all on Linux. -
developer-tools(on): read-only Xcode and Command Line Tools, so/usr/bin/gitruns throughxcrun, and Xcode's own preferences (com.apple.dt.Xcode: whether its license was accepted, whichxcrunchecks). -
open(off): opening anhttps://page in your browser through its proxy, a login's for example, after asking you for each page. The page opens with your logins, so a URL can carry data out: allow only a page you asked for. No LaunchServices, and nothing without its proxy. -
read-home(off): reading, not writing, the whole home folder. -
localhost(off): every localhost port, so the other servers on this machine. Behind its proxy it adds nothing: it works withlanonly. -
lan(off): the network as it is, in place of its filtering proxy: this machine's own addresses and its local network too, with the known gaps. An exit ignores it. -
unsandboxed(off): no sandbox at all, for a harness that can't run in one. It reads and writes what you can. Every start warns, as--no-sandboxdoes, andlnk agent <harness> checkrefuses to run. On a box, whose agents always run behind their proxy, it doesn't start.
No permission opens the Keychain: no sandbox reaches securityd.
An agent's settings from before, with keychain, are read without it.
Hosts (lnk agent allow|deny host <host>, the agent's, in its
sandbox file) are the only hosts its proxy lets it reach, passed as the
proxy's list. With none listed, any. With lan, or without network,
the list does nothing.
Calls (lnk agent allow calls <n>, deny calls) are the most its
proxy takes within a minute, of every verb; more get a 429.
Installs don't use these: the installer and setup steps run behind
a proxy of their own, each step with its own sockets, direct from this
machine even with an exit, logged as <agent>-<harness>-setup. The
installer and configure reach only the hosts the adapter lists
(lnk-<harness> hosts); an adapter that lists none lets them reach any
host on the internet, never this machine or its network. Its other
steps, such as link, which runs what the harness installed in its
home (a WhatsApp bridge, signal-cli), reach only what the
harness may reach when it runs: your hosts and endpoints, any host
without them.
Nothing it writes can widen the sandbox
- The rules files live in Link's settings folder, where no harness can edit them.
- The files folder can't hold the harnesses' homes, so no harness reaches another's.
- The files folder can't be a folder whose writing is a way out: the
home folder or one holding it, a dot folder in it (
~/.config,~/.local,~/.ssh),~/Library/LaunchAgents, Link's settings orlnk's own folder. - At the top of the files folder, what runs outside the sandbox (
.git,.envrc,.vscode,.idea) is read only to the harness, so it commits in a repository it makes inside; in its own home it's its own (the sandbox's security). - What an adapter asks for (
lnk-<name> command, run in the home) is checked too: no extra folders the harness can already write, nor the home folder, Link's settings or/tmp; service names that are only names; noHOME,DYLD_*orLD_*. - The environment is cleared: no
SSH_AUTH_SOCK, no tokens from your shell. - On macOS, each start writes Link's
mktempinto the harness's home (~/.lnk/bin), from outside the sandbox, without following a link: a link in place of.lnkorbinstops the start, and one in place ofmktempis replaced, never written through.
Its log is out of its reach
- launchd and systemd append the harness's output, from outside the
sandbox, to a log in Link's settings folder
(
~/.config/lnk/agents/<agent>/logs). Were it in the harness's home, the harness could replace it with a link to~/.zshrcand have its output written there. lnkrefuses a log that isn't a regular file.lnk agent logsshows its last 200 lines without control characters (no escape sequences), from where the last start began at the earliest.lnkrecords that point; it isn't a marker the harness could print.- It reads at most the last 4 MiB, and
-fshows a line without an end in 64 KiB pieces, so a harness printing no newlines can't make it hold ever more.
Running a command in its sandbox
lnk agent <harness> run runs a command under the same policy as the harness:
its home, the files folder, its permissions, its hosts and endpoints,
and its proxy, with the model key added outside as the harness's is. It
has a rules file and a proxy of its own (on a Mac, its own port; on
Linux, its own sockets folder), so it never changes the running
agent's, and its calls are logged under its own name. With the sandbox
off (--no-sandbox on the last start), it warns and runs unconfined,
as the harness does.
Checking its sandbox
lnk agent <harness> check runs lnk sandbox check's probes inside the
harness's real sandbox, with its home, its permissions and a
<name>-check profile. It refuses to run with the sandbox off or
unsandboxed.
With keys on the wire, it also looks for the model key and each secret the proxy adds in the harness's home. It opens each name there from its folder without following a link, and reads only regular files, at most 64 MiB each, so a harness swapping in a link or a pipe can't make it read elsewhere or hang.
Tests run a fake harness whose installer tries to write outside its
home, and which tries to read ~/.ssh and see /run and other
processes: with bubblewrap where the kernel allows it, and a Seatbelt
twin on a Mac.
lnk agent start --no-sandbox turns it all off, with a warning, for
that start only. Only you or your script turn it off, from outside any
sandbox: nothing an agent runs reaches the files that decide it, and
unsandboxed = false in ~/.config/lnk/machine.toml forbids it on the
machine, whatever asks. Any other command that restarts it (use, connect,
disconnect, allow, deny, memory, route, keys) starts it
sandboxed again.
A background service running a harness with no sandbox runs it through
lnk agent unsandboxed, which reads machine.toml first, at every
restart and boot: once it says unsandboxed = false, or can't be read,
the service refuses, says so in the harness's log, and removes itself,
so it doesn't run again until lnk agent start starts it in its
sandbox. A service written by a lnk from before this check holds its
command as written, and runs unsandboxed until the agent's next start.
Where Ubuntu's rule for user namespaces stops the sandbox, lnk agent start offers to add its profile first, and never starts the harness
unsandboxed on its own (Ubuntu's
rule).
Messages and tools
Only you can text it
- Telegram:
lnk agent connect telegramprints a one-time code, 8 characters, about 40 bits. It records the user id of whoever sends it, alone as the whole message, to the bot in a private chat within 10 minutes; at.melink sends it for you. Only someone who sees your terminal can. Group messages, other messages and bots are skipped. It configures the harness with that allowlist, and some harnesses with their owner commands too; the harness refuses everyone else. For Link Harness, the bridge refuses them itself: only that user's direct messages and mentions in a group reach the harness, and the rest get no answer. Errors never print the bot token. - Slack: the same, with the member id of whoever sends the code to
the app's bot in a direct message. Link reads it with the bot token
through Slack's Web API, the token in a header; messages from bots,
edits and older messages are skipped. The harness takes direct
messages from that member only, and in a channel only their mentions
of it. Its two tokens, bot and
app-level, are checked with
auth.testandapps.connections.open, which opens nothing. The app runs in Socket Mode: the harness connects out to Slack, and nothing on the machine listens. - Discord: the same, with the user id of whoever sends the code to the bot in a direct message. Link sees it arrive on Discord's Gateway, a websocket, as the bot, with only the direct messages intent, while it waits; it connects before showing the code. Messages in servers and from bots are skipped. Link keeps that direct message's channel id beside the user id: a harness's home channel. The harness takes direct messages from that user only, and in a server only their mentions of it. Link refuses a bot whose application lacks the Message Content intent the harnesses ask for.
- WhatsApp:
connect whatsapptakes the agent's number and the owner's, typed at the terminal, so whoever sees it decides. The harness allows direct messages from the owner's number only, and in a group only their mentions of it. It's linked as a device, as WhatsApp Web: the harness shows the QR code and keeps what links the account, its keys to the whole WhatsApp account, in its home, where the sandbox holds it and a move carries it.disconnect whatsappstops using it; unlinking it on the phone ends it. - Signal: the same way, with the agent's own Signal number, never
the owner's. Every harness, and Link's bridge for Link Harness, speaks
Signal through
signal-cli, which the Signal plugin downloads into the harness's home only if its SHA-256 is the one pinned in Link's source, as is the Java runtime it needs off Linux x86-64. For a harness that speaks Signal,lnk-signal cli installandcli linkrun in that harness's own sandbox, as its adapter's steps do, so nothing the harness can write runs outside it. It keeps the account's keys in~/.signal-cli/datathere, where the sandbox holds them and a move carries them.linkrefuses a phone whose Signal is another number than the one given. - In a group, channel or server the agent is in, every harness answers only the owner, and only when they mention it: Link writes each harness's group settings that way (any group, the owner as its only sender, a mention required), and Link Harness's bridges pass on nothing else. On Telegram a reply to the agent counts as a mention. On Discord a reply counts only when it pings the agent, as a reply does unless its sender turns that off. On WhatsApp, Link Harness takes only an @mention, not a reply, and on Signal every harness does. The owner adds the bot to a group themselves.
lnk agent disconnect <channel>forgets a channel's tokens.- Each channel's pairing is a plugin of its own (
lnk-telegram,lnk-slack,lnk-discord,lnk-whatsapp,lnk-signal), run bylnk agent connectoutside the sandbox, as the harness plugin is. It takes its tokens from its variables or the terminal, and hands them back on its stdout, a pipe to the harness plugin, never on a command line. Its arguments are the agent's name and the ids of bots other agents have, none secret.
Messages from others
- A message from a schedule or a subagent reaches Link Harness with
Link-From, is kept as from them, and the model is told who's speaking, with each line of it quoted, so no paragraph of it reads as yours: it's never taken as yours. ALink-Fromnaming no sender is refused. - Every such message carries a hop count; the scheduler refuses one
past the agent's cap (
[tool.scheduler] hops, Link's 8). Hops bound how deep a chain goes, not how many messages it sends (each firing of a recurring job starts again): that's the cap a day ([tool.scheduler] per_day), with the cap on subagents at once, so agents can't wake each other for ever. - The scheduler runs as a tool, outside the agent's sandbox, confined
to writing its jobs (
~/Link/Agents/<agent>/Scheduler). Which conversation a call is made in, and its hops, come from the harness (_meta), never from what the model writes, and a call lists and cancels only that conversation's jobs and its subagents'. The scheduler trusts what the harness sends: Link Harness runs no code the model writes, but a harness that does (a shell) could name another conversation or reset its hops, bounded then by the day's cap alone. - A subagent has its parent's tools or fewer, never more. The
scheduler takes a list only of the tools the harness says the
parent's conversation has (
_metalink.local/tools), and, given none, the parent's whole list; the harness keeps the list with the subagent's conversation and narrows it again at each list, never widens it. A harness that can't keep one isn't sent one: the bridge and the scheduler refuse it rather than send a subagent every tool it was meant not to have. The list bounds what the model is offered and calls; what each tool may do is still its grants'. - The bridge's way in is a Unix socket with the agent's settings (600),
which only you reach. An answer it sends goes only to a chat of the
owner's alone (Telegram: an allowed user's own chat; Slack and
Discord: the owner's direct messages with the bot, kept when they
sent the code), never a group or anyone else; elsewhere it stays in
the conversation. A Slack app or Discord bot connected before Link
kept that sends no such answer until
lnk agent connectagain.
Memory
- The engine runs outside the agent's sandbox, as its tool (
lnk sandbox tool add, asmemory-<agent id>), and in a sandbox of its own: it reads the agent's files folder and its own program, not the folder that program is in (~/.local/bin, withlnkand every plugin), and writes only its folder in the agent's (Memory/<engine>), its HOME. With the agent's sandbox off (--no-sandbox), it runs unconfined. - Its embedding model is served for it outside its sandbox (
lnk model serve, started with each session and stopped with it), and its sandbox is given the address in$LNK_MEMORY_MODEL; one that hasn't said where it listens within 10 s is stopped, and the engine starts without it, searching by words. To reach it, Link Memory's sandbox hasnetwork: on Linux, the only way to this machine's ports. So it could reach the internet, though it calls only that model, on 127.0.0.1. Nothing it indexes leaves this machine: the model runs here. - Link Memory reads only its folder: it never follows a symbolic link
or goes into a hidden folder, and
readserves only what it indexed. It opens every name from its folder's handle without following a link, so a link swapped in while it reads is refused too, and reads only regular files, at most 1 MiB each, however they change meanwhile. Tests show nothing outside is reached. This holds by path: a hard link in the folder is a regular file, read like any other, so a hard link a harness makes there to a file it can already read is indexed. - Its four calls only read, so none asks you first. Another engine's calls that write ask, as any tool's do.
- The agent reaches it only through its proxy, which logs each call
(
lnk sandbox log). Each agent has its own, over its own files; one agent's proxy names no other's. - Its index holds a copy of what it indexed, and each chunk's vector,
in a folder only you can read (700), which no harness reaches. It
also keeps the path and title of each document deleted from the
folder, the latest 10,000, for
list {since}; your agent can list them, as it could the documents before.lnk agent memory offleaves it; delete it by hand.
Keys and models
Secrets
- The model key and the bot token are saved in the agent's settings
(
~/.config/lnk/agents/<agent>.toml; on a Mac, in the Keychain instead) and in the harness's own.envin its home, which the harness needs. All are mode 600. - They reach the adapter on stdin, never on a command line or in the
background service's definition: the LaunchAgent plist is readable by
other local users. The harness's
.envstays a file. - For a harness Link carries channels for (Link Harness), the bot token
never reaches the adapter, nor the harness's proxy:
lnk-harnesshands it tolnk-<channel> bridgeon stdin, and the bridge service (lnk agent bridge) reads it from the agent's settings itself, never from its definition. - With keys on the wire (the default for Link Harness and for a harness
whose adapter says it takes the proxy's address, or
lnk agent keys wire; behind the proxy), the harness's.envgets a placeholder instead of a hosted model's key, and the proxy adds the real one outside the sandbox. The same goes for a Telegram bot's token: the harness gets the bot's id and:link-wire-with a tag of the token, and reaches Telegram through its proxy, which adds the token and refusessetWebhook,logOutandclose, by the rule the Telegram plugin gives (lnk-telegram rule --json) (Sandbox Security). An agent doesn't start while a connected channel's plugin can't say whether it has such a rule, so its secret never reaches the harness by mistake.lnk agent <harness> checkreportsLEAKif the key or the token is anywhere in the harness's home. Slack's, Discord's, WhatsApp's and Signal's stay with a harness that speaks them. - The key proxy passes inference only: messages, completions, embeddings and the models' list, never files, batches or the account. The provider's own tools that run on its servers (web search, web fetch, code execution, MCP servers) are removed from each request, and a request asking the provider to fetch a URL, such as an image or a document, is refused: they would reach the internet for the agent.
A local model
lnk agent start offers the models lnk model list finds, or on an
Apple Silicon Mac with none, to install the one lnk model recommend
picks. A local model needs no key: the harness talks to the runtime on
localhost, and nothing leaves the machine for the model. Behind its
proxy, a local model is reached for inference only: its admin API,
which pulls and deletes models, gets a 403.
On your machine
Keeping the machine up
- On a Mac, the background service runs the harness under
/usr/bin/caffeinate -i, outside the sandbox. It only holds off idle sleep while the harness runs;--let-sleepturns this off. - On Linux,
lnk agent startturns on lingering for your user (loginctl enable-linger) so the agent keeps running after you log out, as a server needs. Everything else of yours that runs as a systemd user service does too. Where systemd doesn't let you, it prints thesudocommand instead.
Where Link keeps an agent's things
All out of every harness's reach but its own home and files folder:
~/Link/Agents/<agent>/Home: its files folder, unless--filesnamed another. Link Harness keeps its conversations there, inconversations/. Its sandbox reads the folder and writes onlyconversations/and the folders the agent's sandbox file lets it ([files] write:instructions/,skills/andwork/by default); a harness from outside Link writes the whole folder.~/Link/Agents/<agent>/Harnesses/<name>(700): a harness's home, with its install, memory and conversations. On a Mac, its.lnk/binholds Link'smktemp, first on the harness'sPATH.~/.config/lnk/harnesses/<harness>.toml(600): what a harness said it needs here and you allowed, for every agent that runs it, and the needs you answered.~/.config/lnk/agents/<agent>/sandbox.toml(600): what you grant the agent, whichever harness runs it.~/.config/lnk/agents/<agent>.toml(600): its ID and name, the active harness, its ports, the model and its key, the files folder, each channel ([channels.<name>]: its settings with its token and your user id, its bot, and what its plugin said of it), and its memory engine. On a Mac, the key and each channel's secret fields are in the Keychain instead. Settings from before the map of channels (a[telegram]section) move into it the first time they're read.~/.config/lnk/agents/<agent>/logs/<name>.log: its log.~/.config/lnk/agents/<agent>/bridge.json(600): for Link Harness, its endpoint, the key it takes and the channels Link carries;bridge.logbeside it, the carrying's log, which never holds a message.~/.config/lnk/agents/<agent>/channels/<channel>(700): for Link Harness, what links WhatsApp (the device's keys,whatsapp.db) and Signal (signal-cliand its account).~/Library/LaunchAgents/link.bridge.<agent id>.pliston macOS, orlnk-bridge-<agent id>.serviceon Linux: the service carrying its channels, stopped with the agent.~/Link/Agents/<agent>/Memory/<engine>(700): its memory's index.~/.config/lnk/agents/.net/<agent id>/<name>(700): the sockets between the harness's own network and Link's proxy (<name>-<step>, a setup step's), andurl.secretbeside them.~/.config/lnk/sandbox/<agent>-<name>*.sb: the macOS sandbox rules.~/Library/LaunchAgents/link.agent.<agent id>.pliston macOS, or the systemd user unitlnk-agent-<agent id>.serviceon Linux: one service per agent, replaced when it switches harness.~/Link/Agents/.incoming(700): where a move unpacks an arriving agent, removed after;~/Link/Agents/.restoring, where a restore unpacks a backup.~/Link/Agents/.moved/: the files folders of agents that moved away, for 30 days, or as long askeep_movedinmachine.tomlsays.~/.config/lnk/moved/<id>.toml: a note of where an agent that left went, and when.~/.config/lnk/machine.toml: this machine's ID and name, andunsandboxed = falsewhere no agent may run without a sandbox. A deployment may write the file with that line alone, beforelnkfirst runs: the ID and name are added to it, its other lines kept.
The layout before agents had names
The first run of lnk agent moves the agent from ~/Link/Agent to
main: its settings from harness.toml, removed after, its files, and
each harness's state (what its adapter's state names, never its
install or .env) into its new home. The old service is stopped and its
file removed before anything moves. The old installs stay in
~/Link/Agent/Harnesses (700) until you delete them, with the
harness's .env of keys (600); lnk agent <harness> remove deletes
them with the rest.
Moving and backing up
Moving your agent
lnk agent move carries one agent at a time between this computer and
your boxes, or between boxes.
- What travels: the harness's state (its memory and conversations,
and anything it keeps in its home but its install), the files folder,
and Link's settings for it, including the model key, the Telegram
bot token, the Slack app's and Discord bot's tokens, and its buckets'
encryption password. The password is obscured, as rclone keeps it,
which isn't encryption. On a harness Link carries channels for,
WhatsApp's and Signal's linked device travels too: what the
channel's plugin names as its link (
lnk-<channel> state), never a program it installed. - Where to: a box you name, or
here. A cloud account's name (lnk agent move aws) takes its one box without asking only when this computer made it. A box found in the account by its tags alone is yours only as far as anyone who can tag machines there says so: the move asks on a terminal, and without one refuses and says to name the box. So does a box made elsewhere that is named like one of your cloud accounts. With--image, the move always starts a new box from that image of yours in the account, which this computer makes; an image holds no agent, and nolnklogin (images you save). - How: one machine's
lnk agent sendpipes into the other'slnk agent receive, through your computer, over SSH with Link's key for a box (lnk box run). Never through the relay, a bucket or a command line. A key given for the move (--key) travels in the stream's first line. - Receiving: the stream is unpacked into
~/Link/Agents/.incoming(700, out of every harness's reach), and no path leaves it:- an entry with
..is skipped, and a leading/dropped - an entry reaching out through a link unpacked before it, or a hard link to a file outside, stops the receive
- a stream of another format, from an older or newer
lnk, is refused before anything is put in place, and so is an agent whose name another agent there has, with a different ID - only then is anything replaced: the state the receiving machine's
adapter names (
lnk-<harness> state), in the harness's home, and the files folder, where incoming files overwrite and nothing is deleted. What the stream says is state counts for nothing there, and nothing else that came for the home (an install, a.env) is put in place. The state goes in as a restore's does, from folder handles (Backups and restores), and so do the files: each name is moved from the folder holding it, opened without following a link, so a link a harness process that outlivedlnk agent stopswaps in for a folder there is replaced, never written through. - links are carried as links, never followed on either side: a link
in a files folder can't pull a file from outside it into the stream,
nor send one out when received.
sendopens each name from the folder holding it without following a link, and reads a file from what it opened, so a link swapped in while it reads, by a harness process that outlivedlnk agent stop, goes as a link too, and one on the way to a state path stops the move. A link is carried as it is, pointing wherever it pointed,/etc/passwdor../..included: a program you run outside the sandbox that follows it reaches that target, on the machine it lands on. - the link rules above hold by path: a hard link is a regular file, sent like any other, so a hard link a harness makes in its home or files folder to a file it can already read is carried, by moves and backups alike.
- files and folders keep their permission bits only (
0o777): never setuid, setgid or sticky, whatever the stream says. - a linked channel's device goes to
~/.config/lnk/agents/<agent>/channels/<channel>(700), for a channel name Link knows only. What of it comes is what the channel's plugin on the receiving machine names (lnk-<channel> state), never what the stream says. A path out of that folder refuses the whole stream, and links and anything else are dropped, since the plugin runs outside any sandbox. - each channel is what its plugin on the receiving machine says of
it (
lnk-<channel> describe): which fields are secret, whether it's linked, its title. A name that can't be a channel's, or that no plugin there describes, is left out, and the receive names it. Titles and hints are printed without control characters. - what the agent may do comes from the machine running the move,
never from the stream alone, which the machine it left wrote. An
agent leaving the computer you run
lnk agent moveon keeps its grants, which that computer sends in the first line. One coming back keeps the grants it has on the receiving machine. Any other gets the stream's, but forread-home,localhost,lanandopen, which open the machine itself, and its endpoints (tcp) and tools (tools), which reach or run past its sandbox: the receive names each one left out, andlnk agent allowgives it. - a model on the machine's own loopback (
ollama,local) is taken from the stream only when it comes from the computer running the move: a box could name any local port that answers, which the sandbox would open to the harness as its model's.
- an entry with
- The machine it left deletes its copy once the agent answers where
it went, running, with its ID: its settings (model key, bot token), its
harnesses' homes (state, install,
.env) and its service go. Its files folder is kept in~/Link/Agents/.moved(30 days, orkeep_moved), out of every harness's reach. If it doesn't answer there, the machine it left keeps its whole copy, stopped, keys included, until a move completes. - One place at a time: the machine it left keeps a note of where it
went, and
lnk agent startrefuses that agent's ID there, so two copies can't answer the same bot. A move between two boxes updates this computer's note. - One ID, one agent: a machine refuses an arriving agent whose ID another agent there has, since its services, sockets and tools are keyed by it.
- A box's word for an agent's ID is taken unless this computer's note says that agent is on another machine: then the move refuses, before stopping anything. So a box can't bring home an agent under another's ID, which would revoke the bucket key of the box that agent is on and end the note of where it is. An agent this computer never had is known only by what its box says.
- A box's backups: a box holds no cloud account of yours. For its
agent's buckets, your computer makes a key reaching only them: an AWS
IAM user
lnk-<bucket>whose inline policy allows that bucket's objects and listing it, nothing else, or a Google Cloud service account withroles/storage.objectAdminon that bucket alone. It sends the key in the move's first line over SSH, and deletes the key the bucket had before. The box keeps it in itsrclone.conf(600). When the agent comes back to your computer, the key's user or service account is deleted; a move to another box replaces the key. - Someone who takes over a box gets that agent's bucket, still encrypted with the agent's password, which the box also holds.
- Making the key needs an account allowed to make IAM users or service accounts. Without one, the box has no backups, and the move says so.
- Upgrading a box: a move runs
lnk upgrade <version>on a box whoselnkis older than your computer's, over the same SSH. The release is verified as any upgrade is.
Backups and restores
- Backups:
lnk agent backupwrites the harness's state (the same paths a move sends, never its.envof keys, nor Link's settings) to.lnk-backup/<harness>.tarin the files folder (600). It reads the state as a move'ssenddoes, never through a symbolic link (a hard link is carried, as a move carries it), and writes the archive through the backup folder's handle: a link in the folder's place is removed first, and one swapped in after isn't written through. It thenlnk bucket pushes it and the folder to your clouds, encrypted if the agent's buckets are. The harness's own config files go with it, and may hold what it keeps there itself, such as a local gateway's token. - Going back to a backup:
lnk agent restoreof an agent that's here pulls.lnk-backup/<harness>.tarfrom your clouds and unpacks it in~/Link/Agents/.restoring, out of every agent's reach, the way a move's stream is unpacked, so no path leaves it. It moves only the harness's state paths into its home, never its install or.env. The archive sits in the files folder, which the agent can write, but it only ever lands in the harness's own home, which the agent can write too. It's opened only once the agent is stopped, and through the backup folder's handle: the folder and the archive are each opened without following a link, and the archive refused unless it's a regular file, so a link put in the backup folder's place, to another agent's backup, or a pipe, is never opened. A harness process that outlivedlnk agent stopmay still change that home meanwhile, so the restore removes and moves each name from the folder holding it, opened without following a link: a link on the way to a state path stops it, and one in a state path's place is removed or replaced, never followed. - Restoring a lost agent:
lnk agent restore <name>finds an agent's buckets by their labels in yourlnk cloudaccounts, asks for the encryption password with echo off, and adopts the buckets with a key reaching only them, as a box does. The model key and bot token aren't in a bucket: they're asked again. The harness's backup comes back the way going back to a backup does, through~/Link/Agents/.restoring, with only its state paths. Its name picks the harness only among those there are here (Link's:link,openclaw,hermes,deepseek, and each added from outside Link), since the agent wrote that folder: a harness from outside Link is added first, then restored this way.
Gaps and limits
Known gaps
A harness's sandbox has the sandbox's known
gaps, and lnk agent <harness> check reports them as open. Which apply depends on its permissions:
- Behind its proxy (
networkwithoutlan, the default),network's are closed. What stays: each of its own localhost ports that something serves, its gateway's among them, and on Linux a new.envrcin the files folder. - With
lan, the harness hasnetwork's: every localhost port and the machine's abstract unix sockets on Linux; on macOS every localhost port through::ffff:127.0.0.1, and the Mac's own address. - Without
network, it reaches its own localhost ports only on macOS, and no network at all on Linux.
The sandbox stops a harness that connects to localhost or
127.0.0.1, not one that means to get around it
(Known limitations).
Limits
- Harnesses: Link Harness; OpenClaw, Hermes Agent and DeepSeek Harness, which come with Link; and those from outside Link. Channels: Telegram, Slack, Discord, WhatsApp and Signal: direct messages, and mentions in a group, from the allowed users only. Link Harness: text only.
- Link Harness sends the model as much of a conversation as its window takes (32,768 tokens for a model Link doesn't know); older turns stay in the files only, where memory searches them.
- Link Harness's tools need a model that takes tools; with one that doesn't, each message fails while a tool is on.
- On Link Harness, a picture, voice note or sticker gets "I can read text only, for now.", and an edited message isn't carried.
- On Link Harness, a Discord message sent while its Gateway connection is down is missed. WhatsApp and Signal deliver what came meanwhile when they reconnect.
- On Link Harness, WhatsApp's bridge answers a message delivered twice once: it remembers the ids of the last 1,000 messages it carried. Only the owner's count, so a stranger's messages can't push them out.
- On Link Harness, a WhatsApp contact known only by WhatsApp's own id (a LID), without a number, gets no answer; so does a Signal sender who hides their number.
- With WhatsApp on your own number, a mention in a group is of you: Link Harness doesn't answer it.
- Signal needs a number of the agent's own.
- WhatsApp is linked as a device, which WhatsApp doesn't allow for bots: it can restrict the number.
- Each agent runs in one place at a time; a machine runs any number.
- Behind its proxy a harness reaches the internet over TCP, through
HTTPS_PROXYorHTTP_PROXY: not UDP, and not what ignores them. Anything ignoring the proxy doesn't connect. - The home exit carries TCP (web requests, APIs, Telegram), not UDP. It
needs this computer on, and a harness honoring
HTTPS_PROXY. It runs on Linux boxes; on a Mac, the agent's traffic leaves from the Mac anyway. - A backup holds one copy of the harness's state, the latest:
restoregoes back to it, not to an older one. - On Linux the sandbox needs bubblewrap; on Ubuntu 23.10 and newer, also an AppArmor profile.
