PROJECT
Hermes Drop
Ask an agent for a secret without putting the secret in the chat.
- WHAT IT IS
- A self-hosted broker plus a Hermes plugin, both directions. MIT licensed.
- WHAT IT IS NOT
- Not end-to-end encryption — the broker holds the inbound decryption key by design.
- AUDIT
- No formal or third-party security audit, and neither has its HPKE library.
- NEEDS
- Authenticated HTTPS in front of the broker. Without it, the page can't be trusted to encrypt.
The problem
I wanted a Hermes agent to be able to receive a credential or a file without either one ever landing in the conversation. Asking for a secret in chat means the secret is now in the chat — in the transcript, in the logs, in whatever the model keeps.
The obvious fix, “just don’t log it”, doesn’t hold up. A chat message is sent to the platform, a tool result is written to the session store before the model sees it, and a request body passes through whatever sits in front of the server. So Hermes Drop moves the secret out of the chat entirely: the agent posts a short-lived link, and the value goes through a web form instead.
Two directions, two different promises
Hermes Drop is two self-hosted pieces: a small Node.js broker behind your
own HTTPS reverse proxy, and a Hermes plugin with a stock /drop skill
command. It works both ways, but the two directions are not mirror images,
and the difference is the most important thing to understand about it.
Inbound, you give the agent something. The broker makes a fresh key pair for that one drop, your browser seals what you type or attach to its public key, and the broker — which holds the private key — opens it and hands it to exactly one claim. The agent gets the plaintext, so the model and your model provider see it.
Outbound, the agent gives you something. The broker encrypts it with a
one-use key, keeps only the ciphertext, and puts the key in the link’s
#fragment. Your browser decrypts it after you type a 3-digit code. The
broker can’t read it afterwards, but the plaintext did pass through it to
be encrypted. If the agent is relaying a value it already had, that value
is in its own tool call and transcript anyway. If it asks the broker to
generate one, the value never enters the model at all.
You → the agent
KEY A per-drop P-256 private key, minted and held by the broker
- 01 · YOUR BROWSERSeals it
HPKE to the drop's public key, with
plaintext → sealedcrypto.subtle, before it leaves the page. - 02 · BROKEROpens it
It holds the key, so it can. Plaintext waits in memory for one claim; the key pair is dropped.
plaintext, in memory - 03 · HERMES GATEWAYClaims once
Over a local
placeholder on disk0600socket. The session store gets a placeholder; memory keeps the value ≤ 15 min. - 04 · MODEL + PROVIDERReads it
As a tool result, on the wire to your model provider. The model is trusted with it.
plaintext
The agent → you
KEY A one-use AES-256-GCM key, handed back in the link's #fragment and dropped
- 01 · MODELAsks to send
A relayed value is already in its tool call and transcript. A
relayed: plaintext · generate: nonegenerated one never is. - 02 · BROKEREncrypts, forgets the key
Plaintext passes through to be encrypted; afterwards it holds ciphertext it cannot open.
ciphertext only - 03 · THE CHATCarries the link
Key in the
link + code, no value#fragment, a 3-digit code on its own line. No value in the message. - 04 · YOUR BROWSERReveals once
Type the code, decrypt locally, acknowledge. Then the broker destroys it.
plaintext, on your screen
Neither direction is end-to-end encryption: the broker host, the Hermes host and the model are trusted parties in both.
Same conversation, or nothing
The part I keep coming back to is that the link has no destination field. Not in the command, not in the tool schema — no platform, channel or thread at any depth.
The link goes back into the conversation the request came from, and the
plugin checks that against the gateway’s own session context before
posting. If it can’t verify it, it refuses (origin_mismatch,
origin_unverified, no_origin). It never falls back to a home channel.
Only Discord and Telegram are supported, and any other platform is refused
by name rather than downgraded. That turned out to be a much better
boundary than validating a destination the model supplied.
Once, then gone
Every drop is one-shot, and its states only move forward. A second claim gets the same generic “unavailable” as a wrong link, so from the outside a spent drop and a wrong guess look the same.
INBOUND
pending- sealed envelope opens
submitted - one claim
claimedreceipt only, no payload
destroyed after 3 failed decryptions, at expiry, or on a broker restart
OUTBOUND
availableaGETconsumes nothing- correct code
reserved - browser acknowledges
destroyed
destroyed after 3 wrong codes, a 60 s acknowledgement window, expiry or shutdown
The link carries a 128-bit capability in its #fragment, which browsers
never send to the server, so it never shows up in a request line, an
access log or a Referer. The broker keeps only a hash of it. Inbound
drops live for 30 minutes by default (1 to 60 can be configured), and
three failed decryptions destroy one. On the way out, three wrong codes
destroy the payload. Losing a delivery is the deliberate price for not
allowing online guessing. The code is a human-presence and anti-preview
gate, not authentication: it travels in the same chat as the link. What
it does is stop a link unfurler or a virus scanner from opening the drop
first. A GET never consumes it.
One trade-off is easy to miss: an opened outbound drop is gone. A page reload costs the secret.
Where the secret is, and isn't
“Not in the chat” is only half an answer, so here is the whole map. The broker keeps everything in memory — no database, no Redis, no analytics, no CDN, no third-party script — and a broker restart destroys every pending drop, because the per-drop keys were never written anywhere. “Destroyed” means removed from an in-memory map and zero-filled on a best-effort basis. Nothing is claimed about swap, core dumps or snapshots.
On the Hermes side, a claimed secret is swapped for a placeholder before the tool result is written to the session store, the search index or the session log. The plaintext is held in gateway memory for at most 15 minutes and put back only into the copy of the request sent to the model provider.
| Place | Inbound · you → agent | Outbound · agent → you |
|---|---|---|
| Chat message | Link, no value. Its #fragment holds the capability: enough to submit once, not to read. Received and expired states carry no link. | Link + code, no value. Its #fragment holds the capability and the AES key; with the code, that opens it once. Keep the chat as private as the value. |
| Your browser | Plaintext while you type; sealed before it is sent. | Plaintext after you reveal it, decrypted on the page. |
| Broker memory | Plaintext from opening until the one claim or expiry. It holds the key. | Ciphertext it cannot open; the plaintext passed through it to be encrypted. |
| Server side: broker disk, request URLs, access logs | No plaintext written. No payload store. Browsers never send the #fragment; the capability goes in a header and is kept only as a hash. | No plaintext, no key. The #fragment never reaches a request or log; the code is kept as an HMAC, never logged. |
| Hermes gateway memory | ≤ 15 min. At most 4 per session, 32 per gateway, then the model must ask again. | Relayed: plaintext, in the tool call on its way to the broker. generate: never — the broker draws it; Hermes only posts the link. |
| Session store, search index, session log | Placeholder [hermes-drop:secret:…], written instead of the value. | Relayed: plaintext, in the tool call Hermes stores. generate: never. |
| Model context, provider request | Plaintext. That is the point of an inbound drop. | Relayed: plaintext, where it already was. generate: never. |
| Files on disk | Spooled as 0600 files under a 0700 folder; the model gets paths, not bytes. | — No outbound files. |
Restart the broker and every pending drop is gone. “Destroyed” is a best-effort zero-fill: nothing is claimed about swap, core dumps or snapshots.
Forms as data, not presets
A plain /drop gives the universal form: the person chooses private text
or up to five files (42 MiB in total). A request can also describe its
own form as a short, closed contract: an ordered list of fields, each with
an id, a label and one of five types — text, email, textarea,
secret, files. Drop renders it, validates it on both sides, binds it
into the encryption, and returns the values keyed by id. Nothing in it is
silently repaired; a contract that breaks a rule is refused with a code.
The rule that matters most is that delivery is derived, never chosen.
A form with no secret field goes to the model, like every inbound drop.
A form with a secret field is forced into a different lane: the whole
submission goes to an authorized consumer that runs without the model, and
the model gets a receipt with no values in it.
The plugin’s consumer registry is empty on purpose, so a password form
fails at mint with secret_consumer_unavailable. Drop doesn’t ask anyone
to type a password it could not receive privately. The test suite has a
canary consumer, but it is never registered, and it is not vault, BWS or
.env integration. The private text lane of the universal
link is something else. It works today, and what you type there reaches
the model, the same as every inbound drop.
THE CONTRACT · VALID
{
"version": 1,
"title": "Release check-in",
"description": "The tag you shipped, and the two log bundles from it.",
"fields": [
{ "id": "release_tag", "type": "text", "label": "Release tag", "required": true },
{ "id": "contact", "type": "email", "label": "Who to ping" },
{ "id": "notes", "type": "textarea", "label": "What looked odd" },
{ "id": "app_logs", "type": "files", "label": "App logs", "min_files": 1, "max_files": 2 },
{ "id": "proxy_logs", "type": "files", "label": "Proxy logs", "max_files": 2 }
]
}DERIVED delivery: { mode: "model" } — no secret field, so the values go to the model, keyed by id.
ADD ONE FIELD
{ "id": "db_password", "type": "secret", "label": "Database password" }
- DERIVED
mode: "consumer"— asecretfield forces the no-model consumer lane - LOOKUP the consumer registry ships empty
- RESULT refused at mint,
secret_consumer_unavailable— no link is posted, nobody is asked
HTTPS is the part you can't skip
Encrypting in the browser only means something if the browser runs the page the broker meant to send. The JavaScript that does the encryption arrives over the same connection as everything else. If an active attacker can rewrite that connection, they can rewrite the code. The swapped page looks the same, but it reads your input before encryption, or seals it to a key the attacker holds. No cipher in that page can help, because whoever wrote the page decides what it encrypts.
There is also a plainer reason: browsers only expose crypto.subtle in a
secure context, so on a plain-HTTP public host the page has no crypto.subtle
to encrypt with. Browsers also treat loopback addresses as secure, which
is why the local quick start works on 127.0.0.1. That is a developer
convenience, not a deployment mode. Drop’s own docs say plain HTTP gives no
protection against an active network attacker, and they rule reports
against deployments without HTTPS out of scope.
A Authenticated HTTPS
- BROKER serves the page
- TLS the browser checks the certificate for
drop.example.test: this is the broker's code - PAGE seals your input to the drop's own key
- BROKER receives an envelope only it can open
B No authenticated HTTPS, an active attacker on the path
- BROKER serves the page
- ATTACKER swaps the JavaScript in transit
- PAGE looks the same, reads what you type before encryption — or seals it to the attacker's key
- BROKER may still get an envelope; the secret has already left
- SECURE CONTEXT
- Browsers expose
crypto.subtleonly on secure origins. Over plain HTTP to a public host there is nothing to encrypt with. - LOOPBACK
- Browsers also count
127.0.0.1andlocalhostas secure. That is why the local quick start runs; it is not a way to deploy. - OUT OF SCOPE
- The project treats findings against a deployment without HTTPS as out of scope, because the page doing the encryption can’t be trusted there.
What it does not do
It is not end-to-end encrypted. The broker holds the inbound
decryption key by design, and the Hermes host, the broker process and the
model are all trusted with the plaintext. Browser-side encryption protects
against hops that terminate TLS or log request bodies in front of the
broker, not against any of those trusted parties. The claimed value enters
the model’s context and goes over the wire to your model provider; if the
model echoes it, that echo is stored like any other output. Anything that
reads the request after the swap sees it too: an observability hook, NeMo
Relay, HERMES_DUMP_REQUESTS=1. None of them should be on for a gateway
that handles drops.
It has had no formal or third-party security audit. It was built against a written threat model and has substantial automated test coverage, including RFC 9180 test vectors, but that is not the same thing, and the underlying HPKE library says the same of itself. The consumer boundary is not protection against a malicious plugin on the Hermes host, and exactly-once delivery is not claimed for a future consumer either.
Read these before trusting it with anything that matters:
- Threat model and Limitations, in the README.
- Security policy and scope, including both lifecycles and what deletion is worth.
- The declarative form engine, and why the consumer lane ships closed.