# NousResearch/hermes-agent · Redacting credentials leaked by browser_type tool

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 5 member(s): 0 open issue(s), 2 closed issue(s), 0 open PR(s), 1 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #52850. Verify it addresses your case.
- **Recommended:** read PR #52850 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-52850`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.34`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #10520: \[Bug\]:
- #47197: Security: browser_type leaks credentials in tool output

## Prior & competing PRs

- **#52850: fix(browser): redact recognized secrets in browser_type display surfaces**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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:
  - `agent/display.py`

    ```diff
    @@ -16,6 +16,7 @@
     from typing import Any
     
     from utils import safe_json_loads
    +from agent.redact import redact_sensitive_text
     from agent.tool_result_classification import file_mutation_result_landed
     
     # ANSI escape codes for coloring tool failure indicators
    ```

    ```diff
    @@ -339,6 +340,62 @@ def _read_file_line_label(args: dict) -> str:
         return f"L{offset}-{offset + limit - 1}"
     
     
    +def redact_browser_typed_text_for_display(value: Any, typed_text: Any) -> Any:
    +    """Apply secret redaction to browser_type text in display-facing payloads.
    +
    +    Backends sometimes echo the attempted input in error strings or fallback
    +    metadata.  When the raw typed value contains a recognizable secret (API
    +    key, token, JWT, etc.) the redacted form differs from the raw value, so we
    +    replace every occurrence of the raw value with its redacted form before a
    +    browser_type result reaches logs, callbacks, the model, or chat history.
    +
    +    Normal typed text (search queries, addresses, form fields) matches no
    +    secret pattern, so it passes through unchanged and stays readable.
    +
    +    Redaction is forced here regardless of the global ``security.redact_secrets``
    +    preference: a typed credential leaking into chat history is a security
    +    boundary, not mere log hygiene.
    +    """
    +    if typed_text is None:
    +        return value
    +    needle = str(typed_text)
    +    if needle == "":
    +        return value
    +    redacted = redact_sensitive_text(needle, force=True)
    +    if redacted == needle:
    +        # Nothing secret-looking in the typed text; leave payload untouched.
    +        return value
    +    if isinstance(value, str):
    +        return value.replace(needle, redacted)
    +    if isinstance(value, dict):
    +        return {
    +            key: redact_browser_typed_text_for_display(item, typed_text)
    +            for key, item in value.items()
    +        }
    +    if isinstance(value, list):
    +        return [redact_browser_typed_text_for_display(item, typed_text) for item in value]
    +    if isinstance(value, tuple):
    +        return tuple(redact_browser_typed_text_for_display(item, typed_text) for item in value)
    +    return value
    +
    +
    +def redact_tool_args_for_display(tool_name: str, args: dict | None) -> dict | None:
    +    """Return a copy of tool args safe for logs/progress UI.
    +
    +    For ``browser_type`` the ``text`` argument is run through the same
    +    secret-pattern redactor used for logs.  Recognizable credentials (API
    +    keys, tokens) are masked before the value reaches tool progress
    +    notifications; normal typed text is left intact for debuggability.
    +    """
    +    if not isinstance(args, dict):
    +        return args
    +    if tool_name == "browser_type" and isinstance(args.get("text"), str):
    +        safe_args = dict(args)
    +        safe_args["text"] = redact_sensitive_text(args["text"], force=True)
    +        return safe_args
    +    return args
    +
    +
     def _delegate_task_goal_parts(tasks: Any, *, per_goal_len: int) -> tuple[int, list[str]]:
         if not isinstance(tasks, list):
             return 0, []
    ```

    ```diff
    @@ -362,6 +419,7 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -
             max_len = _tool_preview_max_len
         if not args:
             return None
    +    args = redact_tool_args_for_display(tool_name, args) or args
         primary_args = {
             "terminal": "command", "web_search": "query", "web_extract": "urls",
             "read_file": "path", "write_file": "path", "patch": "path",
    ```

    ```diff
    @@ -1085,6 +1143,7 @@ def get_cute_tool_message(
         When *result* is provided the line is checked for failure indicators.
         Failed tool calls get a red prefix and an informational suffix.
         """
    +    args = redact_tool_args_for_display(tool_name, args) or args
         dur = f"{duration:.1f}s"
         is_failure, failure_suffix = _detect_tool_failure(tool_name, result)
         skin_prefix = get_skin_tool_prefix()
    ```
  - `agent/tool_executor.py`

    ```diff
    @@ -26,6 +26,7 @@
         build_tool_preview as _build_tool_preview,
         get_cute_tool_message as _get_cute_tool_message_impl,
         get_tool_emoji as _get_tool_emoji,
    +    redact_tool_args_for_display as _redact_tool_args_for_display,
         _detect_tool_failure,
     )
     from agent.tool_guardrails import ToolGuardrailDecision
    ```

    ```diff
    @@ -469,10 +470,11 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
         if not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off":
             print(f"  ⚡ Concurrent: {num_tools} tool calls — {tool_names_str}")
             for i, (tc, name, args, middleware_trace, block_result, blocked_by_guardrail) in enumerate(parsed_calls, 1):
    -            args_str = json.dumps(args, ensure_ascii=False)
    +            display_args = _redact_tool_args_for_display(name, args) or args
    +            args_str = json.dumps(display_args, ensure_ascii=False)
                 if agent.verbose_logging:
    -                print(f"  📞 Tool {i}: {name}({list(args.keys())})")
    -                print(agent._wrap_verbose("Args: ", json.dumps(args, indent=2, ensure_ascii=False)))
    +                print(f"  📞 Tool {i}: {name}({list(display_args.keys())})")
    +                print(agent._wrap_verbose("Args: ", json.dumps(display_args, indent=2, ensure_ascii=False)))
                 else:
                     args_preview = args_str[:agent.log_prefix_chars] + "..." if len(args_str) > agent.log_prefix_chars else args_str
                     print(f"  📞 Tool {i}: {name}({list(args.keys())}) - {args_preview}")
    ```

    ```diff
    @@ -482,8 +484,9 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
                 continue
             if agent.tool_progress_callback:
                 try:
    -                preview = _build_tool_preview(name, args)
    -                agent.tool_progress_callback("tool.started", name, preview, args)
    +                display_args = _redact_tool_args_for_display(name, args) or args
    +                preview = _build_tool_preview(name, display_args)
    +                agent.tool_progress_callback("tool.started", name, preview, display_args)
                 except Exception as cb_err:
                     logging.debug(f"Tool progress callback error: {cb_err}")
     
    ```

    ```diff
    @@ -492,7 +495,8 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
                 continue
             if agent.tool_start_callback:
                 try:
    -                agent.tool_start_callback(tc.id, name, args)
    +                display_args = _redact_tool_args_for_display(name, args) or args
    +                agent.tool_start_callback(tc.id, name, display_args)
                 except Exception as cb_err:
                     logging.debug(f"Tool start callback error: {cb_err}")
     
    ```

    ```diff
    @@ -792,7 +796,8 @@ def _run_tool(index, tool_call, function_name, function_args, middleware_trace):
     
             if not blocked and agent.tool_complete_callback:
                 try:
    -                agent.tool_complete_callback(tc.id, name, args, function_result)
    +                display_args = _redact_tool_args_for_display(name, args) or args
    +                agent.tool_complete_callback(tc.id, name, display_args, function_result)
                 except Exception as cb_err:
                     logging.debug(f"Tool complete callback error: {cb_err}")
     
    ```

    ```diff
    @@ -954,10 +959,11 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
                 agent._iters_since_skill = 0
     
             if not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off":
    -            args_str = json.dumps(function_args, ensure_ascii=False)
    +            display_args = _redact_tool_args_for_display(function_name, function_args) or function_args
    +            args_str = json.dumps(display_args, ensure_ascii=False)
                 if agent.verbose_logging:
    -                print(f"  📞 Tool {i}: {function_name}({list(function_args.keys())})")
    -                print(agent._wrap_verbose("Args: ", json.dumps(function_args, indent=2, ensure_ascii=False)))
    +                print(f"  📞 Tool {i}: {function_name}({list(display_args.keys())})")
    +                print(agent._wrap_verbose("Args: ", json.dumps(display_args, indent=2, ensure_ascii=False)))
                 else:
                     args_preview = args_str[:agent.log_prefix_chars] + "..." if len(args_str) > agent.log_prefix_chars else args_str
                     print(f"  📞 Tool {i}: {function_name}({list(function_args.keys())}) - {args_preview}")
    ```

    ```diff
    @@ -978,14 +984,16 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
     
             if not _execution_blocked and agent.tool_progress_callback:
                 try:
    -                preview = _build_tool_preview(function_name, function_args)
    -                agent.tool_progress_callback("tool.started", function_name, preview, function_args)
    +                display_args = _redact_tool_args_for_display(function_name, function_args) or function_args
    +                preview = _build_tool_preview(function_name, display_args)
    +                agent.tool_progress_callback("tool.started", function_name, preview, display_args)
                 except Exception as cb_err:
                     logging.debug(f"Tool progress callback error: {cb_err}")
     
             if not _execution_blocked and agent.tool_start_callback:
                 try:
    -                agent.tool_start_callback(tool_call.id, function_name, function_args)
    +                display_args = _redact_tool_args_for_display(function_name, function_args) or function_args
    +                agent.tool_start_callback(tool_call.id, function_name, display_args)
                 except Exception as cb_err:
                     logging.debug(f"Tool start callback error: {cb_err}")
     
    ```

    ```diff
    @@ -1215,7 +1223,8 @@ def _execute(next_args: dict) -> Any:
                 if agent._should_emit_quiet_tool_messages():
                     face = random.choice(KawaiiSpinner.get_waiting_faces())
                     emoji = _get_tool_emoji(function_name)
    -                preview = _build_tool_preview(function_name, function_args) or function_name
    +                display_args = _redact_tool_args_for_display(function_name, function_args) or function_args
    +                preview = _build_tool_preview(function_name, display_args) or function_name
                     spinner = KawaiiSpinner(f"{face} {emoji} {preview}", spinner_type='dots', print_fn=agent._print_fn)
                     spinner.start()
                 _ce_result = None
    ```

    ```diff
    @@ -1248,7 +1257,8 @@ def _execute(next_args: dict) -> Any:
                 if agent._should_emit_quiet_tool_messages() and agent._should_start_quiet_spinner():
                     face = random.choice(KawaiiSpinner.get_waiting_faces())
                     emoji = _get_tool_emoji(function_name)
    -                preview = _build_tool_preview(function_name, function_args) or function_name
    +                display_args = _redact_tool_args_for_display(function_name, function_args) or function_args
    +                preview = _build_tool_preview(function_name, display_args) or function_name
                     spinner = KawaiiSpinner(f"{face} {emoji} {preview}", spinner_type='dots', print_fn=agent._print_fn)
                     spinner.start()
                 _mem_result = None
    ```

    ```diff
    @@ -1279,7 +1289,8 @@ def _execute(next_args: dict) -> Any:
                 if agent._should_emit_quiet_tool_messages() and agent._should_start_quiet_spinner():
                     face = random.choice(KawaiiSpinner.get_waiting_faces())
                     emoji = _get_tool_emoji(function_name)
    -                preview = _build_tool_preview(function_name, function_args) or function_name
    +                display_args = _redact_tool_args_for_display(function_name, function_args) or function_args
    +                preview = _build_tool_preview(function_name, display_args) or function_name
                     spinner = KawaiiSpinner(f"{face} {emoji} {preview}", spinner_type='dots', print_fn=agent._print_fn)
                     spinner.start()
                 _spinner_result = None
    ```

    ```diff
    @@ -1441,7 +1452,8 @@ def _execute(next_args: dict) -> Any:
     
             if not _execution_blocked and agent.tool_complete_callback:
                 try:
    -                agent.tool_complete_callback(tool_call.id, function_name, function_args, function_result)
    +                display_args = _redact_tool_args_for_display(function_name, function_args) or function_args
    +                agent.tool_complete_callback(tool_call.id, function_name, display_args, function_result)
                 except Exception as cb_err:
                     logging.debug(f"Tool complete callback error: {cb_err}")
     
    ```
  - `scripts/release.py`

    ```diff
    @@ -45,6 +45,7 @@
     
     # Auto-extracted from noreply emails + manual overrides
     AUTHOR_MAP = {
    +    "[redacted:email]": "rebel0789",  # PR #47308 salvage (redact browser_type typed text across display surfaces; #47197)
         "[redacted:email]": "agt-user",  # PR #48496 salvage (telegram CLOSE-WAIT polling heartbeat, #48495)
         "[redacted:email]": "DavidMetcalfe",  # PR #52272 salvage (route reasoning-model thinking-timeouts to timeout not context_overflow + reasoning-specific guidance; #52271)
         "[redacted:email]": "herbalizer404",  # PR #49076 + #51835 salvage (auxiliary compression fallback: 403/session-usage payment errors + honor fallback chain when aux provider auth unavailable)
    ```
  - `tests/agent/test_display.py`

    ```diff
    @@ -9,6 +9,7 @@
         capture_local_edit_snapshot,
         extract_edit_diff,
         get_cute_tool_message,
    +    redact_tool_args_for_display,
         set_tool_preview_max_len,
         _render_inline_unified_diff,
         _summarize_rendered_diff_sections,
    ```

    ```diff
    @@ -86,6 +87,35 @@ def test_read_file_preview_includes_requested_line_range(self):
             result = build_tool_preview("read_file", {"path": "./package.json", "offset": 1, "limit": 5})
             assert result == "package.json L1-5"
     
    +    def test_browser_type_preview_redacts_api_key(self):
    +        secret = "[redacted:secret-assignment]"
    +        result = build_tool_preview("browser_type", {"ref": "@e3", "text": secret})
    +        assert result is not None
    +        assert secret not in result
    +        assert "sk-pro" in result and "..." in result
    +
    +    def test_browser_type_preview_keeps_normal_text(self):
    +        text = "hello world search query"
    +        result = build_tool_preview("browser_type", {"ref": "@e3", "text": text})
    +        assert result is not None
    +        assert text in result
    +
    +    def test_browser_type_display_args_redact_api_key(self):
    +        secret = "[redacted:secret-assignment]"
    +        safe_args = redact_tool_args_for_display(
    +            "browser_type", {"ref": "@e3", "text": secret}
    +        )
    +        assert secret not in str(safe_args)
    +        assert safe_args["ref"] == "@e3"
    +        assert safe_args["text"].startswith("ghp_AB")
    +
    +    def test_browser_type_display_args_keep_normal_text(self):
    +        text = "my_normal_password_123"
    +        safe_args = redact_tool_args_for_display(
    +            "browser_type", {"ref": "@e3", "text": text}
    +        )
    +        assert safe_args == {"ref": "@e3", "text": text}
    +
         def test_unknown_tool_with_fallback_key(self):
             """Unknown tool but with a recognized fallback key should still preview."""
             result = build_tool_preview("custom_tool", {"query": "test query"})
    ```

    ```diff
    @@ -242,6 +272,29 @@ def test_delegate_task_batch_message_includes_goals(self):
             )
             assert "2x: Review PR A | Review PR B" in line
     
    +    def test_browser_type_cute_message_redacts_api_key(self):
    +        secret = "[redacted:secret-assignment]"
    +        line = get_cute_tool_message(
    +            "browser_type",
    +            {"ref": "@password", "text": secret},
    +            0.1,
    +            result='{"success": true, "typed": "sk-pro...EFGH"}',
    +        )
    +
    +        assert secret not in line
    +        assert "sk-pro" in line
    +
    +    def test_browser_type_cute_message_keeps_normal_text(self):
    +        text = "hello world"
    +        line = get_cute_tool_message(
    +            "browser_type",
    +            {"ref": "@search", "text": text},
    +            0.1,
    +            result='{"success": true, "typed": "hello world"}',
    +        )
    +
    +        assert text in line
    +
     
     class TestEditDiffPreview:
         def test_extract_edit_diff_for_patch(self):
    ```
  - `tests/run_agent/test_run_agent.py`

    ```diff
    @@ -2589,6 +2589,30 @@ def test_sequential_tool_callbacks_fire_in_order(self, agent):
             assert starts == [("c1", "web_search", {"query": "hello"})]
             assert completes == [("c1", "web_search", {"query": "hello"}, '{"success": true}')]
     
    +    def test_sequential_browser_type_callbacks_redact_api_key(self, agent):
    +        secret = "[redacted:secret-assignment]"
    +        tool_call = _mock_tool_call(
    +            name="browser_type",
    +            arguments=json.dumps({"ref": "@apikey", "text": secret}),
    +            call_id="c-secret",
    +        )
    +        mock_msg = _mock_assistant_msg(content="", tool_calls=[tool_call])
    +        messages = []
    +        starts = []
    +        completes = []
    +        progress = []
    +        agent.tool_start_callback = lambda tool_call_id, function_name, function_args: starts.append((tool_call_id, function_name, function_args))
    +        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))
    +        agent.tool_progress_callback = lambda event, name, preview, args, **kw: progress.append((event, name, preview, args))
    +
    +        with patch("run_agent.handle_function_call", return_value='{"success": true, "typed": "sk-pro...EFGH"}'):
    +            agent._execute_tool_calls_sequential(mock_msg, messages, "task-1")
    +
    +        assert starts[0][2]["text"].startswith("sk-pro")
    +        assert completes[0][2]["text"].startswith("sk-pro")
    +        assert progress[0][2].startswith("sk-pro")
    +        assert secret not in repr(starts + completes + progress)
    +
         def test_concurrent_tool_callbacks_fire_for_each_tool(self, agent):
             tc1 = _mock_tool_call(name="web_search", arguments='{"query":"one"}', call_id="c1")
             tc2 = _mock_tool_call(name="web_search", arguments='{"query":"two"}', call_id="c2")
    ```

    ```diff
    @@ -2610,6 +2634,30 @@ def test_concurrent_tool_callbacks_fire_for_each_tool(self, agent):
             assert {entry[0] for entry in completes} == {"c1", "c2"}
             assert {entry[3] for entry in completes} == {'{"id":1}', '{"id":2}'}
     
    +    def test_concurrent_browser_type_callbacks_redact_api_key(self, agent):
    +        secret = "[redacted:secret-assignment]"
    +        tc = _mock_tool_call(
    +            name="browser_type",
    +            arguments=json.dumps({"ref": "@apikey", "text": secret}),
    +            call_id="c-secret",
    +        )
    +        mock_msg = _mock_assistant_msg(content="", tool_calls=[tc])
    +        messages = []
    +        starts = []
    +        completes = []
    +        progress = []
    +        agent.tool_start_callback = lambda tool_call_id, function_name, function_args: starts.append((tool_call_id, function_name, function_args))
    +        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))
    +        agent.tool_progress_callback = lambda event, name, preview, args, **kw: progress.append((event, name, preview, args))
    +
    +        with patch("run_agent.handle_function_call", return_value='{"success": true, "typed": "sk-pro...EFGH"}'):
    +            agent._execute_tool_calls_concurrent(mock_msg, messages, "task-1")
    +
    +        assert starts[0][2]["text"].startswith("sk-pro")
    +        assert completes[0][2]["text"].startswith("sk-pro")
    +        assert progress[0][2].startswith("sk-pro")
    +        assert secret not in repr(starts + completes + progress)
    +
         def test_invoke_tool_handles_agent_level_tools(self, agent):
             """_invoke_tool should handle todo tool directly."""
             with patch("tools.todo_tool.todo_tool", return_value='{"ok":true}') as mock_todo:
    ```
  - `tests/tools/test_browser_camofox.py`

    ```diff
    @@ -235,8 +235,39 @@ def test_type(self, mock_post, monkeypatch):
             mock_post.return_value = _mock_response(json_data={"ok": True})
             result = json.loads(camofox_type("@e3", "hello world", task_id="t5"))
             assert result["success"] is True
    +        # Normal text is left readable.
             assert result["typed"] == "hello world"
     
    +    @patch("tools.browser_camofox.requests.post")
    +    def test_type_redacts_api_key(self, mock_post, monkeypatch):
    +        monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
    +        monkeypatch.setenv("HERMES_REDACT_SECRETS", "true")
    +        mock_post.return_value = _mock_response(json_data={"tabId": "tab5b", "url": "https://x.com"})
    +        camofox_navigate("https://x.com", task_id="t5b")
    +
    +        secret = "[redacted:secret-assignment]"
    +        mock_post.return_value = _mock_response(json_data={"ok": True})
    +        result = json.loads(camofox_type("@apikey", secret, task_id="t5b"))
    +        assert result["success"] is True
    +        assert secret not in json.dumps(result)
    +        assert result["typed"].startswith("sk-pro")
    +
    +    @patch("tools.browser_camofox.requests.post")
    +    def test_type_failure_redacts_api_key(self, mock_post, monkeypatch):
    +        monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
    +        monkeypatch.setenv("HERMES_REDACT_SECRETS", "true")
    +        mock_post.return_value = _mock_response(json_data={"tabId": "tab5c", "url": "https://x.com"})
    +        camofox_navigate("https://x.com", task_id="t5c")
    +
    +        secret = "[redacted:secret-assignment]"
    +        mock_post.side_effect = RuntimeError(f"camofox failed while typing {secret}")
    +        raw_result = camofox_type("@apikey", secret, task_id="t5c")
    +        result = json.loads(raw_result)
    +
    +        assert result["success"] is False
    +        assert secret not in raw_result
    +        assert "sk-pro" in raw_result
    +
         @patch("tools.browser_camofox.requests.post")
         def test_scroll(self, mock_post, monkeypatch):
             monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
    ```
  - `tests/tools/test_browser_type_redaction.py`

    ```diff
    @@ -0,0 +1,74 @@
    +"""Regression tests for browser_type display redaction.
    +
    +Typed text is passed through the same secret-pattern redactor used for logs:
    +recognizable credentials (API keys, tokens) are masked in display-facing
    +output, while normal typed text is left intact.  The raw value is always sent
    +to the browser backend regardless.
    +"""
    +
    +import json
    +from unittest.mock import patch
    +
    +from tools.browser_tool import browser_type
    +
    +
    +def test_browser_type_redacts_api_key_in_output(monkeypatch):
    +    monkeypatch.delenv("CAMOFOX_URL", raising=False)
    +    monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
    +    monkeypatch.setenv("HERMES_REDACT_SECRETS", "true")
    +    secret = "[redacted:secret-assignment]"
    +
    +    with patch(
    +        "tools.browser_tool._run_browser_command",
    +        return_value={"success": True},
    +    ) as mock_run:
    +        result = json.loads(browser_type("@apikey", secret, task_id="redaction-test"))
    +
    +    assert result["success"] is True
    +    assert secret not in json.dumps(result)
    +    assert result["typed"].startswith("sk-pro")
    +    # Raw secret still typed into the page.
    +    mock_run.assert_called_once()
    +    assert mock_run.call_args.args[2] == ["@apikey", secret]
    +
    +
    +def test_browser_type_keeps_normal_text_in_output(monkeypatch):
    +    monkeypatch.delenv("CAMOFOX_URL", raising=False)
    +    monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
    +    monkeypatch.setenv("HERMES_REDACT_SECRETS", "true")
    +    text = "hello world search query"
    +
    +    with patch(
    +        "tools.browser_tool._run_browser_command",
    +        return_value={"success": True},
    +    ) as mock_run:
    +        result = json.loads(browser_type("@search", text, task_id="redaction-test"))
    +
    +    assert result["success"] is True
    +    assert result["typed"] == text
    +    mock_run.assert_called_once()
    +    assert mock_run.call_args.args[2] == ["@search", text]
    +
    +
    +def test_browser_type_failure_redacts_api_key_in_error(monkeypatch):
    +    monkeypatch.delenv("CAMOFOX_URL", raising=False)
    +    monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
    +    monkeypatch.setenv("HERMES_REDACT_SECRETS", "true")
    +    secret = "[redacted:secret-assignment]"
    +
    +    with patch(
    +        "tools.browser_tool._run_browser_command",
    +        return_value={
    +            "success": False,
    +            "error": f"backend failed while typing {secret}",
    +            "fallback_warning": f"chrome fallback also saw {secret}",
    +        },
    +    ) as mock_run:
    +        raw_result = browser_type("@apikey", secret, task_id="redaction-test")
    +        result = json.loads(raw_result)
    +
    +    assert result["success"] is False
    +    assert secret not in raw_result
    +    assert "sk-pro" in raw_result
    +    mock_run.assert_called_once()
    +    assert mock_run.call_args.args[2] == ["@apikey", secret]
    ```
  - `tools/browser_camofox.py`

    ```diff
    @@ -562,13 +562,28 @@ def camofox_type(ref: str, text: str, task_id: Optional[str] = None) -> str:
                 f"/tabs/{session['tab_id']}/type",
                 {"userId": session["user_id"], "ref": clean_ref, "text": text},
             )
    -        return json.dumps({
    +        from agent.display import (
    +            redact_browser_typed_text_for_display,
    +            redact_tool_args_for_display,
    +        )
    +
    +        display_text = (redact_tool_args_for_display("browser_type", {"text": text}) or {})["text"]
    +
    +        response = {
                 "success": True,
    -            "typed": text,
    +            # Match browser_tool.browser_type: run typed text through the
    +            # secret-pattern redactor so API keys / tokens don't leak into
    +            # tool progress or chat history.  The raw text is still typed into
    +            # the page; only the returned display value is redacted.
    +            "typed": display_text,
                 "element": clean_ref,
    -        })
    +        }
    +        response = redact_browser_typed_text_for_display(response, text)
    +        return json.dumps(response)
         except Exception as e:
    -        return tool_error(str(e), success=False)
    +        from agent.display import redact_browser_typed_text_for_display
    +
    +        return tool_error(redact_browser_typed_text_for_display(str(e), text), success=False)
     
     
     def camofox_scroll(direction: str, task_id: Optional[str] = None) -> str:
    ```
  - `tools/browser_tool.py`

    ```diff
    @@ -2758,19 +2758,34 @@ def browser_type(ref: str, text: str, task_id: Optional[str] = None) -> str:
         # Use fill command (clears then types)
         result = _run_browser_command(effective_task_id, "fill", [ref, text])
     
    +    from agent.display import (
    +        redact_browser_typed_text_for_display,
    +        redact_tool_args_for_display,
    +    )
    +
    +    display_text = (redact_tool_args_for_display("browser_type", {"text": text}) or {})["text"]
    +
         if result.get("success"):
             response = {
                 "success": True,
    -            "typed": text,
    +            # Run typed text through the secret-pattern redactor so API keys /
    +            # tokens don't leak into tool progress or chat history.  Normal
    +            # text passes through unchanged.  The raw value was already sent
    +            # to the browser command above.
    +            "typed": display_text,
                 "element": ref
             }
    -        return json.dumps(_copy_fallback_warning(response, result), ensure_ascii=False)
    +        response = _copy_fallback_warning(response, result)
    +        response = redact_browser_typed_text_for_display(response, text)
    +        return json.dumps(response, ensure_ascii=False)
         else:
             response = {
                 "success": False,
                 "error": result.get("error", f"Failed to type into {ref}")
             }
    -        return json.dumps(_copy_fallback_warning(response, result), ensure_ascii=False)
    +        response = _copy_fallback_warning(response, result)
    +        response = redact_browser_typed_text_for_display(response, text)
    +        return json.dumps(response, ensure_ascii=False)
     
     
     def browser_scroll(direction: str, task_id: Optional[str] = None) -> str:
    ```
- **#10661: fix(display): hide browser_type text in tool previews**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#72298`, `#72432`
- **#47308: fix: redact browser typed text surfaces**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `8ff426e53`, `#52850`

## Fix status

- Merged to main in PR #52850 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-26`.
- Subsystem: `agent`
