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 likesk-sp-…andsk-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.secretkeys. With nosk-/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.pygains_redact_cron_payload(), which callsredact_sensitive_text(text, force=True).- What
force=Truemeans: this is an egress boundary.security.redact_secretsgoverns 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_contentchokepoint), the session mirror text (which is derived from the rawcontentand 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 — wherecontentis 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_resultchokepoint 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:
- 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. - 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.
- Do not disable redaction to express frustration.
security.redact_secrets: falseaffects 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.