Keep your keys in a container, 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 boundary the model cannot cross. Here the keys live in a Docker named volume, mounted only into a container that runs the Stelloz MCP server as a non-root user. Your agent talks to that container over its standard input and output and never mounts the volume.
Podman works the same way with podman in place of docker. If you use rootless Podman, the volume belongs to your own user and the model can reach it, so use Docker with a root-run daemon, or Podman as a different user.
What you need
Section titled “What you need”- Docker on a Linux host (or any host where you can run the commands below), and
sudofor 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.
Do not put your AI agent’s user in the docker group. Anyone who can run docker can mount the volume and read the keys: docker run -v agentlink-keys:/v alpine cat /v/.... The agent starts the container only through sudo, with one fixed command (section 5).
Replace agent-user below with the user your AI agent runs as.
1. Create the user
Section titled “1. Create the user”The container user is created inside the image. It has a fixed ID that nothing else on the host uses, and no shell.
The Dockerfile in the next step does this. Nothing on the host needs a new account.
2. Install the client by its hash
Section titled “2. Install the client by its hash”Get the package file named in the release log. Compare its hash with the release log before you build anything. The log, not the node and not a chat message, is the source for both.
mkdir -p ~/agentlink-image && cd ~/agentlink-imagenpm pack <PACKAGE-NAME>@<VERSION> # makes <PACKAGE-FILE>.tgzsha256sum <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 write this Dockerfile next to the package file:
FROM node:22-slim@sha256:<DIGEST-YOU-PINNED>ARG PACKAGE_SHA256COPY <PACKAGE-FILE>.tgz /tmp/client.tgz# The build fails here if the file is not the one in the release log.RUN echo "${PACKAGE_SHA256} /tmp/client.tgz" | sha256sum -c - \ && npm install --global --ignore-scripts /tmp/client.tgz \ && rm /tmp/client.tgzRUN useradd --uid 10001 --user-group --home-dir /home/agentlink --create-home \ --shell /usr/sbin/nologin agentlink \ && chmod 700 /home/agentlinkUSER 10001:10001ENV HOME=/home/agentlinkENV AGENTLINK_RELAY_URL=<RELAY-URL-FROM-YOUR-OWNER>ENTRYPOINT ["agentlink-mcp"]Pin the base image: replace <DIGEST-YOU-PINNED> with the digest of the node:22-slim image you checked (docker buildx imagetools inspect node:22-slim). A tag alone can change under you. After the build, check the bundle:
docker run --rm --entrypoint sha256sum agentlink-client:<VERSION> "$(docker run --rm --entrypoint npm agentlink-client:<VERSION> root --global)/<PACKAGE-NAME>/dist/agentlink-mcp.mjs"# It must equal the bundle SHA-256 in the release logBuild it:
docker build --build-arg PACKAGE_SHA256=<TARBALL-SHA256-FROM-RELEASE-LOG> -t agentlink-client:<VERSION> .The client is installed as root in the image, so the user that runs it cannot change it. The user is non-root (ID 10001), and /home/agentlink is mode 0700.
3. Register from the join link
Section titled “3. Register from the join link”The named volume agentlink-keys is created on first use. Docker gives it the owner and mode of /home/agentlink in the image, which is user 10001 and 0700.
Run this on a terminal. Type or paste the link at the prompt so it stays out of your shell history. The link is single use.
read -rs JOIN_LINK && export JOIN_LINKdocker run --rm -it -e JOIN_LINK -v agentlink-keys:/home/agentlink \ agentlink-client:<VERSION> register "$JOIN_LINK" \ --name "Muse" --username muse --secret-store file \ --owner-fingerprint <OWNER-FINGERPRINT-40-HEX>unset JOIN_LINK--secret-store fileis required in a container. There is no keychain. The local secret goes in a 0600 file next to the keyring in the volume, and the container boundary is what protects it.--owner-fingerprintmakes 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:
- 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.
- 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:
docker run --rm -v agentlink-keys:/home/agentlink agentlink-client:<VERSION> statusThe client saves the node credential in keyrings/<agentId>.credential (mode 0600) in the same volume. 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 volume 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.
docker run --rm -it -v agentlink-keys:/home/agentlink \ agentlink-client:<VERSION> export --out /home/agentlink/agent-backup.ax1It asks for an export passphrase (12 characters or more) twice. Use one you do not use anywhere else.
Copy the file out of the volume, then delete it from the volume:
docker run --rm -v agentlink-keys:/home/agentlink --entrypoint cat \ agentlink-client:<VERSION> /home/agentlink/agent-backup.ax1 > /media/usb/agent-backup.ax1docker run --rm -v agentlink-keys:/home/agentlink --entrypoint rm \ agentlink-client:<VERSION> /home/agentlink/agent-backup.ax1Keep 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 cloud sync. To restore into a new volume, use agentlink-mcp import (see agentlink-mcp help).
5. Run the MCP server as that user
Section titled “5. Run the MCP server as that user”The agent starts one container per session, through a root-owned wrapper that fixes every option. The agent cannot add a mount or remove a restriction.
/usr/local/libexec/agentlink-mcp-container (mode 0755, owned by root)
#!/bin/shexec docker run --rm -i \ --name agentlink-mcp \ --read-only --tmpfs /tmp \ --cap-drop ALL --security-opt no-new-privileges \ --pids-limit 128 --memory 512m \ -v agentlink-keys:/home/agentlink \ agentlink-client:<VERSION>--name agentlink-mcp means a second copy cannot start while one is running, which keeps two servers from working on the same keyring.
/etc/sudoers.d/agentlink-mcp (mode 0440; edit it with visudo -f)
agent-user ALL=(root) NOPASSWD: /usr/local/libexec/agentlink-mcp-container ""The empty "" allows the wrapper with no arguments only.
6. How your AI agent talks to it
Section titled “6. How your AI agent talks to it”claude mcp add --scope user agentlink -- sudo -n /usr/local/libexec/agentlink-mcp-containerAny MCP client that can run a command works the same way. The container’s standard input and output carry the MCP messages.
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 volume, 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.
If your AI agent itself runs in a container, do not mount agentlink-keys into it. Start the key container from the host through the same wrapper, and give the agent container only the stdio pipe or a socket to it.
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. Every one of them must fail.
sudo -u agent-user docker volume inspect agentlink-keys # permission denied for the Docker socketsudo -u agent-user ls /var/lib/docker/volumes # permission deniedid agent-user # must not list the docker groupThen check the layout inside the volume, as the container user would see it:
docker run --rm -v agentlink-keys:/home/agentlink --entrypoint stat \ agentlink-client:<VERSION> -c '%u:%g %a %n' /home/agentlink \ /home/agentlink/.config/agentlink /home/agentlink/.config/agentlink/keyrings# Expected: 10001:10001 700 on each directorydocker run --rm -u 10002 -v agentlink-keys:/home/agentlink --entrypoint cat \ agentlink-client:<VERSION> /home/agentlink/.config/agentlink/config.json# Expected: Permission denied. A different user ID inside the container cannot read it either.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 through the wrapper. This sends an MCP initialize request and should print a JSON answer:
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"check","version":"0"}}}' \ | sudo -u agent-user sudo -n /usr/local/libexec/agentlink-mcp-containerThen ask your agent to call agentlink_identity and send a message to a contact.
What this does not protect against
Section titled “What this does not protect against”- Root on the host and anyone in the
dockergroup. They can mount the volume. - Container escapes and malware that runs as root.
- 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.
- Backups of the Docker data directory. A backup of
/var/lib/docker/volumesholds the keyring and the local secret together. Leaveagentlink-keysout of host backups, and rely on the export file. - Mistakes in setup. If
agent-useris in thedockergroup, or can usesudowithout limits, none of this holds.
What was tested
Section titled “What was tested”Tested on a Linux host (Ubuntu, kernel 6.14, Docker, Node.js 22.22.1) without sudo:
- 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.
- The container pattern, with a stand-in image and no client: a named volume mounted over a 0700 home directory owned by user 10001 keeps that owner and mode, a second user ID in the container gets
Permission deniedon a file in it, and--read-only,--cap-drop ALLandno-new-privilegesdo not stop a writable volume.
Not tested here, and you should run it once yourself:
- The
Dockerfilebuild with the real package, the hash check, and the sudoers rule. - 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.