{"repo":"NousResearch/hermes-agent","public_id":"8518","display_label":"Redact API credentials from request debug dump files","summary":"Multiple reports and fixes address plaintext API keys and Authorization headers being written to request_dump_*.json and session_*.json files, with proposed solutions involving applying existing redaction logic to this code path.","subsystem":"agent","counts":{"members":12,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":5,"merged_prs":1,"attempt_prs":6},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":46637},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-15T12:31:22+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.19515151515151513},"fix_edges":[{"issue_number":8518,"pr_number":46637,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":18707,"pr_number":46637,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":8518,"title":"[Security] API key exposed in request debug dumps via incomplete masking","url":null,"state":"closed"},{"kind":"issue","number":18707,"title":"[Feature]: request_dump_*.json files in ~/.hermes/sessions/ contain plaintext Authorization headers — agent/redact.py exists but isn't applied on this path","url":null,"state":"closed"},{"kind":"issue","number":19202,"title":"[Feature]: Failed-request dumps write complete Authorization headers (containing API keys) to ~/.hermes/sessions/request_dump_*.json in plaintext — agent/redact.py exists but isn't invoked on this path","url":null,"state":"closed"},{"kind":"issue","number":19845,"title":"Session capture leaks API credentials in plaintext to session_*.json","url":null,"state":"closed"},{"kind":"issue","number":46583,"title":"Hardening: redact secrets in API request debug dumps (unredacted request body, written unconditionally on API errors)","url":null,"state":"closed"},{"kind":"pr","number":8533,"title":"fix: strip API keys from request debug dumps (#8518)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#8768","number":8768}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":8768,"title":"fix: omit authorization from request debug dumps","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#8518","number":8518},{"kind":"pr","value":"#46637","number":46637}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":18750,"title":"fix(agent): redact secrets in request_dump_*.json files","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#8518","number":8518},{"kind":"pr","value":"#46637","number":46637}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":18909,"title":"fix(security): strip credentials from request debug dumps","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#8533","number":8533},{"kind":"pr","value":"#8768","number":8768},{"kind":"pr","value":"#8518","number":8518},{"kind":"pr","value":"#8533","number":8533},{"kind":"pr","value":"#8768","number":8768}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":33322,"title":"[codex] Redact request debug dumps","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#18707","number":18707},{"kind":"pr","value":"#19202","number":19202},{"kind":"pr","value":"#8518","number":8518},{"kind":"pr","value":"#46637","number":46637},{"kind":"pr","value":"#46637","number":46637},{"kind":"pr","value":"#46583","number":46583},{"kind":"pr","value":"#18707","number":18707},{"kind":"pr","value":"#19202","number":19202}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":46595,"title":"fix(agent): redact secrets in API request debug dumps","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#18750","number":18750}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":46637,"title":"fix(agent): redact secrets in request debug dumps","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/agent_runtime_helpers.py","agent/redact.py","scripts/release.py","tests/agent/test_redact.py","tests/run_agent/test_run_agent_codex_responses.py"],"fix_hunks":[{"file":"agent/agent_runtime_helpers.py","hunks":["@@ -1217,12 +1217,23 @@ def dump_api_request_debug(\n \n         timestamp = datetime.now().strftime(\"%Y%m%d_%H%M%S_%f\")\n         dump_file = agent.logs_dir / f\"request_dump_{agent.session_id}_{timestamp}.json\"\n-        atomic_json_write(dump_file, dump_payload, default=str)\n+\n+        # Redact secrets before persisting/printing. This dump captures the\n+        # full request body (system prompt, tool defs, context-embedded\n+        # values), and this path fires unconditionally on API errors — so it\n+        # otherwise lands any context-embedded secret in cleartext on disk.\n+        # Run the serialized dump through the same scrubber used for logs/tool\n+        # output, then hand the resulting payload back to the shared atomic\n+        # JSON writer so request dumps keep the same write semantics as before.\n+        from agent.redact import redact_sensitive_text\n+        _serialized = json.dumps(dump_payload, ensure_ascii=False, indent=2, default=str)\n+        _redacted_payload = json.loads(redact_sensitive_text(_serialized, force=True))\n+        atomic_json_write(dump_file, _redacted_payload, default=str)\n \n         agent._vprint(f\"{agent.log_prefix}🧾 Request debug dump written to: {dump_file}\")\n \n         if env_var_enabled(\"HERMES_DUMP_REQUEST_STDOUT\"):\n-            print(json.dumps(dump_payload, ensure_ascii=False, indent=2, default=str))\n+            print(json.dumps(_redacted_payload, ensure_ascii=False, indent=2, default=str))\n \n         return dump_file\n     except Exception as dump_error:"]},{"file":"agent/redact.py","hunks":["@@ -104,6 +104,7 @@\n     r\"mem0_[A-Za-z0-9]{10,}\",           # Mem0 Platform API key\n     r\"brv_[A-Za-z0-9]{10,}\",            # ByteRover API key\n     r\"xai-[A-Za-z0-9]{30,}\",            # xAI (Grok) API key\n+    r\"ntn_[A-Za-z0-9]{10,}\",            # Notion internal integration token\n ]\n \n # ENV assignment patterns: KEY=value where KEY contains a secret-like name"]},{"file":"scripts/release.py","hunks":["@@ -46,6 +46,7 @@\n # Auto-extracted from noreply emails + manual overrides\n AUTHOR_MAP = {\n     \"[redacted:email]\": \"Kenmege\",\n+    \"[redacted:email]\": \"xtymac\",\n     \"[redacted:email]\": \"Diyoncrz18\",\n     \"[redacted:email]\": \"ali-nld\",\n     \"[redacted:email]\": \"necoweb3\","]},{"file":"tests/agent/test_redact.py","hunks":["@@ -53,6 +53,10 @@ def test_fal_key(self):\n         result = redact_sensitive_text(\"fal_abc123def456ghi789jkl\")\n         assert \"abc123def456\" not in result\n \n+    def test_notion_internal_integration_token(self):\n+        result = redact_sensitive_text(\"ntn_abc123def456ghi789jkl\")\n+        assert \"abc123def456\" not in result\n+\n     def test_short_token_fully_masked(self):\n         result = redact_sensitive_text(\"key=sk-short1234567\")\n         assert \"***\" in result"]},{"file":"tests/run_agent/test_run_agent_codex_responses.py","hunks":["@@ -1913,6 +1913,58 @@ def test_dump_api_request_debug_uses_chat_completions_url(monkeypatch, tmp_path)\n     assert payload[\"request\"][\"url\"] == \"http://127.0.0.1:9208/v1/chat/completions\"\n \n \n+def test_dump_api_request_debug_redacts_request_and_error_secrets(monkeypatch, tmp_path, capsys):\n+    \"\"\"Request debug dumps should redact secrets before disk/stdout output.\"\"\"\n+    import json\n+\n+    _patch_agent_bootstrap(monkeypatch)\n+    monkeypatch.setenv(\"HERMES_DUMP_REQUEST_STDOUT\", \"1\")\n+    agent = run_agent.AIAgent(\n+        model=\"gpt-4o\",\n+        base_url=\"http://127.0.0.1:9208/v1\",\n+        api_key=\"[redacted:secret-assignment]\",\n+        quiet_mode=True,\n+        max_iterations=1,\n+        skip_context_files=True,\n+        skip_memory=True,\n+    )\n+    agent.logs_dir = tmp_path\n+\n+    notion_token = \"[redacted:secret-assignment]\"\n+    error_secret = \"[redacted:secret-assignment]\"\n+    response_secret = \"[redacted:secret-assignment]\"\n+    response = SimpleNamespace(status_code=400, text=f\"provider echoed {response_secret}\")\n+\n+    class ProviderError(RuntimeError):\n+        body: object\n+        response: object\n+\n+    error = ProviderError(f\"bad token {error_secret}\")\n+    error.body = {\"message\": f\"bad token {error_secret}\"}\n+    error.response = response\n+\n+    dump_file = agent._dump_api_request_debug(\n+        {\n+            \"model\": \"gpt-4o\",\n+            \"messages\": [{\"role\": \"user\", \"content\": f\"use {notion_token}\"}],\n+            \"metadata\": {\"NOTION_API_KEY\": notion_token},\n+        },\n+        reason=\"provider_error\",\n+        error=error,\n+    )\n+\n+    assert dump_file is not None\n+    dumped_text = dump_file.read_text()\n+    stdout_text = capsys.readouterr().out\n+    for raw in (notion_token, error_secret, response_secret, \"providersecret1234567890\"):\n+        assert raw not in dumped_text\n+        assert raw not in stdout_text\n+\n+    payload = json.loads(dumped_text)\n+    assert payload[\"request\"][\"headers\"][\"Authorization\"].startswith(\"Bearer sk-ant-p...\")\n+    assert \"***\" in dumped_text or \"...\" in dumped_text\n+\n+\n # --- Reasoning-only response tests (fix for empty content retry loop) ---\n \n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}