{"repo":"NousResearch/hermes-agent","public_id":"10520","display_label":"Redacting credentials leaked by browser_type tool","summary":"These issues and PRs concern a security problem where text typed via a browser automation tool exposes credentials in output or display surfaces. The work involves identifying and redacting sensitive input across various display contexts.","subsystem":"agent","counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":52850},"resolution":{"state":"partially_resolved","by":"pr-52850"},"freshness":{"state":"verified","last_verified_at":"2026-06-26T05:02:22+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.33599999999999997},"fix_edges":[{"issue_number":47197,"pr_number":52850,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":10520,"title":"[Bug]:","url":null,"state":"closed"},{"kind":"issue","number":47197,"title":"Security: browser_type leaks credentials in tool output","url":null,"state":"closed"},{"kind":"pr","number":10661,"title":"fix(display): hide browser_type text in tool previews","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#72298","number":72298},{"kind":"pr","value":"#72432","number":72432}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":47308,"title":"fix: redact browser typed text surfaces","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"8ff426e53","number":0},{"kind":"pr","value":"#52850","number":52850}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":52850,"title":"fix(browser): redact recognized secrets in browser_type display surfaces","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/display.py","agent/tool_executor.py","scripts/release.py","tests/agent/test_display.py","tests/run_agent/test_run_agent.py","tests/tools/test_browser_camofox.py","tests/tools/test_browser_type_redaction.py","tools/browser_camofox.py","tools/browser_tool.py"],"fix_hunks":[{"file":"agent/display.py","hunks":["@@ -16,6 +16,7 @@\n from typing import Any\n \n from utils import safe_json_loads\n+from agent.redact import redact_sensitive_text\n from agent.tool_result_classification import file_mutation_result_landed\n \n # ANSI escape codes for coloring tool failure indicators","@@ -339,6 +340,62 @@ def _read_file_line_label(args: dict) -> str:\n     return f\"L{offset}-{offset + limit - 1}\"\n \n \n+def redact_browser_typed_text_for_display(value: Any, typed_text: Any) -> Any:\n+    \"\"\"Apply secret redaction to browser_type text in display-facing payloads.\n+\n+    Backends sometimes echo the attempted input in error strings or fallback\n+    metadata.  When the raw typed value contains a recognizable secret (API\n+    key, token, JWT, etc.) the redacted form differs from the raw value, so we\n+    replace every occurrence of the raw value with its redacted form before a\n+    browser_type result reaches logs, callbacks, the model, or chat history.\n+\n+    Normal typed text (search queries, addresses, form fields) matches no\n+    secret pattern, so it passes through unchanged and stays readable.\n+\n+    Redaction is forced here regardless of the global ``security.redact_secrets``\n+    preference: a typed credential leaking into chat history is a security\n+    boundary, not mere log hygiene.\n+    \"\"\"\n+    if typed_text is None:\n+        return value\n+    needle = str(typed_text)\n+    if needle == \"\":\n+        return value\n+    redacted = redact_sensitive_text(needle, force=True)\n+    if redacted == needle:\n+        # Nothing secret-looking in the typed text; leave payload untouched.\n+        return value\n+    if isinstance(value, str):\n+        return value.replace(needle, redacted)\n+    if isinstance(value, dict):\n+        return {\n+            key: redact_browser_typed_text_for_display(item, typed_text)\n+            for key, item in value.items()\n+        }\n+    if isinstance(value, list):\n+        return [redact_browser_typed_text_for_display(item, typed_text) for item in value]\n+    if isinstance(value, tuple):\n+        return tuple(redact_browser_typed_text_for_display(item, typed_text) for item in value)\n+    return value\n+\n+\n+def redact_tool_args_for_display(tool_name: str, args: dict | None) -> dict | None:\n+    \"\"\"Return a copy of tool args safe for logs/progress UI.\n+\n+    For ``browser_type`` the ``text`` argument is run through the same\n+    secret-pattern redactor used for logs.  Recognizable credentials (API\n+    keys, tokens) are masked before the value reaches tool progress\n+    notifications; normal typed text is left intact for debuggability.\n+    \"\"\"\n+    if not isinstance(args, dict):\n+        return args\n+    if tool_name == \"browser_type\" and isinstance(args.get(\"text\"), str):\n+        safe_args = dict(args)\n+        safe_args[\"text\"] = redact_sensitive_text(args[\"text\"], force=True)\n+        return safe_args\n+    return args\n+\n+\n def _delegate_task_goal_parts(tasks: Any, *, per_goal_len: int) -> tuple[int, list[str]]:\n     if not isinstance(tasks, list):\n         return 0, []","@@ -362,6 +419,7 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -\n         max_len = _tool_preview_max_len\n     if not args:\n         return None\n+    args = redact_tool_args_for_display(tool_name, args) or args\n     primary_args = {\n         \"terminal\": \"command\", \"web_search\": \"query\", \"web_extract\": \"urls\",\n         \"read_file\": \"path\", \"write_file\": \"path\", \"patch\": \"path\",","@@ -1085,6 +1143,7 @@ def get_cute_tool_message(\n     When *result* is provided the line is checked for failure indicators.\n     Failed tool calls get a red prefix and an informational suffix.\n     \"\"\"\n+    args = redact_tool_args_for_display(tool_name, args) or args\n     dur = f\"{duration:.1f}s\"\n     is_failure, failure_suffix = _detect_tool_failure(tool_name, result)\n     skin_prefix = get_skin_tool_prefix()"]},{"file":"agent/tool_executor.py","hunks":["@@ -26,6 +26,7 @@\n     build_tool_preview as _build_tool_preview,\n     get_cute_tool_message as _get_cute_tool_message_impl,\n     get_tool_emoji as _get_tool_emoji,\n+    redact_tool_args_for_display as _redact_tool_args_for_display,\n     _detect_tool_failure,\n )\n from agent.tool_guardrails import ToolGuardrailDecision","@@ -469,10 +470,11 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe\n     if not agent.quiet_mode and getattr(agent, \"tool_progress_mode\", \"all\") != \"off\":\n         print(f\"  ⚡ Concurrent: {num_tools} tool calls — {tool_names_str}\")\n         for i, (tc, name, args, middleware_trace, block_result, blocked_by_guardrail) in enumerate(parsed_calls, 1):\n-            args_str = json.dumps(args, ensure_ascii=False)\n+            display_args = _redact_tool_args_for_display(name, args) or args\n+            args_str = json.dumps(display_args, ensure_ascii=False)\n             if agent.verbose_logging:\n-                print(f\"  📞 Tool {i}: {name}({list(args.keys())})\")\n-                print(agent._wrap_verbose(\"Args: \", json.dumps(args, indent=2, ensure_ascii=False)))\n+                print(f\"  📞 Tool {i}: {name}({list(display_args.keys())})\")\n+                print(agent._wrap_verbose(\"Args: \", json.dumps(display_args, indent=2, ensure_ascii=False)))\n             else:\n                 args_preview = args_str[:agent.log_prefix_chars] + \"...\" if len(args_str) > agent.log_prefix_chars else args_str\n                 print(f\"  📞 Tool {i}: {name}({list(args.keys())}) - {args_preview}\")","@@ -482,8 +484,9 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe\n             continue\n         if agent.tool_progress_callback:\n             try:\n-                preview = _build_tool_preview(name, args)\n-                agent.tool_progress_callback(\"tool.started\", name, preview, args)\n+                display_args = _redact_tool_args_for_display(name, args) or args\n+                preview = _build_tool_preview(name, display_args)\n+                agent.tool_progress_callback(\"tool.started\", name, preview, display_args)\n             except Exception as cb_err:\n                 logging.debug(f\"Tool progress callback error: {cb_err}\")\n ","@@ -492,7 +495,8 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe\n             continue\n         if agent.tool_start_callback:\n             try:\n-                agent.tool_start_callback(tc.id, name, args)\n+                display_args = _redact_tool_args_for_display(name, args) or args\n+                agent.tool_start_callback(tc.id, name, display_args)\n             except Exception as cb_err:\n                 logging.debug(f\"Tool start callback error: {cb_err}\")\n ","@@ -792,7 +796,8 @@ def _run_tool(index, tool_call, function_name, function_args, middleware_trace):\n \n         if not blocked and agent.tool_complete_callback:\n             try:\n-                agent.tool_complete_callback(tc.id, name, args, function_result)\n+                display_args = _redact_tool_args_for_display(name, args) or args\n+                agent.tool_complete_callback(tc.id, name, display_args, function_result)\n             except Exception as cb_err:\n                 logging.debug(f\"Tool complete callback error: {cb_err}\")\n ","@@ -954,10 +959,11 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe\n             agent._iters_since_skill = 0\n \n         if not agent.quiet_mode and getattr(agent, \"tool_progress_mode\", \"all\") != \"off\":\n-            args_str = json.dumps(function_args, ensure_ascii=False)\n+            display_args = _redact_tool_args_for_display(function_name, function_args) or function_args\n+            args_str = json.dumps(display_args, ensure_ascii=False)\n             if agent.verbose_logging:\n-                print(f\"  📞 Tool {i}: {function_name}({list(function_args.keys())})\")\n-                print(agent._wrap_verbose(\"Args: \", json.dumps(function_args, indent=2, ensure_ascii=False)))\n+                print(f\"  📞 Tool {i}: {function_name}({list(display_args.keys())})\")\n+                print(agent._wrap_verbose(\"Args: \", json.dumps(display_args, indent=2, ensure_ascii=False)))\n             else:\n                 args_preview = args_str[:agent.log_prefix_chars] + \"...\" if len(args_str) > agent.log_prefix_chars else args_str\n                 print(f\"  📞 Tool {i}: {function_name}({list(function_args.keys())}) - {args_preview}\")","@@ -978,14 +984,16 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe\n \n         if not _execution_blocked and agent.tool_progress_callback:\n             try:\n-                preview = _build_tool_preview(function_name, function_args)\n-                agent.tool_progress_callback(\"tool.started\", function_name, preview, function_args)\n+                display_args = _redact_tool_args_for_display(function_name, function_args) or function_args\n+                preview = _build_tool_preview(function_name, display_args)\n+                agent.tool_progress_callback(\"tool.started\", function_name, preview, display_args)\n             except Exception as cb_err:\n                 logging.debug(f\"Tool progress callback error: {cb_err}\")\n \n         if not _execution_blocked and agent.tool_start_callback:\n             try:\n-                agent.tool_start_callback(tool_call.id, function_name, function_args)\n+                display_args = _redact_tool_args_for_display(function_name, function_args) or function_args\n+                agent.tool_start_callback(tool_call.id, function_name, display_args)\n             except Exception as cb_err:\n                 logging.debug(f\"Tool start callback error: {cb_err}\")\n ","@@ -1215,7 +1223,8 @@ def _execute(next_args: dict) -> Any:\n             if agent._should_emit_quiet_tool_messages():\n                 face = random.choice(KawaiiSpinner.get_waiting_faces())\n                 emoji = _get_tool_emoji(function_name)\n-                preview = _build_tool_preview(function_name, function_args) or function_name\n+                display_args = _redact_tool_args_for_display(function_name, function_args) or function_args\n+                preview = _build_tool_preview(function_name, display_args) or function_name\n                 spinner = KawaiiSpinner(f\"{face} {emoji} {preview}\", spinner_type='dots', print_fn=agent._print_fn)\n                 spinner.start()\n             _ce_result = None","@@ -1248,7 +1257,8 @@ def _execute(next_args: dict) -> Any:\n             if agent._should_emit_quiet_tool_messages() and agent._should_start_quiet_spinner():\n                 face = random.choice(KawaiiSpinner.get_waiting_faces())\n                 emoji = _get_tool_emoji(function_name)\n-                preview = _build_tool_preview(function_name, function_args) or function_name\n+                display_args = _redact_tool_args_for_display(function_name, function_args) or function_args\n+                preview = _build_tool_preview(function_name, display_args) or function_name\n                 spinner = KawaiiSpinner(f\"{face} {emoji} {preview}\", spinner_type='dots', print_fn=agent._print_fn)\n                 spinner.start()\n             _mem_result = None","@@ -1279,7 +1289,8 @@ def _execute(next_args: dict) -> Any:\n             if agent._should_emit_quiet_tool_messages() and agent._should_start_quiet_spinner():\n                 face = random.choice(KawaiiSpinner.get_waiting_faces())\n                 emoji = _get_tool_emoji(function_name)\n-                preview = _build_tool_preview(function_name, function_args) or function_name\n+                display_args = _redact_tool_args_for_display(function_name, function_args) or function_args\n+                preview = _build_tool_preview(function_name, display_args) or function_name\n                 spinner = KawaiiSpinner(f\"{face} {emoji} {preview}\", spinner_type='dots', print_fn=agent._print_fn)\n                 spinner.start()\n             _spinner_result = None","@@ -1441,7 +1452,8 @@ def _execute(next_args: dict) -> Any:\n \n         if not _execution_blocked and agent.tool_complete_callback:\n             try:\n-                agent.tool_complete_callback(tool_call.id, function_name, function_args, function_result)\n+                display_args = _redact_tool_args_for_display(function_name, function_args) or function_args\n+                agent.tool_complete_callback(tool_call.id, function_name, display_args, function_result)\n             except Exception as cb_err:\n                 logging.debug(f\"Tool complete callback error: {cb_err}\")\n "]},{"file":"scripts/release.py","hunks":["@@ -45,6 +45,7 @@\n \n # Auto-extracted from noreply emails + manual overrides\n AUTHOR_MAP = {\n+    \"[redacted:email]\": \"rebel0789\",  # PR #47308 salvage (redact browser_type typed text across display surfaces; #47197)\n     \"[redacted:email]\": \"agt-user\",  # PR #48496 salvage (telegram CLOSE-WAIT polling heartbeat, #48495)\n     \"[redacted:email]\": \"DavidMetcalfe\",  # PR #52272 salvage (route reasoning-model thinking-timeouts to timeout not context_overflow + reasoning-specific guidance; #52271)\n     \"[redacted:email]\": \"herbalizer404\",  # PR #49076 + #51835 salvage (auxiliary compression fallback: 403/session-usage payment errors + honor fallback chain when aux provider auth unavailable)"]},{"file":"tests/agent/test_display.py","hunks":["@@ -9,6 +9,7 @@\n     capture_local_edit_snapshot,\n     extract_edit_diff,\n     get_cute_tool_message,\n+    redact_tool_args_for_display,\n     set_tool_preview_max_len,\n     _render_inline_unified_diff,\n     _summarize_rendered_diff_sections,","@@ -86,6 +87,35 @@ def test_read_file_preview_includes_requested_line_range(self):\n         result = build_tool_preview(\"read_file\", {\"path\": \"./package.json\", \"offset\": 1, \"limit\": 5})\n         assert result == \"package.json L1-5\"\n \n+    def test_browser_type_preview_redacts_api_key(self):\n+        secret = \"[redacted:secret-assignment]\"\n+        result = build_tool_preview(\"browser_type\", {\"ref\": \"@e3\", \"text\": secret})\n+        assert result is not None\n+        assert secret not in result\n+        assert \"sk-pro\" in result and \"...\" in result\n+\n+    def test_browser_type_preview_keeps_normal_text(self):\n+        text = \"hello world search query\"\n+        result = build_tool_preview(\"browser_type\", {\"ref\": \"@e3\", \"text\": text})\n+        assert result is not None\n+        assert text in result\n+\n+    def test_browser_type_display_args_redact_api_key(self):\n+        secret = \"[redacted:secret-assignment]\"\n+        safe_args = redact_tool_args_for_display(\n+            \"browser_type\", {\"ref\": \"@e3\", \"text\": secret}\n+        )\n+        assert secret not in str(safe_args)\n+        assert safe_args[\"ref\"] == \"@e3\"\n+        assert safe_args[\"text\"].startswith(\"ghp_AB\")\n+\n+    def test_browser_type_display_args_keep_normal_text(self):\n+        text = \"my_normal_password_123\"\n+        safe_args = redact_tool_args_for_display(\n+            \"browser_type\", {\"ref\": \"@e3\", \"text\": text}\n+        )\n+        assert safe_args == {\"ref\": \"@e3\", \"text\": text}\n+\n     def test_unknown_tool_with_fallback_key(self):\n         \"\"\"Unknown tool but with a recognized fallback key should still preview.\"\"\"\n         result = build_tool_preview(\"custom_tool\", {\"query\": \"test query\"})","@@ -242,6 +272,29 @@ def test_delegate_task_batch_message_includes_goals(self):\n         )\n         assert \"2x: Review PR A | Review PR B\" in line\n \n+    def test_browser_type_cute_message_redacts_api_key(self):\n+        secret = \"[redacted:secret-assignment]\"\n+        line = get_cute_tool_message(\n+            \"browser_type\",\n+            {\"ref\": \"@password\", \"text\": secret},\n+            0.1,\n+            result='{\"success\": true, \"typed\": \"sk-pro...EFGH\"}',\n+        )\n+\n+        assert secret not in line\n+        assert \"sk-pro\" in line\n+\n+    def test_browser_type_cute_message_keeps_normal_text(self):\n+        text = \"hello world\"\n+        line = get_cute_tool_message(\n+            \"browser_type\",\n+            {\"ref\": \"@search\", \"text\": text},\n+            0.1,\n+            result='{\"success\": true, \"typed\": \"hello world\"}',\n+        )\n+\n+        assert text in line\n+\n \n class TestEditDiffPreview:\n     def test_extract_edit_diff_for_patch(self):"]},{"file":"tests/run_agent/test_run_agent.py","hunks":["@@ -2589,6 +2589,30 @@ def test_sequential_tool_callbacks_fire_in_order(self, agent):\n         assert starts == [(\"c1\", \"web_search\", {\"query\": \"hello\"})]\n         assert completes == [(\"c1\", \"web_search\", {\"query\": \"hello\"}, '{\"success\": true}')]\n \n+    def test_sequential_browser_type_callbacks_redact_api_key(self, agent):\n+        secret = \"[redacted:secret-assignment]\"\n+        tool_call = _mock_tool_call(\n+            name=\"browser_type\",\n+            arguments=json.dumps({\"ref\": \"@apikey\", \"text\": secret}),\n+            call_id=\"c-secret\",\n+        )\n+        mock_msg = _mock_assistant_msg(content=\"\", tool_calls=[tool_call])\n+        messages = []\n+        starts = []\n+        completes = []\n+        progress = []\n+        agent.tool_start_callback = lambda tool_call_id, function_name, function_args: starts.append((tool_call_id, function_name, function_args))\n+        agent.tool_complete_callback = lambda tool_call_id, function_name, function_args, function_result: completes.append((tool_call_id, function_name, function_args, function_result))\n+        agent.tool_progress_callback = lambda event, name, preview, args, **kw: progress.append((event, name, preview, args))\n+\n+        with patch(\"run_agent.handle_function_call\", return_value='{\"success\": true, \"typed\": \"sk-pro...EFGH\"}'):\n+            agent._execute_tool_calls_sequential(mock_msg, messages, \"task-1\")\n+\n+        assert starts[0][2][\"text\"].startswith(\"sk-pro\")\n+        assert completes[0][2][\"text\"].startswith(\"sk-pro\")\n+        assert progress[0][2].startswith(\"sk-pro\")\n+        assert secret not in repr(starts + completes + progress)\n+\n     def test_concurrent_tool_callbacks_fire_for_each_tool(self, agent):\n         tc1 = _mock_tool_call(name=\"web_search\", arguments='{\"query\":\"one\"}', call_id=\"c1\")\n         tc2 = _mock_tool_call(name=\"web_search\", arguments='{\"query\":\"two\"}', call_id=\"c2\")","@@ -2610,6 +2634,30 @@ def test_concurrent_tool_callbacks_fire_for_each_tool(self, agent):\n         assert {entry[0] for entry in completes} == {\"c1\", \"c2\"}\n         assert {entry[3] for entry in completes} == {'{\"id\":1}', '{\"id\":2}'}\n \n+    def test_concurrent_browser_type_callbacks_redact_api_key(self, agent):\n+        secret = \"[redacted:secret-assignment]\"\n+        tc = _mock_tool_call(\n+            name=\"browser_type\",\n+            arguments=json.dumps({\"ref\": \"@apikey\", \"text\": secret}),\n+            call_id=\"c-secret\",\n+        )\n+        mock_msg = _mock_assistant_msg(content=\"\", tool_calls=[tc])\n+        messages = []\n+        starts = []\n+        completes = []\n+        progress = []\n+        agent.tool_start_callback = lambda tool_call_id, function_name, function_args: starts.append((tool_call_id, function_name, function_args))\n+        agent.tool_complete_callback = lambda tool_call_id, function_name, function_args, function_result: completes.append((tool_call_id, function_name, function_args, function_result))\n+        agent.tool_progress_callback = lambda event, name, preview, args, **kw: progress.append((event, name, preview, args))\n+\n+        with patch(\"run_agent.handle_function_call\", return_value='{\"success\": true, \"typed\": \"sk-pro...EFGH\"}'):\n+            agent._execute_tool_calls_concurrent(mock_msg, messages, \"task-1\")\n+\n+        assert starts[0][2][\"text\"].startswith(\"sk-pro\")\n+        assert completes[0][2][\"text\"].startswith(\"sk-pro\")\n+        assert progress[0][2].startswith(\"sk-pro\")\n+        assert secret not in repr(starts + completes + progress)\n+\n     def test_invoke_tool_handles_agent_level_tools(self, agent):\n         \"\"\"_invoke_tool should handle todo tool directly.\"\"\"\n         with patch(\"tools.todo_tool.todo_tool\", return_value='{\"ok\":true}') as mock_todo:"]},{"file":"tests/tools/test_browser_camofox.py","hunks":["@@ -235,8 +235,39 @@ def test_type(self, mock_post, monkeypatch):\n         mock_post.return_value = _mock_response(json_data={\"ok\": True})\n         result = json.loads(camofox_type(\"@e3\", \"hello world\", task_id=\"t5\"))\n         assert result[\"success\"] is True\n+        # Normal text is left readable.\n         assert result[\"typed\"] == \"hello world\"\n \n+    @patch(\"tools.browser_camofox.requests.post\")\n+    def test_type_redacts_api_key(self, mock_post, monkeypatch):\n+        monkeypatch.setenv(\"CAMOFOX_URL\", \"http://localhost:9377\")\n+        monkeypatch.setenv(\"HERMES_REDACT_SECRETS\", \"true\")\n+        mock_post.return_value = _mock_response(json_data={\"tabId\": \"tab5b\", \"url\": \"https://x.com\"})\n+        camofox_navigate(\"https://x.com\", task_id=\"t5b\")\n+\n+        secret = \"[redacted:secret-assignment]\"\n+        mock_post.return_value = _mock_response(json_data={\"ok\": True})\n+        result = json.loads(camofox_type(\"@apikey\", secret, task_id=\"t5b\"))\n+        assert result[\"success\"] is True\n+        assert secret not in json.dumps(result)\n+        assert result[\"typed\"].startswith(\"sk-pro\")\n+\n+    @patch(\"tools.browser_camofox.requests.post\")\n+    def test_type_failure_redacts_api_key(self, mock_post, monkeypatch):\n+        monkeypatch.setenv(\"CAMOFOX_URL\", \"http://localhost:9377\")\n+        monkeypatch.setenv(\"HERMES_REDACT_SECRETS\", \"true\")\n+        mock_post.return_value = _mock_response(json_data={\"tabId\": \"tab5c\", \"url\": \"https://x.com\"})\n+        camofox_navigate(\"https://x.com\", task_id=\"t5c\")\n+\n+        secret = \"[redacted:secret-assignment]\"\n+        mock_post.side_effect = RuntimeError(f\"camofox failed while typing {secret}\")\n+        raw_result = camofox_type(\"@apikey\", secret, task_id=\"t5c\")\n+        result = json.loads(raw_result)\n+\n+        assert result[\"success\"] is False\n+        assert secret not in raw_result\n+        assert \"sk-pro\" in raw_result\n+\n     @patch(\"tools.browser_camofox.requests.post\")\n     def test_scroll(self, mock_post, monkeypatch):\n         monkeypatch.setenv(\"CAMOFOX_URL\", \"http://localhost:9377\")"]},{"file":"tests/tools/test_browser_type_redaction.py","hunks":["@@ -0,0 +1,74 @@\n+\"\"\"Regression tests for browser_type display redaction.\n+\n+Typed text is passed through the same secret-pattern redactor used for logs:\n+recognizable credentials (API keys, tokens) are masked in display-facing\n+output, while normal typed text is left intact.  The raw value is always sent\n+to the browser backend regardless.\n+\"\"\"\n+\n+import json\n+from unittest.mock import patch\n+\n+from tools.browser_tool import browser_type\n+\n+\n+def test_browser_type_redacts_api_key_in_output(monkeypatch):\n+    monkeypatch.delenv(\"CAMOFOX_URL\", raising=False)\n+    monkeypatch.delenv(\"BROWSER_CDP_URL\", raising=False)\n+    monkeypatch.setenv(\"HERMES_REDACT_SECRETS\", \"true\")\n+    secret = \"[redacted:secret-assignment]\"\n+\n+    with patch(\n+        \"tools.browser_tool._run_browser_command\",\n+        return_value={\"success\": True},\n+    ) as mock_run:\n+        result = json.loads(browser_type(\"@apikey\", secret, task_id=\"redaction-test\"))\n+\n+    assert result[\"success\"] is True\n+    assert secret not in json.dumps(result)\n+    assert result[\"typed\"].startswith(\"sk-pro\")\n+    # Raw secret still typed into the page.\n+    mock_run.assert_called_once()\n+    assert mock_run.call_args.args[2] == [\"@apikey\", secret]\n+\n+\n+def test_browser_type_keeps_normal_text_in_output(monkeypatch):\n+    monkeypatch.delenv(\"CAMOFOX_URL\", raising=False)\n+    monkeypatch.delenv(\"BROWSER_CDP_URL\", raising=False)\n+    monkeypatch.setenv(\"HERMES_REDACT_SECRETS\", \"true\")\n+    text = \"hello world search query\"\n+\n+    with patch(\n+        \"tools.browser_tool._run_browser_command\",\n+        return_value={\"success\": True},\n+    ) as mock_run:\n+        result = json.loads(browser_type(\"@search\", text, task_id=\"redaction-test\"))\n+\n+    assert result[\"success\"] is True\n+    assert result[\"typed\"] == text\n+    mock_run.assert_called_once()\n+    assert mock_run.call_args.args[2] == [\"@search\", text]\n+\n+\n+def test_browser_type_failure_redacts_api_key_in_error(monkeypatch):\n+    monkeypatch.delenv(\"CAMOFOX_URL\", raising=False)\n+    monkeypatch.delenv(\"BROWSER_CDP_URL\", raising=False)\n+    monkeypatch.setenv(\"HERMES_REDACT_SECRETS\", \"true\")\n+    secret = \"[redacted:secret-assignment]\"\n+\n+    with patch(\n+        \"tools.browser_tool._run_browser_command\",\n+        return_value={\n+            \"success\": False,\n+            \"error\": f\"backend failed while typing {secret}\",\n+            \"fallback_warning\": f\"chrome fallback also saw {secret}\",\n+        },\n+    ) as mock_run:\n+        raw_result = browser_type(\"@apikey\", secret, task_id=\"redaction-test\")\n+        result = json.loads(raw_result)\n+\n+    assert result[\"success\"] is False\n+    assert secret not in raw_result\n+    assert \"sk-pro\" in raw_result\n+    mock_run.assert_called_once()\n+    assert mock_run.call_args.args[2] == [\"@apikey\", secret]"]},{"file":"tools/browser_camofox.py","hunks":["@@ -562,13 +562,28 @@ def camofox_type(ref: str, text: str, task_id: Optional[str] = None) -> str:\n             f\"/tabs/{session['tab_id']}/type\",\n             {\"userId\": session[\"user_id\"], \"ref\": clean_ref, \"text\": text},\n         )\n-        return json.dumps({\n+        from agent.display import (\n+            redact_browser_typed_text_for_display,\n+            redact_tool_args_for_display,\n+        )\n+\n+        display_text = (redact_tool_args_for_display(\"browser_type\", {\"text\": text}) or {})[\"text\"]\n+\n+        response = {\n             \"success\": True,\n-            \"typed\": text,\n+            # Match browser_tool.browser_type: run typed text through the\n+            # secret-pattern redactor so API keys / tokens don't leak into\n+            # tool progress or chat history.  The raw text is still typed into\n+            # the page; only the returned display value is redacted.\n+            \"typed\": display_text,\n             \"element\": clean_ref,\n-        })\n+        }\n+        response = redact_browser_typed_text_for_display(response, text)\n+        return json.dumps(response)\n     except Exception as e:\n-        return tool_error(str(e), success=False)\n+        from agent.display import redact_browser_typed_text_for_display\n+\n+        return tool_error(redact_browser_typed_text_for_display(str(e), text), success=False)\n \n \n def camofox_scroll(direction: str, task_id: Optional[str] = None) -> str:"]},{"file":"tools/browser_tool.py","hunks":["@@ -2758,19 +2758,34 @@ def browser_type(ref: str, text: str, task_id: Optional[str] = None) -> str:\n     # Use fill command (clears then types)\n     result = _run_browser_command(effective_task_id, \"fill\", [ref, text])\n \n+    from agent.display import (\n+        redact_browser_typed_text_for_display,\n+        redact_tool_args_for_display,\n+    )\n+\n+    display_text = (redact_tool_args_for_display(\"browser_type\", {\"text\": text}) or {})[\"text\"]\n+\n     if result.get(\"success\"):\n         response = {\n             \"success\": True,\n-            \"typed\": text,\n+            # Run typed text through the secret-pattern redactor so API keys /\n+            # tokens don't leak into tool progress or chat history.  Normal\n+            # text passes through unchanged.  The raw value was already sent\n+            # to the browser command above.\n+            \"typed\": display_text,\n             \"element\": ref\n         }\n-        return json.dumps(_copy_fallback_warning(response, result), ensure_ascii=False)\n+        response = _copy_fallback_warning(response, result)\n+        response = redact_browser_typed_text_for_display(response, text)\n+        return json.dumps(response, ensure_ascii=False)\n     else:\n         response = {\n             \"success\": False,\n             \"error\": result.get(\"error\", f\"Failed to type into {ref}\")\n         }\n-        return json.dumps(_copy_fallback_warning(response, result), ensure_ascii=False)\n+        response = _copy_fallback_warning(response, result)\n+        response = redact_browser_typed_text_for_display(response, text)\n+        return json.dumps(response, ensure_ascii=False)\n \n \n def browser_scroll(direction: str, task_id: Optional[str] = None) -> str:\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}