security: redact credentials from API responses and fix credential file permissions - #243
Conversation
Prevent secrets (GitHub PATs, API keys, private keys) from being
returned in plaintext through the WebUI API endpoints.
**What was found:**
During a security audit, GET /api/memory and GET /api/session were
found to return session messages, tool_call arguments, and stored
agent memory verbatim — including GitHub PATs and other credentials
that the agent handled during its operation. These endpoints require
no authentication when no password is set (the default), making any
process on the same machine able to extract credentials by querying
localhost:8787.
**Changes:**
api/helpers.py — add redact_session_data() and supporting utilities
- _build_redact_fn(): imports redact_sensitive_text from
hermes-agent's agent.redact module; falls back to a built-in
regex covering the most common credential prefixes (ghp_, sk-,
AKIA, hf_, SG., xox*, private keys, Authorization headers,
KEY=value env assignments)
- _redact_value(): recursively applies redaction to str/dict/list
- redact_session_data(): redacts messages[], tool_calls[], title
api/routes.py — apply redaction at response time (storage unchanged)
- GET /api/session: wrap session payload with redact_session_data()
- GET /api/session/export: redact before download
- GET /api/memory: apply _redact_text() to MEMORY.md and USER.md
api/streaming.py — redact session payload in the SSE done event
- The done event carries the full session including all messages;
now wrapped with redact_session_data() before sending
api/startup.py — add fix_credential_permissions()
- At startup, chmod 600 any HERMES_HOME credential file that has
group or world read bits: .env, google_token.json,
google_client_secret.json, auth.json, .signing_key
server.py
- Call fix_credential_permissions() early in main()
- Add a localhost-mode tip when no password is set, pointing users
to HERMES_WEBUI_PASSWORD
tests/test_security_redaction.py — 13 new tests
- 8 unit tests (always run): helpers/_redact_value,
redact_session_data for messages/tool_calls/titles/multi-type,
fix_credential_permissions permission enforcement
- 5 integration tests (skipped unless test server is up):
end-to-end API coverage of /api/session, /api/session/export,
/api/sessions, and /api/memory
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Thanks for this PR, @kcclaw001 — credential leakage through unauthenticated API endpoints is a real risk and this addresses it systematically. What the fix does well
Questions and concerns1. Regex coverage completeness — the PR lists 2. False positive risk — 3. 4. 5. Startup tip placement — the localhost-mode tip pointing to The security rationale is clear and the implementation is well-structured. The questions above are mostly about scope and edge cases rather than blocking issues. Happy to look at the diff more closely if you can share which patterns the regex fallback actually covers. |
Full Review: PR #243 — redact credentials from API responsesThanks @kcclaw001! Systematic credential protection at the response layer — this is the right approach. Security AuditThe redaction is response-layer only — session files on disk are untouched. This is correct: you want the agent to have access to credentials it handled, but the API shouldn't leak them to the browser. Regex patterns are comprehensive:
Masking is correct: Coverage is complete:
Startup tip for localhost — the Code ReviewThe layered approach (try hermes-agent
One minor issue: Tests562 passed, 0 failed, 47 skipped. Comprehensive test suite (301 lines) covering API key masking, GitHub PATs, AWS keys, Slack tokens, private keys, Authorization headers, env assignments, session endpoint redaction, memory redaction, export redaction, and SSE done event redaction. VerdictApproved. Solid security improvement, comprehensive tests, correct architecture. The |
…le permissions (#243) Adds response-layer credential redaction to three endpoints: - GET /api/session — messages[], tool_calls[], and title - GET /api/session/export — download also redacted - SSE done event — session payload in stream - GET /api/memory — MEMORY.md and USER.md content Adds api/startup.py with fix_credential_permissions() at server startup. Adds 13 tests in tests/test_security_redaction.py. Merged with #237 container detection changes in server.py.
* fix: decode HTML entities before markdown processing + zh/zh-Hant translations (#239) Adds decode() helper in renderMd() to fix double-escaping of HTML entities from LLM output (e.g. <code> becoming &lt;code&gt; instead of rendering). XSS-safe: decode runs before esc(), only 5 entity patterns. Also adds 40+ missing zh (Simplified Chinese) translation keys and a new zh-Hant (Traditional Chinese) locale with 163 keys. Fix applied: removed duplicate settings_label_notifications key in both zh and zh-Hant locales. Fixes #240 * fix: restore custom model list discovery with config api key (#238) get_available_models() now reads api_key from config.yaml before env vars: 1. model.api_key 2. providers.<active>.api_key / providers.custom.api_key 3. env var fallbacks (HERMES_API_KEY, OPENAI_API_KEY, etc.) Also adds OpenAI/Python User-Agent header and a regression test covering authenticated /v1/models discovery. Fixes users with LM Studio / Ollama custom endpoints configured in config.yaml whose model picker silently collapsed to the default model. * feat: Docker UID/GID matching to avoid root-owned .hermes files (#237) Adds docker_init.bash with hermeswebuitoo/hermeswebui user pattern so container files match the host user UID/GID. Prevents .hermes volume mounts from being owned by root when using a non-root host user. Configure via WANTED_UID and WANTED_GID env vars (default 1000/1000). Readme updated with setup instructions. Fix applied: removed duplicate WANTED_GID=1000 line in docker-compose.yml that was overriding the ${GID:-1000} variable expansion. * security: redact credentials from API responses and fix credential file permissions (#243) Adds response-layer credential redaction to three endpoints: - GET /api/session — messages[], tool_calls[], and title - GET /api/session/export — download also redacted - SSE done event — session payload in stream - GET /api/memory — MEMORY.md and USER.md content Adds api/startup.py with fix_credential_permissions() at server startup. Adds 13 tests in tests/test_security_redaction.py. Merged with #237 container detection changes in server.py. * fix: cancel button now interrupts agent and cleans up UI state (#244) Wires agent.interrupt() into cancel_stream() so the backend actually stops tool execution when the user clicks Cancel, rather than only stopping the SSE stream while the agent keeps running. Changes: - api/config.py: adds AGENT_INSTANCES dict (stream_id -> AIAgent) - api/streaming.py: stores agent in AGENT_INSTANCES after creation, checks CANCEL_FLAGS immediately after store (race condition fix), calls agent.interrupt() in cancel_stream(), cleans up in finally block - static/boot.js: removes stale setStatus(cancelling) call - static/messages.js: setBusy(false)/setStatus('') unconditionally on cancel Race condition fix: after storing agent in AGENT_INSTANCES, immediately checks if CANCEL_FLAGS[stream_id] is already set (cancel arrived during agent init) and interrupts before starting. Check is inside the same STREAMS_LOCK acquisition, making it atomic. New test file: tests/test_cancel_interrupt.py with 6 unit tests. * docs: v0.46.0 release notes, bump version, update test counts --------- Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
|
Agent review — APPROVED ✅ (merged to v0.46.0) Reviewed full diff, PR comments, and discussion thread. What this adds: Response-layer credential redaction across all API endpoints. Session files on disk untouched — only the API output is masked. Coverage verified:
Fixes applied:
Tests: 13 tests in Merged to master via stage branch in PR #249 (v0.46.0). |
* fix: decode HTML entities before markdown processing + zh/zh-Hant translations (nesquena#239) Adds decode() helper in renderMd() to fix double-escaping of HTML entities from LLM output (e.g. <code> becoming &lt;code&gt; instead of rendering). XSS-safe: decode runs before esc(), only 5 entity patterns. Also adds 40+ missing zh (Simplified Chinese) translation keys and a new zh-Hant (Traditional Chinese) locale with 163 keys. Fix applied: removed duplicate settings_label_notifications key in both zh and zh-Hant locales. Fixes nesquena#240 * fix: restore custom model list discovery with config api key (nesquena#238) get_available_models() now reads api_key from config.yaml before env vars: 1. model.api_key 2. providers.<active>.api_key / providers.custom.api_key 3. env var fallbacks (HERMES_API_KEY, OPENAI_API_KEY, etc.) Also adds OpenAI/Python User-Agent header and a regression test covering authenticated /v1/models discovery. Fixes users with LM Studio / Ollama custom endpoints configured in config.yaml whose model picker silently collapsed to the default model. * feat: Docker UID/GID matching to avoid root-owned .hermes files (nesquena#237) Adds docker_init.bash with hermeswebuitoo/hermeswebui user pattern so container files match the host user UID/GID. Prevents .hermes volume mounts from being owned by root when using a non-root host user. Configure via WANTED_UID and WANTED_GID env vars (default 1000/1000). Readme updated with setup instructions. Fix applied: removed duplicate WANTED_GID=1000 line in docker-compose.yml that was overriding the ${GID:-1000} variable expansion. * security: redact credentials from API responses and fix credential file permissions (nesquena#243) Adds response-layer credential redaction to three endpoints: - GET /api/session — messages[], tool_calls[], and title - GET /api/session/export — download also redacted - SSE done event — session payload in stream - GET /api/memory — MEMORY.md and USER.md content Adds api/startup.py with fix_credential_permissions() at server startup. Adds 13 tests in tests/test_security_redaction.py. Merged with nesquena#237 container detection changes in server.py. * fix: cancel button now interrupts agent and cleans up UI state (nesquena#244) Wires agent.interrupt() into cancel_stream() so the backend actually stops tool execution when the user clicks Cancel, rather than only stopping the SSE stream while the agent keeps running. Changes: - api/config.py: adds AGENT_INSTANCES dict (stream_id -> AIAgent) - api/streaming.py: stores agent in AGENT_INSTANCES after creation, checks CANCEL_FLAGS immediately after store (race condition fix), calls agent.interrupt() in cancel_stream(), cleans up in finally block - static/boot.js: removes stale setStatus(cancelling) call - static/messages.js: setBusy(false)/setStatus('') unconditionally on cancel Race condition fix: after storing agent in AGENT_INSTANCES, immediately checks if CANCEL_FLAGS[stream_id] is already set (cancel arrived during agent init) and interrupts before starting. Check is inside the same STREAMS_LOCK acquisition, making it atomic. New test file: tests/test_cancel_interrupt.py with 6 unit tests. * docs: v0.46.0 release notes, bump version, update test counts --------- Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
* fix: decode HTML entities before markdown processing + zh/zh-Hant translations (nesquena#239) Adds decode() helper in renderMd() to fix double-escaping of HTML entities from LLM output (e.g. <code> becoming &lt;code&gt; instead of rendering). XSS-safe: decode runs before esc(), only 5 entity patterns. Also adds 40+ missing zh (Simplified Chinese) translation keys and a new zh-Hant (Traditional Chinese) locale with 163 keys. Fix applied: removed duplicate settings_label_notifications key in both zh and zh-Hant locales. Fixes nesquena#240 * fix: restore custom model list discovery with config api key (nesquena#238) get_available_models() now reads api_key from config.yaml before env vars: 1. model.api_key 2. providers.<active>.api_key / providers.custom.api_key 3. env var fallbacks (HERMES_API_KEY, OPENAI_API_KEY, etc.) Also adds OpenAI/Python User-Agent header and a regression test covering authenticated /v1/models discovery. Fixes users with LM Studio / Ollama custom endpoints configured in config.yaml whose model picker silently collapsed to the default model. * feat: Docker UID/GID matching to avoid root-owned .hermes files (nesquena#237) Adds docker_init.bash with hermeswebuitoo/hermeswebui user pattern so container files match the host user UID/GID. Prevents .hermes volume mounts from being owned by root when using a non-root host user. Configure via WANTED_UID and WANTED_GID env vars (default 1000/1000). Readme updated with setup instructions. Fix applied: removed duplicate WANTED_GID=1000 line in docker-compose.yml that was overriding the ${GID:-1000} variable expansion. * security: redact credentials from API responses and fix credential file permissions (nesquena#243) Adds response-layer credential redaction to three endpoints: - GET /api/session — messages[], tool_calls[], and title - GET /api/session/export — download also redacted - SSE done event — session payload in stream - GET /api/memory — MEMORY.md and USER.md content Adds api/startup.py with fix_credential_permissions() at server startup. Adds 13 tests in tests/test_security_redaction.py. Merged with nesquena#237 container detection changes in server.py. * fix: cancel button now interrupts agent and cleans up UI state (nesquena#244) Wires agent.interrupt() into cancel_stream() so the backend actually stops tool execution when the user clicks Cancel, rather than only stopping the SSE stream while the agent keeps running. Changes: - api/config.py: adds AGENT_INSTANCES dict (stream_id -> AIAgent) - api/streaming.py: stores agent in AGENT_INSTANCES after creation, checks CANCEL_FLAGS immediately after store (race condition fix), calls agent.interrupt() in cancel_stream(), cleans up in finally block - static/boot.js: removes stale setStatus(cancelling) call - static/messages.js: setBusy(false)/setStatus('') unconditionally on cancel Race condition fix: after storing agent in AGENT_INSTANCES, immediately checks if CANCEL_FLAGS[stream_id] is already set (cancel arrived during agent init) and interrupts before starting. Check is inside the same STREAMS_LOCK acquisition, making it atomic. New test file: tests/test_cancel_interrupt.py with 6 unit tests. * docs: v0.46.0 release notes, bump version, update test counts --------- Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Summary
During a security audit of a local Hermes deployment, the following issues were found and fixed:
doneevent (chat stream) also contained the full session payload with unredacted credentialsgoogle_token.json,google_client_secret.json) had664permissions instead of600All API endpoints have no authentication by default, making this exploitable by any process on the same machine (including a malicious browser tab).
Changes
api/helpers.py— new credential redaction utilities_build_redact_fn(): importsredact_sensitive_textfromagent.redact(hermes-agent); falls back to built-in regex coveringghp_,sk-,AKIA,hf_,SG.,xox*,Authorization: Bearer,KEY=valueenv patterns, and PEM private key blocks_redact_value(): recursively redacts str / dict / listredact_session_data(): applies redaction tomessages[],tool_calls[], andtitle— response-layer only, session files on disk are not modifiedapi/routes.py— apply redaction in three endpointsGET /api/session— wraps full session payload withredact_session_data()GET /api/session/export— redacts before serving downloadGET /api/memory— applies_redact_text()to MEMORY.md and USER.mdapi/streaming.py— redact SSE done eventdoneevent sends the complete session object; now wrapped withredact_session_data()api/startup.py—fix_credential_permissions()chmod 600any HERMES_HOME file in a known-sensitive list (.env,google_token.json,google_client_secret.json,auth.json,.signing_key) if it has group or world read bits setserver.pyfix_credential_permissions()before accepting connectionsHERMES_WEBUI_PASSWORDtests/test_security_redaction.py— 13 new tests_redact_value,redact_session_dataacross message content / tool args / multi-type / innocent-content-unchanged,fix_credential_permissionsenforcement and no-opstart.sh)Test plan
pytest tests/test_security_redaction.py -v— 8 unit tests pass, 5 skipped./start.sh: all 13 tests passcurl http://localhost:8787/api/memory— credentials maskedcurl "http://localhost:8787/api/session?session_id=<id>"—ghp_tokens masked in messages🤖 Generated with Claude Code