dimaforcepush

PROJECT self-hosted · MIT · Discord and Telegram

Hermes Secret Drop

Give your agent a password without pasting it into the chat.

The agent posts a one-time link. You open it, type the secret in your browser, and the agent gets it without the secret ever becoming a chat message. It works the other way too: the agent can hand you a password through a link instead of writing it out.

Not end-to-end encrypted, not security-audited. What it won't do

staging-deploy a chat with your agent

  1. You

    Deploy staging. You'll need the database password.

  2. Hermes agent

    Private input requested Received

    Open it to send text or files. Expires in 30 minutes.

  3. you open it in your browser

    drop.example.com

    Send privately to Hermes

    masked example value

    Encrypted in this browser before it's sent.

  4. Hermes agent

    Got it, it came through the link. Deploying staging now.

  1. You ask in Discord or Telegram, as usual.
  2. The agent posts a link into the same chat. It has no way to send it anywhere else.
  3. You type the secret in your browser. It's encrypted there, before it leaves.
  4. The agent gets it. The chat only ever shows the link.

Illustration with dummy values. Not a screenshot, not a live demo.

01 · the problem

A pasted secret leaves copies

Ask for a password in chat and the password is now in the chat: in the history, in the agent's logs, in whatever the model keeps. “Just don't log it” doesn't hold up. The platform stores the message, the agent writes tool results to its session store before the model reads them, and the request passes whatever sits in front of the server.

You

here you go: p4ss-EXAMPLE-0000

Where that line ends up

  • Chat history, on the platform, for anyone who can scroll up. Drop keeps it out
  • The agent's session log and search index, written before the model even reads it. Drop keeps it out
  • Whatever sits in front of the server: proxies that end TLS, request-body logs. sealed first, needs HTTPS
  • The model and your model provider. When you give the agent a secret, it is because the agent needs it. still true with Drop

02 · both directions

It works both ways, with different promises

The two directions aren't mirror images. Who ends up seeing the value is different, and that is the most important thing to know about Drop.

you → agent

Give the agent a key or a file

Private text, or up to five files (42 MiB in total). Type /drop, or let the agent ask.

  1. 1

    Private input requested

    A link in the chat you're already in.

  2. 2

    masked example value

    Sealed in your browser, sent once.

  3. 3

    Received

    The same message, edited. No link left in it.

The chat
A link and a status card.
The agent's session log
A placeholder, not the value.
The agent and your model provider
Yes. That's the point: the agent needs it.

agent → you

Get a password from the agent

A login, a password, an API key: labelled fields, each with a Copy button, masked until you ask.

  1. 1

    A secret for you

    A link, and a 3-digit code on its own line: 417

  2. 2

    Enter the code

    A link preview can't spend it. Opening it takes the code.

  3. 3

    passwordmasked example value

    Opened. This link won't work again.

The chat
The link and the code.
The broker, afterwards
Only ciphertext it can't open.
The model
No, if the broker generated the password. Yes, if the agent passed on one it already had.

03 · compare

Paste it, or drop it

The same task done two ways. Drop is the highlighted column. Read the last rows too.

Pasting a secret into the chat compared with handing it over through a Drop link
Paste it in the chatHand it over with Drop
Shows up as a chat messageYes, in the historyNo. The chat shows a link and a status card.
Saved in the agent's session log and search indexYesNo. A placeholder is saved instead.
Readable by proxies and logs in front of the serverThe platform has it anywayNo, it's sealed in your browser first. Only with real HTTPS.
Can be opened againWhenever someone scrolls upNo. One claim or one reveal, then it's destroyed.
How long it lastsUntil someone deletes itMinutes. An inbound drop lasts 30 by default, 1 to 60 configurable. A broker restart drops every pending one.
Reaches the model and your model providerYesYes, when you give it to the agent. No, when the broker generates a password for you.
Protects you from the host, the broker or the modelNoNo. They are all trusted with the plaintext.

04 · limits

What it won't do

The short version of the project's threat model and limitations. Read the full ones before you trust it with anything that matters.

  • It isn't end-to-end encryption. The broker holds the inbound decryption key by design. Your Hermes host, the broker process and the model are all trusted with the plaintext.
  • It hasn't been audited. No formal or third-party security audit. Its HPKE library says the same of itself. There's a written threat model and a lot of automated tests, including RFC 9180 vectors; that isn't an audit.
  • It needs real HTTPS. The page that encrypts arrives over the same connection. Whoever can rewrite that connection can rewrite the page, so authenticated HTTPS is load-bearing.
  • The code isn't a password. The 3-digit code travels in the same chat as the link. It stops link previews and virus scanners from opening a drop first. It doesn't prove who you are.
  • One look, then it's gone. An opened outbound drop can't be opened again, so a page reload costs the secret. Three wrong codes destroy it.
  • Password forms are refused today. A form with a secret field must go to a consumer that runs without the model. None ships, so Drop refuses before it posts a link.
  • “Destroyed” is best-effort. Removed from memory and zero-filled. Nothing is claimed about swap, core dumps or snapshots.
  • Discord and Telegram only. Any other platform is refused by name, not downgraded.

05 · setup

Run it yourself

Both pieces are yours to host: a small Node.js broker behind your own HTTPS reverse proxy, and a Hermes plugin that adds /drop. It runs on released, unmodified Hermes.

Ask your agent

Copy this into the coding agent you already use and let it do the setup. It reads the README, looks at your machine and asks you only what it can't decide itself. You still need a server and a domain with HTTPS; the prompt doesn't conjure those.

Prompt
Set up Hermes Secret Drop for me: its self-hosted broker behind HTTPS, and its Hermes plugin.

Source of truth: https://github.com/dmmeteo/hermes-secret-drop
Read the README first, especially Deploying the broker, Installing the Hermes plugin, Verifying an install, Threat model and Limitations. Follow the documented install path; don't invent your own.

1. Inspect this environment before changing anything: OS, Docker and Compose, Node.js, the reverse proxy and how it does HTTPS, any existing Hermes install and its profiles. Tell me what you found.
2. If something required is missing (a public hostname, DNS, working HTTPS, permission to run Docker or create directories), stop and tell me what's missing and my options.
3. Ask me only for real decisions, like the hostname, the Hermes profile and the socket directory. Never ask me to paste a password, token or key into this chat. If a step needs one, tell me where to put it myself.
4. Ask before anything that exposes a service publicly, opens ports, or deletes or overwrites existing config, containers or data. The plugin needs a gateway restart; ask before restarting it.
5. Verify the way the README does: the preflight check, a test drop from the broker, the plugin listed after the restart. Then I'll type /drop in Discord or Telegram and check the link arrives in that same conversation.
6. Finish with a short summary: what you installed, where, what you changed, what's left for me. Don't call it end-to-end encrypted or audited. The README says it's neither.

Install manually

Old school, and fair enough: the same setup, typed by you, straight from the README.

  1. Try the broker locally

    No Hermes needed. Mint a link, open it, claim the value once.

    npm ci
    npm run verify
    npm start &
    node bin/handoff-admin.mjs create
    Quick start
  2. Deploy it behind HTTPS

    Set your host name, create the socket directory, build. The supplied compose.yml targets Traefik.

    cp .env.example .env
    docker compose build
    docker compose up -d
    Deploying the broker
  3. Add the plugin to Hermes

    Install into a named profile, point it at the broker's socket, restart the gateway. Then type /drop.

    HERMES_HOME="$HOME/.hermes" \
      bin/install-hermes-drop.sh install
    Installing the plugin

There's also a standalone Claude Code command with a narrower boundary: text in, generated credentials out.

06 · all of it

How it works, in full

The promises, the lifecycles and where the secret sits at each point. Open what you need.

Why “just don't log it” isn't enough

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 Secret Drop takes 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 Secret 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.

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. A model that cannot express a destination cannot pick the wrong one.

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.

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.

PlaceYou → agentAgent → 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 and 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. The capability goes in a header and is kept only as a hash.No plaintext, no key. The code is kept as an HMAC, never logged.
Hermes gateway memoryAt most 15 minutes. 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.
Session store, search index, session logA placeholder ([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.
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.

Today no such consumer ships. A form with a secret field is refused before a link is posted. 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 a different thing. It works today, and what you type there reaches the model, the same as every inbound drop.

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. In the threat model’s own words: authenticated HTTPS is load-bearing; it authenticates the JavaScript that performs the encryption.

There is also a plainer reason: browsers only expose crypto.subtle in a secure context, so on a public host over plain HTTP the page has nothing 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.

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: