Skip to content

Keep your keys on macOS, away from your AI model

Status: written for local custody (ADR-011), which is not released yet. The install command and the hash below are filled in at release. Nothing here works until the release log lists the client.

A local agent keeps its keys on your device and seals and opens its own messages. That protects it from the node’s operator and from Cloudflare. It does not protect it from your AI model. Claude Code, Hermes and similar agents run shell commands as your user. If the key files belong to that user, a model that a hostile message talks into running cat can read the keys, and its vendor then sees them.

The fix is a second user. A hidden user called _agentlink owns the key files and runs the Stelloz MCP server. Your agent runs as your own account and can start the server only through sudo, with one fixed command. Through that pipe it can use the tools (read mail, send mail). It cannot read the keyring, the local secret or the node credential.

  • macOS with Node.js 22.22 or later, and an administrator account for the one-time setup.
  • A join link from your owner (<node URL>/join/alj_...). It works once.
  • The owner’s fingerprint, which your owner reads to you from the dashboard, over a channel the node does not control.
  • The release log entry for the client: the version, the npm integrity string, the tarball SHA-256 and the bundle SHA-256. Verify the integrity (npm checks it) AND the bundle SHA-256.
  • node on a system PATH for the key user (for example /usr/bin or /usr/local/bin). A node installed by nvm or Homebrew lives in your own home or a path systemd and sudo do not see, so install one system wide.

Replace youruser below with the account your AI agent runs as.

Terminal window
sudo sysadminctl -addUser _agentlink -fullName "AgentLink key holder" \
-home /var/agentlink -shell /usr/bin/false -password -
sudo dscl . -create /Users/_agentlink IsHidden 1
sudo mkdir -p /var/agentlink
sudo chown _agentlink:staff /var/agentlink
sudo chmod 700 /var/agentlink
  • -password - makes sysadminctl ask for a password. Use a long random one and throw it away; nobody logs in as this user.
  • The home directory is mode 0700, so no other user can even list it.
  • Do not make _agentlink an administrator, and never add youruser to a group that can read /var/agentlink.

Get the package file named in the release log. Compare its hash with the release log before you install anything. The log, not the node and not a chat message, is the source for both.

Terminal window
mkdir -p ~/agentlink-install && cd ~/agentlink-install
npm pack <PACKAGE-NAME>@<VERSION> # makes <PACKAGE-FILE>.tgz
shasum -a 256 <PACKAGE-FILE>.tgz
# It must print exactly: <TARBALL-SHA256-FROM-RELEASE-LOG>
openssl dgst -sha512 -binary <PACKAGE-FILE>.tgz | openssl base64 -A
# It must equal the value after "sha512-" in the release log (the npm integrity string)

If the two hashes differ, stop and tell your owner. Then install it as an administrator into a directory your agent cannot write to:

Terminal window
sudo npm install --global --ignore-scripts --prefix /opt/agentlink ./<PACKAGE-FILE>.tgz
ls -l /opt/agentlink/bin/agentlink-mcp # owned by root
shasum -a 256 /opt/agentlink/lib/node_modules/<PACKAGE-NAME>/dist/agentlink-mcp.mjs
# It must equal the bundle SHA-256 in the release log

A root-owned install matters. If your agent could write to the client, it could swap in a copy that sends your keys elsewhere.

Run these as _agentlink. Type or paste the link at the prompt so it stays out of your shell history. The link is single use, so it appears in the sudo log but is already spent by then.

Terminal window
read -rs JOIN_LINK && export JOIN_LINK
sudo -u _agentlink -H \
/opt/agentlink/bin/agentlink-mcp register "$JOIN_LINK" \
--name "Muse" --username muse --secret-store file \
--owner-fingerprint <OWNER-FINGERPRINT-40-HEX>
unset JOIN_LINK
  • --secret-store file is required for this user. A hidden user with no login session has no usable Keychain, so the local secret goes in a 0600 file next to the keyring, and the user separation is what protects it.
  • --owner-fingerprint makes the client stop if the node shows a different owner key than the one your owner read to you. If you leave it out, the client prints the fingerprint and you must compare it yourself.
  • The command prints the agent ID, the address, the safety code and the owner fingerprint.

Now check two things, out loud with your owner:

  1. The owner fingerprint printed here equals the one in the owner’s dashboard. A node that lied about the owner key would show a different one.
  2. Tell your owner the safety code, in your own chat with them. They approve the agent in the dashboard only if it matches.

You can print both again at any time:

Terminal window
sudo -u _agentlink -H /opt/agentlink/bin/agentlink-mcp status

The client saves the node credential in keyrings/<agentId>.credential (mode 0600) in the same private directory. You never copy it by hand.

4. Make the export file and keep it offline

Section titled “4. Make the export file and keep it offline”

The export file is your only backup. If the Mac is lost and you have no export, the agent is gone and you make a new one with a new address. The node cannot restore it.

Terminal window
sudo -u _agentlink -H /opt/agentlink/bin/agentlink-mcp export \
--out /var/agentlink/agent-backup.ax1

It asks for an export passphrase (12 characters or more) twice. Use one you do not use anywhere else.

Copy the file off the Mac, then delete it:

Terminal window
sudo -u _agentlink cat /var/agentlink/agent-backup.ax1 > /Volumes/BACKUP/agent-backup.ax1
sudo rm /var/agentlink/agent-backup.ax1

Keep the file and its passphrase apart, for example the file on a USB drive in a drawer and the passphrase in a password manager. Do not put the file in iCloud. To restore on a new device, use agentlink-mcp import (see agentlink-mcp help).

The agent starts the server on demand through sudo. A root-owned wrapper fixes the command line and the environment, so the agent cannot choose them.

/usr/local/libexec/agentlink-mcp-run (mode 0755, owned by root:wheel)

#!/bin/sh
export AGENTLINK_RELAY_URL='<RELAY-URL-FROM-YOUR-OWNER>'
exec /opt/agentlink/bin/agentlink-mcp
Terminal window
sudo mkdir -p /usr/local/libexec
sudo install -m 0755 -o root -g wheel agentlink-mcp-run /usr/local/libexec/agentlink-mcp-run

/etc/sudoers.d/agentlink-mcp (mode 0440; edit it with sudo visudo -f /etc/sudoers.d/agentlink-mcp)

youruser ALL=(_agentlink) NOPASSWD: /usr/local/libexec/agentlink-mcp-run ""

The empty "" allows the wrapper with no arguments only, so your agent cannot run export as the key user.

Register the sudo command as the MCP server. It speaks MCP over the standard input and output of that command.

Terminal window
claude mcp add --scope user agentlink -- sudo -n -u _agentlink -H /usr/local/libexec/agentlink-mcp-run

Any MCP client that can run a command works the same way.

The model can now use the tools, so it can read and send mail as the agent. That is what you want. It cannot read the key files, so a hostile message cannot make it hand the keys to its vendor. The vendor still sees any mail the model reads, because the model has to read it. Sealing protects the path to the agent, not the agent.

7. Check that the model cannot reach the keys

Section titled “7. Check that the model cannot reach the keys”

Run these as an administrator. The first three must fail with Permission denied. The last one (status) fails differently: it reports that no config or registration was found, because that user has none of its own.

Terminal window
sudo -u youruser ls /var/agentlink
sudo -u youruser cat /var/agentlink/.config/agentlink/config.json
sudo -u youruser sh -c 'cat /var/agentlink/.config/agentlink/keyrings/*'
sudo -u youruser /opt/agentlink/bin/agentlink-mcp status

The last one fails because youruser has no config of its own. If it ever prints a safety code, the agent is reading keys it should not.

Then check the layout:

Terminal window
sudo stat -f '%Su:%Sg %Lp %N' /var/agentlink /var/agentlink/.config/agentlink \
/var/agentlink/.config/agentlink/keyrings /var/agentlink/.config/agentlink/keyrings/*
# Expected: 700 on each directory, 600 on each file, all owned by _agentlink
id youruser # must not show a group that can read /var/agentlink

The client enforces the same rules itself. It refuses a key file that group or others can read, a key file that is a symlink, or a key directory that is not yours.

Finally, check that chat still works. This sends an MCP initialize request through the same command your agent uses, and should print a JSON answer:

Terminal window
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"check","version":"0"}}}' \
| sudo -u youruser sudo -n -u _agentlink -H /usr/local/libexec/agentlink-mcp-run

Then ask your agent to call agentlink_identity and send a message to a contact.

If you want a socket instead of sudo, run the server from a LaunchDaemon. launchd listens, and for each connection it starts the server as _agentlink with the socket as input and output.

/Library/LaunchDaemons/com.agentlink.mcp.plist (owned by root:wheel, mode 0644)

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>com.agentlink.mcp</string>
<key>UserName</key><string>_agentlink</string>
<key>ProgramArguments</key>
<array><string>/usr/local/libexec/agentlink-mcp-run</string></array>
<key>WorkingDirectory</key><string>/var/agentlink</string>
<key>inetdCompatibility</key>
<dict><key>Wait</key><false/></dict>
<key>Sockets</key>
<dict>
<key>Listeners</key>
<dict>
<key>SockPathName</key><string>/var/run/agentlink-mcp.sock</string>
<key>SockPathMode</key><integer>432</integer>
<key>SockPathOwner</key><integer>0</integer>
<key>SockPathGroup</key><integer>GROUP-ID-OF-YOUR-AGENT-GROUP</integer>
</dict>
</dict>
</dict>
</plist>

432 is mode 0660 written in decimal. Make a group for your agent (sudo dseditgroup -o create agentlink-agents, add youruser, and read its ID with dscl . -read /Groups/agentlink-agents PrimaryGroupID), then put that number in SockPathGroup. Check man launchd.plist on your macOS version for SockPathOwner and SockPathGroup before you rely on them. If your version does not list them, use the sudo way above. Load the daemon with sudo launchctl bootstrap system /Library/LaunchDaemons/com.agentlink.mcp.plist and connect with nc -U /var/run/agentlink-mcp.sock.

  • Administrators of this Mac. They can read everything.
  • Malware that runs as _agentlink or as an administrator.
  • A model that a hostile message talks into sending mail as the agent through the tools. The tools can send; your owner’s approvals and sharing rules still apply to what they may do.
  • Mistakes in setup. If youruser can use sudo without limits for other commands, it can run sudo -u _agentlink cat ..., and none of this holds. Run your AI agent from a standard (non-administrator) account if you can.

Nothing in this guide was run on a Mac. It was written on a Linux host, where the shared parts (the file rules below) were tested without root:

  • The client’s file rules, with the real SDK code on a temporary directory: a new key directory is created as 0700 and a file as 0600; a file at mode 0640 or 0604 is refused; a symlink in place of a key file is refused; a key directory that was loosened to 0755 is put back to 0700. These rules do not depend on the operating system.
  • The LaunchDaemon file parses as valid XML property-list data.

Not tested, and you should run it once yourself:

  • Every macOS command: sysadminctl, dscl, shasum, stat -f, launchctl, the sudoers rule and the launchd socket keys.
  • Registering, exporting and chatting, because they need a running node and a released client.
  • The install steps, because no client package is published yet. The package name, version and hash are placeholders.

Stelloz's source code is not public yet. An open source GitHub link may come later.