Redacting credentials leaked by browser_type tool

NousResearch/hermes-agent · cluster 10520 · agent

Data as of

Nothing open on GitHub. Fix merged in #52850. Check it covers your case.

The short answer

0open issues
0open PRs
5items grouped
Known?
Yes. 5 related items grouped here, 2 issues already closed.
Fix event
A fix was merged to main in PR #52850. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #52850. That is separate from the fix event.
Still open
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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #52850 fix(browser): redact recognized secrets in browser_type display surfaces

    merged to main

    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

    Show the diff
    • agent/display.py
      @@ -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
      @@ -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, []
      @@ -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",
      @@ -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
      @@ -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
      @@ -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}")
      @@ -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}")
       
      @@ -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}")
       
      @@ -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}")
       
      @@ -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}")
      @@ -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}")
       
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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,
      @@ -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"})
      @@ -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
      @@ -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")
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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:

2 PRs closed without merging

From the close comments: merged via another change 1 · other 1

  • #10661 fix(display): hide browser_type text in tool previews · other → #72298, #72432
  • #47308 fix: redact browser typed text surfaces · merged via another change → 8ff426e, #52850
Closed issues · 2
  • Closed#10520[Bug]:
  • Closed#47197Security: browser_type leaks credentials in tool output
How this cluster was grouped
  • Link evidence (partly structural links): 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 item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#52850
Closed issues
2
Attempts
2 PRs closed without merging
Fix link checked
26 Jun 2026

For your agent

The same facts, typed.