{"repo":"NousResearch/hermes-agent","public_id":"43083","display_label":"Agent credential redaction breaking tool call history","summary":"These members concern a problem where credential/secret redaction in agent conversation history causes models to fail on subsequent tool calls. The fixes explore where and how redaction should occur to avoid corrupting the history the model reads back.","subsystem":"agent","counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":54136},"resolution":{"state":"resolved","by":"pr-54136"},"freshness":{"state":"verified","last_verified_at":"2026-06-28T09:44:06+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.4},"fix_edges":[{"issue_number":43083,"pr_number":54136,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":43083,"title":"Passwords get replaced by *** but model reads back its own conversation history and fails on second tool call.","url":null,"state":"closed"},{"kind":"pr","number":43092,"title":"fix(agent): move tool-call argument redaction to persistence boundary (#43083)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#43083","number":43083},{"kind":"pr","value":"#19798","number":19798},{"kind":"pr","value":"#43664","number":43664}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":43155,"title":"fix(agent): stop redacting credentials in conversation history (#43083)","url":null,"state":"closed","close_reason":{"category":"partial_fix","implication":"needs_llm"},"merged":false},{"kind":"pr","number":43664,"title":"fix(agent): teach models secret indirection instead of inlining credentials (#43083)","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":54136,"title":"fix(agent): stop redacting tool-call args in history; fix auth-header quote-eating (#43083)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/chat_completion_helpers.py","agent/redact.py","infographic/43083-secret-redaction/infographic.png","tests/agent/test_redact.py","tests/agent/test_tool_call_arg_no_redaction.py"],"fix_hunks":[{"file":"agent/chat_completion_helpers.py","hunks":["@@ -1050,18 +1050,23 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic\n                     \"arguments\": tool_call.function.arguments\n                 },\n             }\n-            # Defence-in-depth: redact credentials from tool call arguments\n-            # before they enter conversation history. Tool execution uses the\n-            # raw API response object, not this dict, so redacting the\n-            # persisted shape is safe and only affects storage. Catches the\n-            # case where a model accidentally inlines a secret into a tool\n-            # call (e.g. `terminal(command=\"curl -H 'Authorization: Bearer\n-            # sk-...'\")`). (#19798)\n-            if isinstance(tc_dict[\"function\"][\"arguments\"], str):\n-                from agent.redact import redact_sensitive_text\n-                tc_dict[\"function\"][\"arguments\"] = redact_sensitive_text(\n-                    tc_dict[\"function\"][\"arguments\"]\n-                )\n+            # Tool-call arguments are intentionally NOT redacted here. This\n+            # dict enters the in-memory conversation history that is replayed\n+            # to the model on every subsequent turn AND persisted to state.db,\n+            # which is itself replayed verbatim on session resume\n+            # (get_messages_as_conversation). Masking a credential to `***`\n+            # here poisons that replay: the model reads back its own\n+            # `PGPASSWORD='***' psql ...` call and copies the placeholder into\n+            # the next tool call, breaking every credential-dependent command\n+            # on the second turn (#43083). The masking also provided no real\n+            # protection — the same secret still leaks verbatim through tool\n+            # OUTPUT (file contents, command output, diffs, the compaction\n+            # block), none of which this pass ever touched. Keeping secrets\n+            # out of the replayable store is a separate tokenization/vault\n+            # concern, not something arg-redaction can deliver without\n+            # breaking replay. Storage-time redaction remains governed by the\n+            # `security.redact_secrets` toggle. (#19798 introduced this;\n+            # #43083 removed it.)\n             # Preserve extra_content (e.g. Gemini thought_signature) so it\n             # is sent back on subsequent API calls.  Without this, Gemini 3\n             # thinking models reject the request with a 400 error."]},{"file":"agent/redact.py","hunks":["@@ -173,8 +173,15 @@\n # while the header name and scheme word are preserved for debuggability. The\n # previous rule only matched ``Bearer``, so ``Basic <base64 user:pass>`` and\n # ``token <pat>`` leaked verbatim into logs/transcripts.\n+#\n+# The credential class excludes quote characters (``\"`` / ``'``): a token sitting\n+# flush against a closing quote (``\"Authorization: Bearer sk-...\"``) must not pull\n+# that quote into the match, or masking turns value corruption into *syntax*\n+# corruption — the closing quote vanishes and the command/string no longer parses\n+# (unterminated quote → shell EOF / Python SyntaxError). Real credentials never\n+# contain ``\"`` or ``'``, so excluding them is safe. See #43083.\n _AUTH_HEADER_RE = re.compile(\n-    r\"((?:Proxy-)?Authorization:\\s*)([A-Za-z][\\w.+-]*\\s+)?(\\S+)\",\n+    r\"((?:Proxy-)?Authorization:\\s*)([A-Za-z][\\w.+-]*\\s+)?([^\\s\\\"']+)\",\n     re.IGNORECASE,\n )\n "]},{"file":"tests/agent/test_redact.py","hunks":["@@ -170,6 +170,25 @@ def test_authorization_prose_unchanged(self):\n         text = \"the authorization model is fully open\"\n         assert redact_sensitive_text(text) == text\n \n+    def test_token_flush_against_double_quote_preserves_quote(self):\n+        # Regression for #43083: a token sitting flush against a closing\n+        # double quote must NOT pull that quote into the mask. Greedy \\S+\n+        # used to eat it, turning value corruption into syntax corruption\n+        # (unterminated quote → shell EOF).\n+        text = 'curl -H \"Authorization: Bearer sk-abcdef1234567890\"'\n+        result = redact_sensitive_text(text)\n+        assert \"sk-abcdef1234567890\" not in result\n+        assert result.count('\"') == 2, result  # both quotes survive\n+        assert result.endswith('\"'), result\n+\n+    def test_token_flush_against_single_quote_preserves_quote(self):\n+        # Regression for #43083: same as above with single quotes (Python\n+        # f-string context). The closing ' must survive the mask.\n+        text = \"auth = f'Authorization: Bearer {placeholder}'\"\n+        result = redact_sensitive_text(text)\n+        assert result.count(\"'\") == 2, result\n+        assert result.endswith(\"'\"), result\n+\n \n class TestApiKeyHeaders:\n     def test_x_api_key_header_masked(self):"]},{"file":"tests/agent/test_tool_call_arg_no_redaction.py","hunks":["@@ -0,0 +1,91 @@\n+\"\"\"Regression test for #43083.\n+\n+``build_assistant_message`` must NOT redact tool-call arguments. The dict it\n+returns enters the in-memory conversation history that is replayed to the model\n+on every subsequent turn AND is persisted to state.db, which is itself replayed\n+verbatim on session resume. Masking a credential to ``***`` there poisons the\n+replay: the model reads back its own ``PGPASSWORD='***' psql ...`` call and\n+copies the placeholder into the next tool call, breaking every\n+credential-dependent command on the second turn.\n+\"\"\"\n+\n+from unittest.mock import MagicMock\n+\n+from agent.chat_completion_helpers import build_assistant_message\n+\n+\n+class _FakeToolCall:\n+    def __init__(self, tc_id, name, arguments):\n+        self.id = tc_id\n+        self.type = \"function\"\n+        self.function = MagicMock()\n+        self.function.name = name\n+        self.function.arguments = arguments\n+        self.extra_content = None\n+\n+    def __getattr__(self, _name):\n+        return None\n+\n+\n+class _FakeAssistantMsg:\n+    def __init__(self, content, tool_calls):\n+        self.content = content\n+        self.tool_calls = tool_calls\n+        self.function_call = None\n+        self.reasoning_content = None\n+        self.model_extra = None\n+        self.reasoning_details = None\n+\n+    def __getattr__(self, _name):\n+        return None\n+\n+\n+class _FakeAgent:\n+    stream_delta_callback = None\n+    _stream_callback = None\n+    reasoning_callback = None\n+    verbose_logging = False\n+\n+    def _extract_reasoning(self, _msg):\n+        return None\n+\n+    def _strip_think_blocks(self, text):\n+        return text\n+\n+    def _needs_thinking_reasoning_pad(self):\n+        return False\n+\n+    def _split_responses_tool_id(self, _raw):\n+        return (None, None)\n+\n+    def _derive_responses_function_call_id(self, _call_id, _resp_id):\n+        return None\n+\n+    def _deterministic_call_id(self, _name, _args, idx):\n+        return f\"det_{idx}\"\n+\n+\n+def _build(arguments):\n+    tc = _FakeToolCall(\"call_1\", \"terminal\", arguments)\n+    msg = build_assistant_message(_FakeAgent(), _FakeAssistantMsg(\"ok\", [tc]), \"tool_calls\")\n+    return msg[\"tool_calls\"][0][\"function\"][\"arguments\"]\n+\n+\n+def test_pgpassword_preserved_verbatim(monkeypatch):\n+    # Force redaction ON to prove build_assistant_message bypasses it for\n+    # tool-call args regardless of the global toggle.\n+    monkeypatch.setattr(\"agent.redact._REDACT_ENABLED\", True, raising=False)\n+    args = '{\"command\": \"PGPASSWORD=\\'honchorulez\\' psql -h 127.0.0.1\"}'\n+    got = _build(args)\n+    assert got == args\n+    assert \"honchorulez\" in got\n+    assert \"***\" not in got\n+\n+\n+def test_bearer_token_preserved_verbatim(monkeypatch):\n+    monkeypatch.setattr(\"agent.redact._REDACT_ENABLED\", True, raising=False)\n+    args = '{\"command\": \"curl -H \\'Authorization: Bearer sk-abcdef1234567890\\'\"}'\n+    got = _build(args)\n+    assert got == args\n+    assert \"sk-abcdef1234567890\" in got\n+    assert \"***\" not in got\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}