dimaforcepush

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.
01

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.

02

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.

TWO DIRECTIONS · SCHEMATIC The same link-in-the-chat shape, two different trust stories.
INBOUND

You → the agent

KEY A per-drop P-256 private key, minted and held by the broker

  1. 01 · YOUR BROWSERSeals it

    HPKE to the drop's public key, with crypto.subtle, before it leaves the page.

    plaintext → sealed
  2. 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
  3. 03 · HERMES GATEWAYClaims once

    Over a local 0600 socket. The session store gets a placeholder; memory keeps the value ≤ 15 min.

    placeholder on disk
  4. 04 · MODEL + PROVIDERReads it

    As a tool result, on the wire to your model provider. The model is trusted with it.

    plaintext
OUTBOUND

The agent → you

KEY A one-use AES-256-GCM key, handed back in the link's #fragment and dropped

  1. 01 · MODELAsks to send

    A relayed value is already in its tool call and transcript. A generated one never is.

    relayed: plaintext · generate: none
  2. 02 · BROKEREncrypts, forgets the key

    Plaintext passes through to be encrypted; afterwards it holds ciphertext it cannot open.

    ciphertext only
  3. 03 · THE CHATCarries the link

    Key in the #fragment, a 3-digit code on its own line. No value in the message.

    link + code, no value
  4. 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.

03

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.

04

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.

LIFECYCLES · NO EDGE BACK Every drop only moves forward.

INBOUND

  1. pending
  2. sealed envelope openssubmitted
  3. one claimclaimedreceipt only, no payload

destroyed after 3 failed decryptions, at expiry, or on a broker restart

OUTBOUND

  1. availablea GET consumes nothing
  2. correct codereserved
  3. browser acknowledgesdestroyed

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.

05

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.

WHERE THE VALUE IS · PER THE PROJECT'S OWN THREAT MODEL Not in the chat is one row of this table.
PlaceInbound · you → agentOutbound · agent → you
Chat messageLink, 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 browserPlaintext while you type; sealed before it is sent.Plaintext after you reveal it, decrypted on the page.
Broker memoryPlaintext 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 logsNo 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 logPlaceholder [hermes-drop:secret:…], written instead of the value.Relayed: plaintext, in the tool call Hermes stores. generate: never.
Model context, provider requestPlaintext. That is the point of an inbound drop.Relayed: plaintext, where it already was. generate: never.
Files on diskSpooled 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.

06

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.

A FORM AS DATA · INVENTED EXAMPLE, NOT A REAL REQUEST Five fields in, one form out — and the one field that stops the press.

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" }

  1. DERIVED mode: "consumer" — a secret field forces the no-model consumer lane
  2. LOOKUP the consumer registry ships empty
  3. RESULT refused at mint, secret_consumer_unavailable — no link is posted, nobody is asked
07

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.

THE HTTPS BOUNDARY · SCHEMATIC Encryption in the page is only as good as the page.

A Authenticated HTTPS

  1. BROKER serves the page
  2. TLS the browser checks the certificate for drop.example.test: this is the broker's code
  3. PAGE seals your input to the drop's own key
  4. BROKER receives an envelope only it can open

B No authenticated HTTPS, an active attacker on the path

  1. BROKER serves the page
  2. ATTACKER swaps the JavaScript in transit
  3. PAGE looks the same, reads what you type before encryption — or seals it to the attacker's key
  4. BROKER may still get an envelope; the secret has already left
SECURE CONTEXT
Browsers expose crypto.subtle only on secure origins. Over plain HTTP to a public host there is nothing to encrypt with.
LOOPBACK
Browsers also count 127.0.0.1 and localhost as 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.
08

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: