The API Key You Printed Once Should Not End Up in a Chat Log


It is ten at night and you are debugging a 401. You cat .env in the terminal to see whether the key was set correctly, and Hermes reads that output into the session. Twenty minutes later the scheduled job you set up earlier posts “today’s config check” into your team’s Telegram group — with your full API key sitting in the middle of the message. The only thing you did was look at your own config file.

That is the real shape of credential leakage: nobody breaks in and steals anything. The same text just travels from tool output to session history to an outbound message, and at one of those stops nobody is responsible for masking it. Two fixes merged on 18 September 2026 patch a stop each: PR #114839 repairs the masking rules for tool output, PR #114789 repairs the redaction policy on cron delivery lanes. Here is what both do — and, just as importantly, what they still do not protect.

Stop one: the masking rules missed two real credential shapes

Hermes has a redactor (agent/redact.py) that terminal output, file reads, and everything downstream of them — session storage, session_search — pass through. It recognises vendors by a prefix table: ghp_, xoxb-, AKIA, hf_, pplx- and so on. It is on by default, governed by security.redact_secrets (turning it off logs a startup warning).

The gap was two shapes that never made it into the table:

  • Dotted sk- keys from Alibaba’s stack. Keys like sk-sp-… and sk-ws-… carry dots in the body, and the old character class did not include ., so the match stopped at the first dot: the head was masked and the tail stayed in cleartext. A key that looks redacted while half of it survives is worse than one that was clearly never touched.
  • Zhipu’s prefix-less id.secret keys. With no sk-/ghp_ style prefix, the prefix table could never match them, and the whole credential travelled verbatim.

The fix does two things. The sk- entry now accepts a dot-delimited body, so sk-sp-… and sk-ws-… match completely, and a sentence dot after the key is never consumed. For Zhipu, a deliberately provider-shaped regex was added: a 32-character lowercase-hex id, a dot, then a credential segment of at least 16 alphanumerics. The reason for not reaching for a generic “high-entropy dotted token” rule is false positives: content-hash filenames like <sha>.bundle or <md5>.sqlite3 must not match. The tests carry negative controls for exactly that — a bare 40-hex sha, a short sk- fragment, and a 31-character truncated id all have to come out byte-identical.

Measured against a real probe:

Input Before After
api_key: sk-sp-...7890.abcdefgh1234567890_XYZ-0987654321 tail leaked in cleartext sk-sp-...4321
api_key: 50aaedFAKE1234567890abcdef1234567890abc.ZpSh99 verbatim 50aaed...Sh99
the same file through file_read «redacted:sk-…».abcdefg… tail survived «redacted:sk-…» / «redacted-secret»

That third row deserves its own paragraph: the “file content” path does not mask, it replaces with a sentinel. There is a real incident behind that (issue #35519). An agent read a key that had been masked into a shape that looked like a genuine but truncated credential, wrote it back into config.yaml, and permanently destroyed the stored credential into a dead 13-character value — 401s from then on. The sentinel («redacted-secret») is syntactically impossible as a usable token, so it can never be mistaken for one or written back. Performance held up too: pathological inputs — a 20,000-character body, 10,000 dots, 2,000 hex runs — each redact in under 3 milliseconds.

Stop two: every cron delivery lane now redacts, by force

Tool output was one path. The easier one to forget is that cron job output leaves the machine by design. In the previous implementation, a script job’s stdout and stderr were scrubbed where they were captured, but an LLM job’s response text reached _deliver_result unscanned — and every lane below it sent the raw string.

PR #114789 puts redaction at the single delivery chokepoint and adds a fail-closed backstop:

  • cron/scheduler_delivery.py gains _redact_cron_payload(), which calls redact_sensitive_text(text, force=True).
  • What force=True means: this is an egress boundary. security.redact_secrets governs the logs on your own machine; this governs what leaves it. So the user setting cannot turn it off here.
  • Fail closed: if the redactor itself raises, the whole payload is replaced with [REDACTED - redaction failed] rather than passed through. That matches the posture already used where shell stdout is captured.
  • It covers four lanes: the platform message (the cleaned_delivery_content chokepoint), the session mirror text (which is derived from the raw content and so did not inherit the chokepoint), the job name, which reaches mirror prefixes and thread titles, and the bot-chat turn handed to another profile — where content is rebound so the durable deferred record and its replay also carry the scrubbed copy.
  • The failure-notice lane needed no separate change: it exits through the same _deliver_result chokepoint and is covered by construction.

One deliberate exception is worth knowing: redact_url_credentials is not enabled for cron. These jobs legitimately deliver magic links and pre-signed URLs to your own chat, and stripping credential-bearing URLs wholesale would break the feature. That is the same judgement agent/redact.py already documents for chat-egress boundaries.

What this still does not cover

Both fixes are real, but they address Hermes’ own output. Three things stay your responsibility:

  1. Do not paste credentials into the conversation. Text you paste yourself also lands in session history and the database. The redactor does its best with known shapes, but “known shapes” is always an incomplete list, and a third-party key may have no recognisable form at all. To check a config value, use hermes config get <key> and confirm presence, length or last four characters — not the value itself.
  2. Do not echo sensitive files from cron scripts. The delivery lane redacts, but the script’s own logs, your CI output and your shell history can still hold the original. Print the verdict (“KEY present” / “KEY missing”), never the value.
  3. Do not disable redaction to express frustration. security.redact_secrets: false affects the local-log path only and logs a startup warning; it cannot reach the cron lane, which is forced on. If you are debugging the redactor itself, turn it back on afterwards.

For the bigger picture on storing and rotating credentials, see the keychain encryption guide. If your notification lane is a chat platform, the unattended-approvals guide covers the other half of that control, and for the jobs themselves start with the cron troubleshooting guide and the hermes secrets reference.

Release status

Both PRs merged into main on 18 September 2026, and the newest tag is v0.21.3 (tag name v2026.9.14, released 14 September), so neither is in a tagged release yet. A git install picks them up on the next update:

hermes --version
hermes update

Docker image and cloud users get them with the following tag.

The pattern worth keeping

Seen together, the rule is simple: redaction is a list that has to stay current, it belongs at the single egress point, and it needs a fail-closed backstop. Adding a name to the prefix table is not enough if the body can contain dots; scrubbing a few lanes is not enough when the session mirror and the job name carry the same text out; and a redactor that falls back to shipping the original text on error protects nothing at all. Those three seams are what got fixed.