{"repo":"NousResearch/hermes-agent","public_id":"8875","display_label":"Browser security hardening: SSRF and JS execution guards","summary":"Multiple issues and fixes targeting SSRF guard bypasses via eval/navigation, unsafe JS expression execution in browser_console, and extending private-network policy enforcement across browser tool surfaces.","subsystem":"agent","counts":{"members":7,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":54132},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-28T09:42:02+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.25523809523809526},"fix_edges":[{"issue_number":44731,"pr_number":54132,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":8875,"title":"[Bug]: [Security]Arbitrary Browser JavaScript Execution via `browser_console` Tool","url":null,"state":"closed"},{"kind":"issue","number":44731,"title":"[Security] Hermes browser private-network policy bypass via eval-triggered main-frame navigation","url":null,"state":"closed"},{"kind":"pr","number":9120,"title":"fix(security): validate browser_console JS expressions before execution","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":44755,"title":"fix(browser): block snapshot from eval-navigated private pages","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#45133","number":45133},{"kind":"pr","value":"#54132","number":54132}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":45101,"title":"fix(browser): extend private-network guard to browser_vision","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#45133","number":45133},{"kind":"pr","value":"#54132","number":54132}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":45133,"title":"fix(browser): allow local sidecar sessions to bypass SSRF guard","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"7a6fe9bbf","number":0},{"kind":"commit","value":"48f5c4259","number":0},{"kind":"commit","value":"0ae619608","number":0},{"kind":"pr","value":"#54132","number":54132}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":54132,"title":"fix(browser): seal eval-path SSRF bypass + land snapshot/vision guards (#44731)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tools/test_browser_eval_ssrf.py","tests/tools/test_browser_snapshot_ssrf.py","tools/browser_tool.py"],"fix_hunks":[{"file":"tests/tools/test_browser_eval_ssrf.py","hunks":["@@ -0,0 +1,229 @@\n+\"\"\"Tests that browser_console(expression=...) cannot bypass the SSRF guard.\n+\n+browser_snapshot / browser_vision re-check the page URL before returning\n+content, but ``_browser_eval`` returns arbitrary JS results directly. Two\n+sub-paths could read private content without ever touching snapshot/vision:\n+\n+  1. Direct fetch:  ``fetch('http://127.0.0.1/secret').then(r => r.text())``\n+     — the page URL stays public, so the post-eval recheck can't see it.\n+     Closed by a pre-scan of the expression for private-host URL literals.\n+  2. Navigate-then-read:  ``location.href = 'http://127.0.0.1/'`` then a later\n+     eval reads ``document.body.innerText`` — closed by re-checking the page\n+     URL after the eval runs.\n+\n+This is the sibling fix for the eval return-value path of issue #44731.\n+\"\"\"\n+\n+import json\n+\n+import pytest\n+\n+from tools import browser_tool\n+\n+\n+PRIVATE_URL = \"http://127.0.0.1:8080/secret\"\n+PUBLIC_URL = \"https://example.com/page\"\n+METADATA_URL = \"http://169.254.169.254/latest/meta-data/\"\n+\n+\n[redacted:email](autouse=True)\n+def _no_camofox(monkeypatch):\n+    monkeypatch.setattr(browser_tool, \"_is_camofox_mode\", lambda: False)\n+    # No supervisor — force the subprocess fallback path by default.\n+    monkeypatch.setattr(browser_tool, \"_last_session_key\", lambda key: key)\n+\n+\n+def _eval(expression, task_id=\"test\"):\n+    return json.loads(browser_tool._browser_eval(expression, task_id=task_id))\n+\n+\n+# ---------------------------------------------------------------------------\n+# Sub-path 1: direct private-host fetch literal in the expression (pre-scan)\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestExpressionPreScan:\n+    def _guard_on(self, monkeypatch):\n+        monkeypatch.setattr(browser_tool, \"_is_local_backend\", lambda: False)\n+        monkeypatch.setattr(browser_tool, \"_is_local_sidecar_key\", lambda key: False)\n+        monkeypatch.setattr(browser_tool, \"_allow_private_urls\", lambda: False)\n+\n+    def test_blocks_private_fetch_literal(self, monkeypatch):\n+        self._guard_on(monkeypatch)\n+        monkeypatch.setattr(browser_tool, \"_is_safe_url\", lambda url: False)\n+        monkeypatch.setattr(browser_tool, \"_is_always_blocked_url\", lambda url: False)\n+\n+        called = {\"n\": 0}\n+\n+        def _run(task_id, command, args=None, **kwargs):\n+            called[\"n\"] += 1\n+            return {\"success\": True, \"data\": {\"result\": \"leaked-content\"}}\n+\n+        monkeypatch.setattr(browser_tool, \"_run_browser_command\", _run)\n+\n+        result = _eval(f\"fetch('{PRIVATE_URL}').then(r => r.text())\")\n+        assert result[\"success\"] is False\n+        assert \"private or internal address\" in result[\"error\"]\n+        assert PRIVATE_URL in result[\"error\"]\n+        # Expression never executed — blocked before any browser command.\n+        assert called[\"n\"] == 0\n+\n+    def test_blocks_metadata_fetch_literal(self, monkeypatch):\n+        self._guard_on(monkeypatch)\n+        # Public-safe to is_safe_url, but the always-blocked floor catches IMDS.\n+        monkeypatch.setattr(browser_tool, \"_is_safe_url\", lambda url: True)\n+        monkeypatch.setattr(\n+            browser_tool, \"_is_always_blocked_url\",\n+            lambda url: \"169.254.169.254\" in url,\n+        )\n+        monkeypatch.setattr(\n+            browser_tool, \"_run_browser_command\",\n+            lambda *a, **k: {\"success\": True, \"data\": {\"result\": \"creds\"}},\n+        )\n+\n+        result = _eval(f\"fetch('{METADATA_URL}')\")\n+        assert result[\"success\"] is False\n+        assert \"private or internal address\" in result[\"error\"]\n+\n+    def test_allows_public_fetch_literal(self, monkeypatch):\n+        self._guard_on(monkeypatch)\n+        monkeypatch.setattr(browser_tool, \"_is_safe_url\", lambda url: True)\n+        monkeypatch.setattr(browser_tool, \"_is_always_blocked_url\", lambda url: False)\n+        # After the (public) eval, the page-URL recheck must also see a public URL.\n+        monkeypatch.setattr(\n+            browser_tool, \"_run_browser_command\",\n+            lambda task_id, command, args=None, **k: (\n+                {\"success\": True, \"data\": {\"result\": PUBLIC_URL}}\n+                if args == [\"window.location.href\"]\n+                else {\"success\": True, \"data\": {\"result\": \"ok\"}}\n+            ),\n+        )\n+\n+        result = _eval(f\"fetch('{PUBLIC_URL}').then(r => r.text())\")\n+        assert result[\"success\"] is True\n+        assert result[\"result\"] == \"ok\"\n+\n+    def test_skips_prescan_for_local_backend(self, monkeypatch):\n+        monkeypatch.setattr(browser_tool, \"_is_local_backend\", lambda: True)\n+        monkeypatch.setattr(\n+            browser_tool, \"_run_browser_command\",\n+            lambda *a, **k: {\"success\": True, \"data\": {\"result\": \"local-ok\"}},\n+        )\n+        result = _eval(f\"fetch('{PRIVATE_URL}')\")\n+        assert result[\"success\"] is True\n+        assert result[\"result\"] == \"local-ok\"\n+\n+    def test_skips_prescan_for_local_sidecar(self, monkeypatch):\n+        monkeypatch.setattr(browser_tool, \"_is_local_backend\", lambda: False)\n+        monkeypatch.setattr(browser_tool, \"_is_local_sidecar_key\", lambda key: True)\n+        monkeypatch.setattr(browser_tool, \"_allow_private_urls\", lambda: False)\n+        monkeypatch.setattr(\n+            browser_tool, \"_run_browser_command\",\n+            lambda *a, **k: {\"success\": True, \"data\": {\"result\": \"sidecar-ok\"}},\n+        )\n+        result = _eval(f\"fetch('{PRIVATE_URL}')\")\n+        assert result[\"success\"] is True\n+\n+    def test_skips_prescan_when_allow_private(self, monkeypatch):\n+        monkeypatch.setattr(browser_tool, \"_is_local_backend\", lambda: False)\n+        monkeypatch.setattr(browser_tool, \"_is_local_sidecar_key\", lambda key: False)\n+        monkeypatch.setattr(browser_tool, \"_allow_private_urls\", lambda: True)\n+        monkeypatch.setattr(\n+            browser_tool, \"_run_browser_command\",\n+            lambda *a, **k: {\"success\": True, \"data\": {\"result\": \"allowed\"}},\n+        )\n+        result = _eval(f\"fetch('{PRIVATE_URL}')\")\n+        assert result[\"success\"] is True\n+\n+\n+# ---------------------------------------------------------------------------\n+# Sub-path 2: navigate-then-read (post-eval page-URL recheck)\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestPostEvalPageRecheck:\n+    def _guard_on(self, monkeypatch):\n+        monkeypatch.setattr(browser_tool, \"_is_local_backend\", lambda: False)\n+        monkeypatch.setattr(browser_tool, \"_is_local_sidecar_key\", lambda key: False)\n+        monkeypatch.setattr(browser_tool, \"_allow_private_urls\", lambda: False)\n+\n+    def test_blocks_when_page_navigated_private(self, monkeypatch):\n+        self._guard_on(monkeypatch)\n+        # Expression itself has no URL literal (reads the DOM), so the pre-scan\n+        # passes; the danger is that the page was navigated to a private URL by\n+        # an earlier eval. The recheck must catch it.\n+        monkeypatch.setattr(browser_tool, \"_is_safe_url\", lambda url: False)\n+        monkeypatch.setattr(browser_tool, \"_is_always_blocked_url\", lambda url: False)\n+        monkeypatch.setattr(\n+            browser_tool, \"_run_browser_command\",\n+            lambda task_id, command, args=None, **k: (\n+                {\"success\": True, \"data\": {\"result\": PRIVATE_URL}}\n+                if args == [\"window.location.href\"]\n+                else {\"success\": True, \"data\": {\"result\": \"secret DOM text\"}}\n+            ),\n+        )\n+\n+        result = _eval(\"document.body.innerText\")\n+        assert result[\"success\"] is False\n+        assert \"private or internal address\" in result[\"error\"]\n+        assert PRIVATE_URL in result[\"error\"]\n+\n+    def test_allows_when_page_public(self, monkeypatch):\n+        self._guard_on(monkeypatch)\n+        monkeypatch.setattr(browser_tool, \"_is_safe_url\", lambda url: True)\n+        monkeypatch.setattr(browser_tool, \"_is_always_blocked_url\", lambda url: False)\n+        monkeypatch.setattr(\n+            browser_tool, \"_run_browser_command\",\n+            lambda task_id, command, args=None, **k: (\n+                {\"success\": True, \"data\": {\"result\": PUBLIC_URL}}\n+                if args == [\"window.location.href\"]\n+                else {\"success\": True, \"data\": {\"result\": \"public DOM text\"}}\n+            ),\n+        )\n+\n+        result = _eval(\"document.body.innerText\")\n+        assert result[\"success\"] is True\n+        assert result[\"result\"] == \"public DOM text\"\n+\n+    def test_fail_open_when_url_probe_fails(self, monkeypatch):\n+        \"\"\"If the window.location.href probe errors, don't block (fail-open).\"\"\"\n+        self._guard_on(monkeypatch)\n+        monkeypatch.setattr(browser_tool, \"_is_safe_url\", lambda url: False)\n+        monkeypatch.setattr(browser_tool, \"_is_always_blocked_url\", lambda url: False)\n+\n+        def _run(task_id, command, args=None, **k):\n+            if args == [\"window.location.href\"]:\n+                return {\"success\": False, \"error\": \"CDP probe failed\"}\n+            return {\"success\": True, \"data\": {\"result\": \"dom text\"}}\n+\n+        monkeypatch.setattr(browser_tool, \"_run_browser_command\", _run)\n+\n+        result = _eval(\"document.body.innerText\")\n+        assert result[\"success\"] is True\n+        assert result[\"result\"] == \"dom text\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# Helper-level unit tests\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestExpressionScanHelper:\n+    def test_returns_first_private_literal(self, monkeypatch):\n+        monkeypatch.setattr(browser_tool, \"_is_safe_url\", lambda url: \"127.0.0.1\" not in url)\n+        monkeypatch.setattr(browser_tool, \"_is_always_blocked_url\", lambda url: False)\n+        out = browser_tool._expression_targets_private_url(\n+            \"fetch('https://example.com'); fetch('http://127.0.0.1/x')\"\n+        )\n+        assert out == \"http://127.0.0.1/x\"\n+\n+    def test_none_when_no_url(self, monkeypatch):\n+        monkeypatch.setattr(browser_tool, \"_is_safe_url\", lambda url: True)\n+        monkeypatch.setattr(browser_tool, \"_is_always_blocked_url\", lambda url: False)\n+        assert browser_tool._expression_targets_private_url(\"document.title\") is None\n+\n+    def test_strips_trailing_punctuation(self, monkeypatch):\n+        monkeypatch.setattr(browser_tool, \"_is_safe_url\", lambda url: False)\n+        monkeypatch.setattr(browser_tool, \"_is_always_blocked_url\", lambda url: False)\n+        out = browser_tool._expression_targets_private_url(\"location.href='http://10.0.0.1/';\")\n+        assert out == \"http://10.0.0.1/\""]},{"file":"tests/tools/test_browser_snapshot_ssrf.py","hunks":["@@ -0,0 +1,438 @@\n+\"\"\"Tests that browser_snapshot blocks content from eval-navigated private pages.\n+\n+When browser_console() changes location.href to a private/internal address,\n+browser_snapshot() must detect this and return an error instead of exposing\n+the private page content.\n+\n+This is the fix for the SSRF bypass described in issue #44731.\n+\"\"\"\n+\n+import json\n+\n+import pytest\n+\n+from tools import browser_tool\n+\n+\n+def _make_snapshot_result(snapshot=\"Public page content\", refs=None):\n+    \"\"\"Return a mock successful snapshot result.\"\"\"\n+    return {\n+        \"success\": True,\n+        \"data\": {\n+            \"snapshot\": snapshot,\n+            \"refs\": refs or {\"@e1\": {\"role\": \"heading\", \"name\": \"Public\"}},\n+        },\n+    }\n+\n+\n+def _make_eval_result(result):\n+    \"\"\"Return a mock successful eval result.\"\"\"\n+    return {\"success\": True, \"data\": {\"result\": result}}\n+\n+\n+# ---------------------------------------------------------------------------\n+# browser_snapshot: private-network guard after eval navigation\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestBrowserSnapshotPrivateNetworkGuard:\n+    \"\"\"browser_snapshot must block content from private pages navigated via eval.\"\"\"\n+\n+    PRIVATE_URL = \"http://127.0.0.1:8080/secret\"\n+    PUBLIC_URL = \"https://example.com/page\"\n+\n+    @pytest.fixture(autouse=True)\n+    def _setup(self, monkeypatch):\n+        \"\"\"Common patches for snapshot SSRF tests.\"\"\"\n+        monkeypatch.setattr(browser_tool, \"_is_camofox_mode\", lambda: False)\n+        monkeypatch.setattr(\n+            browser_tool,\n+            \"_get_session_info\",\n+            lambda task_id: {\n+                \"session_name\": f\"s_{task_id}\",\n+                \"bb_session_id\": None,\n+                \"cdp_url\": None,\n+                \"features\": {\"local\": True},\n+                \"_first_nav\": False,\n+            },\n+        )\n+\n+    def test_blocks_private_url_after_eval_navigation(self, monkeypatch):\n+        \"\"\"Snapshot must block when current page URL is private.\"\"\"\n+        monkeypatch.setattr(browser_tool, \"_is_local_backend\", lambda: False)\n+        monkeypatch.setattr(browser_tool, \"_allow_private_urls\", lambda: False)\n+        monkeypatch.setattr(browser_tool, \"_is_safe_url\", lambda url: False)\n+\n+        call_count = {\"n\": 0}\n+\n+        def mock_run_browser_command(task_id, command, args=None, **kwargs):\n+            call_count[\"n\"] += 1\n+            if command == \"snapshot\":\n+                return _make_snapshot_result()\n+            elif command == \"eval\":\n+                return _make_eval_result(self.PRIVATE_URL)\n+            return {\"success\": False, \"error\": \"unknown command\"}\n+\n+        monkeypatch.setattr(\n+            browser_tool, \"_run_browser_command\", mock_run_browser_command\n+        )\n+\n+        result = json.loads(browser_browser_snapshot(task_id=\"test\"))\n+        assert result[\"success\"] is False\n+        assert \"private or internal address\" in result[\"error\"]\n+        assert self.PRIVATE_URL in result[\"error\"]\n+        # Must have called eval to check URL\n+        assert call_count[\"n\"] == 2  # snapshot + eval\n+\n+    def test_allows_public_url_after_eval_navigation(self, monkeypatch):\n+        \"\"\"Snapshot must succeed when current page URL is public.\"\"\"\n+        monkeypatch.setattr(browser_tool, \"_is_local_backend\", lambda: False)\n+        monkeypatch.setattr(browser_tool, \"_allow_private_urls\", lambda: False)\n+        monkeypatch.setattr(browser_tool, \"_is_safe_url\", lambda url: True)\n+\n+        def mock_run_browser_command(task_id, command, args=None, **kwargs):\n+            if command == \"snapshot\":\n+                return _make_snapshot_result()\n+            elif command == \"eval\":\n+                return _make_eval_result(self.PUBLIC_URL)\n+            return {\"success\": False, \"error\": \"unknown command\"}\n+\n+        monkeypatch.setattr(\n+            browser_tool, \"_run_browser_command\", mock_run_browser_command\n+        )\n+\n+        result = json.loads(browser_browser_snapshot(task_id=\"test\"))\n+        assert result[\"success\"] is True\n+        assert \"snapshot\" in result\n+\n+    def test_skips_check_in_local_backend_mode(self, monkeypatch):\n+        \"\"\"Local backend mode skips SSRF check entirely.\"\"\"\n+        monkeypatch.setattr(browser_tool, \"_is_local_backend\", lambda: True)\n+\n+        def mock_run_browser_command(task_id, command, args=None, **kwargs):\n+            if command == \"snapshot\":\n+                return _make_snapshot_result()\n+            return {\"success\": False, \"error\": \"should not be called\"}\n+\n+        monkeypatch.setattr(\n+            browser_tool, \"_run_browser_command\", mock_run_browser_command\n+        )\n+\n+        result = json.loads(browser_browser_snapshot(task_id=\"test\"))\n+        assert result[\"success\"] is True\n+        assert \"snapshot\" in result\n+\n+\n+    def test_skips_check_for_local_sidecar_session(self, monkeypatch):\n+        \"\"\"Local sidecar sessions can legitimately access private URLs.\"\"\"\n+        monkeypatch.setattr(browser_tool, \"_is_local_backend\", lambda: False)\n+        monkeypatch.setattr(browser_tool, \"_allow_private_urls\", lambda: False)\n+        # Simulate the effective_task_id being a local sidecar key\n+        monkeypatch.setattr(browser_tool, \"_is_local_sidecar_key\", lambda key: True)\n+\n+        def mock_run_browser_command(task_id, command, args=None, **kwargs):\n+            if command == \"snapshot\":\n+                return _make_snapshot_result()\n+            return {\"success\": False, \"error\": \"should not be called\"}\n+\n+        monkeypatch.setattr(\n+            browser_tool, \"_run_browser_command\", mock_run_browser_command\n+        )\n+\n+        result = json.loads(browser_browser_snapshot(task_id=\"test\"))\n+        assert result[\"success\"] is True\n+        assert \"snapshot\" in result\n+\n+    def test_skips_check_when_private_urls_allowed(self, monkeypatch):\n+        \"\"\"When allow_private_urls is enabled, SSRF check is skipped.\"\"\"\n+        monkeypatch.setattr(browser_tool, \"_is_local_backend\", lambda: False)\n+        monkeypatch.setattr(browser_tool, \"_allow_private_urls\", lambda: True)\n+\n+        def mock_run_browser_command(task_id, command, args=None, **kwargs):\n+            if command == \"snapshot\":\n+                return _make_snapshot_result()\n+            return {\"success\": False, \"error\": \"should not be called\"}\n+\n+        monkeypatch.setattr(\n+            browser_tool, \"_run_browser_command\", mock_run_browser_command\n+        )\n+\n+        result = json.loads(browser_browser_snapshot(task_id=\"test\"))\n+        assert result[\"success\"] is True\n+        assert \"snapshot\" in result\n+\n+    def test_handles_eval_failure_gracefully(self, monkeypatch):\n+        \"\"\"If URL eval fails, snapshot should still succeed (fail-open).\"\"\"\n+        monkeypatch.setattr(browser_tool, \"_is_local_backend\", lambda: False)\n+        monkeypatch.setattr(browser_tool, \"_allow_private_urls\", lambda: False)\n+\n+        def mock_run_browser_command(task_id, command, args=None, **kwargs):\n+            if command == \"snapshot\":\n+                return _make_snapshot_result()\n+            elif command == \"eval\":\n+                return {\"success\": False, \"error\": \"eval failed\"}\n+            return {\"success\": False, \"error\": \"unknown\"}\n+\n+        monkeypatch.setattr(\n+            browser_tool, \"_run_browser_command\", mock_run_browser_command\n+        )\n+\n+        result = json.loads(browser_browser_snapshot(task_id=\"test\"))\n+        # Should succeed — eval failure means we can't determine URL, fail-open\n+        assert result[\"success\"] is True\n+\n+    def test_handles_empty_url_result(self, monkeypatch):\n+        \"\"\"If URL eval returns empty string, snapshot should succeed.\"\"\"\n+        monkeypatch.setattr(browser_tool, \"_is_local_backend\", lambda: False)\n+        monkeypatch.setattr(browser_tool, \"_allow_private_urls\", lambda: False)\n+\n+        def mock_run_browser_command(task_id, command, args=None, **kwargs):\n+            if command == \"snapshot\":\n+                return _make_snapshot_result()\n+            elif command == \"eval\":\n+                return _make_eval_result(\"\")\n+            return {\"success\": False, \"error\": \"unknown\"}\n+\n+        monkeypatch.setattr(\n+            browser_tool, \"_run_browser_command\", mock_run_browser_command\n+        )\n+\n+        result = json.loads(browser_browser_snapshot(task_id=\"test\"))\n+        assert result[\"success\"] is True\n+\n+    def test_handles_eval_exception(self, monkeypatch):\n+        \"\"\"If URL eval raises an exception, snapshot should succeed.\"\"\"\n+        monkeypatch.setattr(browser_tool, \"_is_local_backend\", lambda: False)\n+        monkeypatch.setattr(browser_tool, \"_allow_private_urls\", lambda: False)\n+\n+        def mock_run_browser_command(task_id, command, args=None, **kwargs):\n+            if command == \"snapshot\":\n+                return _make_snapshot_result()\n+            elif command == \"eval\":\n+                raise RuntimeError(\"CDP connection lost\")\n+            return {\"success\": False, \"error\": \"unknown\"}\n+\n+        monkeypatch.setattr(\n+            browser_tool, \"_run_browser_command\", mock_run_browser_command\n+        )\n+\n+        result = json.loads(browser_browser_snapshot(task_id=\"test\"))\n+        assert result[\"success\"] is True\n+\n+    def test_blocks_loopback_url(self, monkeypatch):\n+        \"\"\"Loopback URLs (localhost) must be blocked.\"\"\"\n+        monkeypatch.setattr(browser_tool, \"_is_local_backend\", lambda: False)\n+        monkeypatch.setattr(browser_tool, \"_allow_private_urls\", lambda: False)\n+        monkeypatch.setattr(browser_tool, \"_is_safe_url\", lambda url: False)\n+\n+        def mock_run_browser_command(task_id, command, args=None, **kwargs):\n+            if command == \"snapshot\":\n+                return _make_snapshot_result()\n+            elif command == \"eval\":\n+                return _make_eval_result(\"http://localhost:3000/admin\")\n+            return {\"success\": False, \"error\": \"unknown\"}\n+\n+        monkeypatch.setattr(\n+            browser_tool, \"_run_browser_command\", mock_run_browser_command\n+        )\n+\n+        result = json.loads(browser_browser_snapshot(task_id=\"test\"))\n+        assert result[\"success\"] is False\n+        assert \"private or internal address\" in result[\"error\"]\n+\n+    def test_blocks_private_ip_range(self, monkeypatch):\n+        \"\"\"Private IP ranges (10.x, 172.16.x, 192.168.x) must be blocked.\"\"\"\n+        monkeypatch.setattr(browser_tool, \"_is_local_backend\", lambda: False)\n+        monkeypatch.setattr(browser_tool, \"_allow_private_urls\", lambda: False)\n+        monkeypatch.setattr(browser_tool, \"_is_safe_url\", lambda url: False)\n+\n+        for private_ip in [\"http://10.0.0.1/api\", \"http://172.16.0.1/admin\", \"http://192.168.1.1/config\"]:\n+            def mock_run_browser_command(task_id, command, args=None, **kwargs):\n+                if command == \"snapshot\":\n+                    return _make_snapshot_result()\n+                elif command == \"eval\":\n+                    return _make_eval_result(private_ip)\n+                return {\"success\": False, \"error\": \"unknown\"}\n+\n+            monkeypatch.setattr(\n+                browser_tool, \"_run_browser_command\", mock_run_browser_command\n+            )\n+\n+            result = json.loads(browser_browser_snapshot(task_id=\"test\"))\n+            assert result[\"success\"] is False, f\"Expected block for {private_ip}\"\n+            assert \"private or internal address\" in result[\"error\"]\n+\n+\n+# Helper to avoid name collision with the actual function\n+def browser_browser_snapshot(**kwargs):\n+    from tools.browser_tool import browser_snapshot\n+    return browser_snapshot(**kwargs)\n+\n+\n+def browser_browser_vision(**kwargs):\n+    from tools.browser_tool import browser_vision\n+    return browser_vision(**kwargs)\n+\n+\n+def _make_screenshot_result(path=\"/tmp/test_screenshot.png\"):\n+    \"\"\"Return a mock successful screenshot result.\"\"\"\n+    return {\"success\": True, \"data\": {\"path\": path}}\n+\n+\n+# ---------------------------------------------------------------------------\n+# browser_vision: private-network guard after eval navigation\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestBrowserVisionPrivateNetworkGuard:\n+    \"\"\"browser_vision must block screenshots from private pages navigated via eval.\"\"\"\n+\n+    PRIVATE_URL = \"http://127.0.0.1:8080/secret\"\n+    PUBLIC_URL = \"https://example.com/page\"\n+\n+    @pytest.fixture(autouse=True)\n+    def _setup(self, monkeypatch):\n+        \"\"\"Common patches for vision SSRF tests.\"\"\"\n+        monkeypatch.setattr(browser_tool, \"_is_camofox_mode\", lambda: False)\n+\n+    def test_blocks_private_url_after_eval_navigation(self, monkeypatch):\n+        \"\"\"Vision must block when current page URL is private.\"\"\"\n+        monkeypatch.setattr(browser_tool, \"_is_local_backend\", lambda: False)\n+        monkeypatch.setattr(browser_tool, \"_allow_private_urls\", lambda: False)\n+        monkeypatch.setattr(browser_tool, \"_is_safe_url\", lambda url: False)\n+\n+        def mock_run_browser_command(task_id, command, args=None, **kwargs):\n+            if command == \"eval\":\n+                return _make_eval_result(self.PRIVATE_URL)\n+            return {\"success\": False, \"error\": \"should not reach screenshot\"}\n+\n+        monkeypatch.setattr(\n+            browser_tool, \"_run_browser_command\", mock_run_browser_command\n+        )\n+\n+        result = json.loads(browser_browser_vision(question=\"what do you see\", task_id=\"test\"))\n+        assert result[\"success\"] is False\n+        assert \"private or internal address\" in result[\"error\"]\n+        assert self.PRIVATE_URL in result[\"error\"]\n+\n+    def test_allows_public_url_after_eval_navigation(self, monkeypatch):\n+        \"\"\"Vision must proceed when current page URL is public.\"\"\"\n+        monkeypatch.setattr(browser_tool, \"_is_local_backend\", lambda: False)\n+        monkeypatch.setattr(browser_tool, \"_allow_private_urls\", lambda: False)\n+        monkeypatch.setattr(browser_tool, \"_is_safe_url\", lambda url: True)\n+\n+        def mock_run_browser_command(task_id, command, args=None, **kwargs):\n+            if command == \"eval\":\n+                return _make_eval_result(self.PUBLIC_URL)\n+            elif command == \"screenshot\":\n+                return _make_screenshot_result()\n+            return {\"success\": False, \"error\": \"unknown\"}\n+\n+        monkeypatch.setattr(\n+            browser_tool, \"_run_browser_command\", mock_run_browser_command\n+        )\n+        # Screenshot file won't exist — that's fine, function returns error\n+        # but the important thing is the guard didn't block it.\n+\n+        result_raw = browser_browser_vision(question=\"what do you see\", task_id=\"test\")\n+        result = json.loads(result_raw)\n+        # Guard passed; function continues to screenshot path.\n+        # Since screenshot file doesn't exist, it returns a file-not-found error,\n+        # NOT the \"private or internal address\" error.\n+        assert \"private or internal address\" not in result.get(\"error\", \"\")\n+\n+    def test_skips_check_in_local_backend_mode(self, monkeypatch):\n+        \"\"\"Local backend mode skips SSRF check entirely.\"\"\"\n+        monkeypatch.setattr(browser_tool, \"_is_local_backend\", lambda: True)\n+\n+        def mock_run_browser_command(task_id, command, args=None, **kwargs):\n+            if command == \"screenshot\":\n+                return _make_screenshot_result()\n+            return {\"success\": False, \"error\": \"should not be called\"}\n+\n+        monkeypatch.setattr(\n+            browser_tool, \"_run_browser_command\", mock_run_browser_command\n+        )\n+\n+        result_raw = browser_browser_vision(question=\"what\", task_id=\"test\")\n+        result = json.loads(result_raw)\n+        assert \"private or internal address\" not in result.get(\"error\", \"\")\n+\n+\n+    def test_skips_check_for_local_sidecar_session(self, monkeypatch):\n+        \"\"\"Local sidecar sessions can legitimately access private URLs.\"\"\"\n+        monkeypatch.setattr(browser_tool, \"_is_local_backend\", lambda: False)\n+        monkeypatch.setattr(browser_tool, \"_allow_private_urls\", lambda: False)\n+        # Simulate the effective_task_id being a local sidecar key\n+        monkeypatch.setattr(browser_tool, \"_is_local_sidecar_key\", lambda key: True)\n+\n+        def mock_run_browser_command(task_id, command, args=None, **kwargs):\n+            if command == \"screenshot\":\n+                return _make_screenshot_result()\n+            return {\"success\": False, \"error\": \"should not be called\"}\n+\n+        monkeypatch.setattr(\n+            browser_tool, \"_run_browser_command\", mock_run_browser_command\n+        )\n+\n+        result_raw = browser_browser_vision(question=\"what\", task_id=\"test\")\n+        result = json.loads(result_raw)\n+        assert \"private or internal address\" not in result.get(\"error\", \"\")\n+\n+    def test_skips_check_when_private_urls_allowed(self, monkeypatch):\n+        \"\"\"When allow_private_urls is enabled, SSRF check is skipped.\"\"\"\n+        monkeypatch.setattr(browser_tool, \"_is_local_backend\", lambda: False)\n+        monkeypatch.setattr(browser_tool, \"_allow_private_urls\", lambda: True)\n+\n+        def mock_run_browser_command(task_id, command, args=None, **kwargs):\n+            if command == \"screenshot\":\n+                return _make_screenshot_result()\n+            return {\"success\": False, \"error\": \"should not be called\"}\n+\n+        monkeypatch.setattr(\n+            browser_tool, \"_run_browser_command\", mock_run_browser_command\n+        )\n+\n+        result_raw = browser_browser_vision(question=\"what\", task_id=\"test\")\n+        result = json.loads(result_raw)\n+        assert \"private or internal address\" not in result.get(\"error\", \"\")\n+\n+    def test_handles_eval_failure_gracefully(self, monkeypatch):\n+        \"\"\"If URL eval fails, vision should still proceed (fail-open).\"\"\"\n+        monkeypatch.setattr(browser_tool, \"_is_local_backend\", lambda: False)\n+        monkeypatch.setattr(browser_tool, \"_allow_private_urls\", lambda: False)\n+\n+        def mock_run_browser_command(task_id, command, args=None, **kwargs):\n+            if command == \"eval\":\n+                return {\"success\": False, \"error\": \"eval failed\"}\n+            elif command == \"screenshot\":\n+                return _make_screenshot_result()\n+            return {\"success\": False, \"error\": \"unknown\"}\n+\n+        monkeypatch.setattr(\n+            browser_tool, \"_run_browser_command\", mock_run_browser_command\n+        )\n+\n+        result_raw = browser_browser_vision(question=\"what\", task_id=\"test\")\n+        result = json.loads(result_raw)\n+        assert \"private or internal address\" not in result.get(\"error\", \"\")\n+\n+    def test_handles_eval_exception(self, monkeypatch):\n+        \"\"\"If URL eval raises an exception, vision should still proceed.\"\"\"\n+        monkeypatch.setattr(browser_tool, \"_is_local_backend\", lambda: False)\n+        monkeypatch.setattr(browser_tool, \"_allow_private_urls\", lambda: False)\n+\n+        def mock_run_browser_command(task_id, command, args=None, **kwargs):\n+            if command == \"eval\":\n+                raise RuntimeError(\"CDP connection lost\")\n+            elif command == \"screenshot\":\n+                return _make_screenshot_result()\n+            return {\"success\": False, \"error\": \"unknown\"}\n+\n+        monkeypatch.setattr(\n+            browser_tool, \"_run_browser_command\", mock_run_browser_command\n+        )\n+\n+        result_raw = browser_browser_vision(question=\"what\", task_id=\"test\")\n+        result = json.loads(result_raw)\n+        assert \"private or internal address\" not in result.get(\"error\", \"\")\n\\ No newline at end of file"]},{"file":"tools/browser_tool.py","hunks":["@@ -2695,6 +2695,37 @@ def browser_snapshot(\n         snapshot_text = data.get(\"snapshot\", \"\")\n         refs = data.get(\"refs\", {})\n \n+        # ── Private-network guard: block snapshots from eval-navigated private pages ──\n+        # After any eval (browser_console) that may have changed location.href to a\n+        # private/internal address, the snapshot would expose private page content.\n+        # Re-check the current URL before returning the snapshot.\n+        if (\n+            not _is_local_backend()\n+            and not _is_local_sidecar_key(effective_task_id)\n+            and not _allow_private_urls()\n+        ):\n+            try:\n+                _url_result = _run_browser_command(\n+                    effective_task_id, \"eval\", [\"window.location.href\"],\n+                    timeout=5, _engine_override=\"auto\",\n+                )\n+                if _url_result.get(\"success\"):\n+                    _current_url = (\n+                        _url_result.get(\"data\", {}).get(\"result\", \"\")\n+                        .strip().strip('\"').strip(\"'\")\n+                    )\n+                    if _current_url and not _is_safe_url(_current_url):\n+                        return json.dumps({\n+                            \"success\": False,\n+                            \"error\": (\n+                                \"Blocked: page URL targets a private or internal address \"\n+                                f\"({_current_url}). This may have been caused by a \"\n+                                \"JavaScript navigation via browser_console.\"\n+                            ),\n+                        }, ensure_ascii=False)\n+            except Exception as _url_exc:\n+                logger.debug(\"browser_snapshot: URL safety check failed (%s)\", _url_exc)\n+\n         # Check if snapshot needs summarization\n         if len(snapshot_text) > SNAPSHOT_SUMMARIZE_THRESHOLD and user_task:\n             snapshot_text = _extract_relevant_content(snapshot_text, user_task)","@@ -3000,6 +3031,74 @@ def browser_console(clear: bool = False, expression: Optional[str] = None, task_\n     return json.dumps(response, ensure_ascii=False)\n \n \n+def _eval_ssrf_guard_active(effective_task_id: str) -> bool:\n+    \"\"\"Return True when eval-driven private-network access must be guarded.\n+\n+    Matches the gating used by ``browser_navigate`` / ``browser_snapshot`` /\n+    ``browser_vision``: the SSRF guard is only meaningful for non-local\n+    backends (cloud browser, or a containerized terminal whose browser-on-host\n+    can reach internal networks the terminal can't), and is skipped for local\n+    sidecar sessions and when ``allow_private_urls`` is set.\n+    \"\"\"\n+    return (\n+        not _is_local_backend()\n+        and not _is_local_sidecar_key(effective_task_id)\n+        and not _allow_private_urls()\n+    )\n+\n+\n+# URL-shaped literals embedded in a JS expression (http/https only).  Used to\n+# pre-screen ``browser_console(expression=...)`` calls that fetch/XHR/navigate\n+# to a private host directly — that path never updates ``location.href`` so the\n+# post-eval page-URL recheck below can't see it.\n+_JS_URL_LITERAL_RE = re.compile(r\"\"\"https?://[^\\s'\"`)\\]<>]+\"\"\", re.IGNORECASE)\n+\n+\n+def _expression_targets_private_url(expression: str) -> Optional[str]:\n+    \"\"\"Return the first private/always-blocked URL literal in a JS expression.\n+\n+    Best-effort: scans for ``http(s)://...`` literals (fetch/XHR/navigation\n+    targets the agent may have embedded) and returns the first one that targets\n+    a private/internal address or the always-blocked cloud-metadata floor.\n+    Returns ``None`` when no such literal is found.\n+    \"\"\"\n+    if not isinstance(expression, str):\n+        return None\n+    for match in _JS_URL_LITERAL_RE.findall(expression):\n+        candidate = match.rstrip(\".,;\")\n+        if _is_always_blocked_url(candidate) or not _is_safe_url(candidate):\n+            return candidate\n+    return None\n+\n+\n+def _current_page_private_url(effective_task_id: str) -> Optional[str]:\n+    \"\"\"Return the current page URL when it targets a private/internal address.\n+\n+    Reads ``window.location.href`` via a low-cost eval and returns it when the\n+    page has been navigated (e.g. via ``location.href = '...'`` in a prior\n+    eval) to an address the SSRF guard would reject.  Returns ``None`` when the\n+    page is public, the URL can't be determined, or the check errors (fail-open\n+    on probe failure, matching the snapshot/vision guards).\n+    \"\"\"\n+    try:\n+        url_result = _run_browser_command(\n+            effective_task_id, \"eval\", [\"window.location.href\"],\n+            timeout=5, _engine_override=\"auto\",\n+        )\n+        if url_result.get(\"success\"):\n+            current_url = (\n+                url_result.get(\"data\", {}).get(\"result\", \"\")\n+                .strip().strip('\"').strip(\"'\")\n+            )\n+            if current_url and (\n+                _is_always_blocked_url(current_url) or not _is_safe_url(current_url)\n+            ):\n+                return current_url\n+    except Exception as exc:\n+        logger.debug(\"_current_page_private_url: probe failed (%s)\", exc)\n+    return None\n+\n+\n def _browser_eval(expression: str, task_id: Optional[str] = None) -> str:\n     \"\"\"Evaluate a JavaScript expression in the page context and return the result.\"\"\"\n     if _is_camofox_mode():","@@ -3007,6 +3106,27 @@ def _browser_eval(expression: str, task_id: Optional[str] = None) -> str:\n \n     effective_task_id = _last_session_key(task_id or \"default\")\n \n+    # ── Private-network guard (eval return-value path) ──────────────────────\n+    # browser_snapshot / browser_vision re-check the page URL before returning\n+    # content, but eval returns arbitrary JS results directly — an attacker can\n+    # read a private page via `fetch('http://127.0.0.1/secret')` or by reading\n+    # the DOM after `location.href = 'http://127.0.0.1/'`, never touching\n+    # snapshot/vision.  Close both sub-paths on the same gating condition:\n+    #   1. Pre-scan the expression for private-host URL literals (direct fetch).\n+    #   2. After eval, re-check the page URL (navigate-then-read).\n+    if _eval_ssrf_guard_active(effective_task_id):\n+        blocked_literal = _expression_targets_private_url(expression)\n+        if blocked_literal:\n+            return json.dumps({\n+                \"success\": False,\n+                \"error\": (\n+                    \"Blocked: JavaScript expression targets a private or \"\n+                    f\"internal address ({blocked_literal}). Reading internal \"\n+                    \"endpoints via browser_console is not permitted in this \"\n+                    \"browser mode.\"\n+                ),\n+            }, ensure_ascii=False)\n+\n     # --- Fast path: route through the supervisor's persistent CDP WS ---------\n     # When a CDPSupervisor is alive for this task_id, ``Runtime.evaluate`` runs\n     # on the already-connected WebSocket — zero subprocess startup cost vs","@@ -3028,6 +3148,20 @@ def _browser_eval(expression: str, task_id: Optional[str] = None) -> str:\n                         parsed = json.loads(raw_result)\n                     except (json.JSONDecodeError, ValueError):\n                         pass  # keep as string\n+                # Post-eval page-URL recheck: if this (or a prior) eval\n+                # navigated the page to a private address, withhold the result.\n+                if _eval_ssrf_guard_active(effective_task_id):\n+                    _blocked_url = _current_page_private_url(effective_task_id)\n+                    if _blocked_url:\n+                        return json.dumps({\n+                            \"success\": False,\n+                            \"error\": (\n+                                \"Blocked: page URL targets a private or internal \"\n+                                f\"address ({_blocked_url}). This may have been \"\n+                                \"caused by a JavaScript navigation via \"\n+                                \"browser_console.\"\n+                            ),\n+                        }, ensure_ascii=False)\n                 response = {\n                     \"success\": True,\n                     \"result\": parsed,","@@ -3103,6 +3237,19 @@ def _browser_eval(expression: str, task_id: Optional[str] = None) -> str:\n         \"result\": parsed,\n         \"result_type\": type(parsed).__name__,\n     }\n+    # Post-eval page-URL recheck: if this (or a prior) eval navigated the page\n+    # to a private address, withhold the result (mirrors the supervisor path).\n+    if _eval_ssrf_guard_active(effective_task_id):\n+        _blocked_url = _current_page_private_url(effective_task_id)\n+        if _blocked_url:\n+            return json.dumps({\n+                \"success\": False,\n+                \"error\": (\n+                    \"Blocked: page URL targets a private or internal address \"\n+                    f\"({_blocked_url}). This may have been caused by a \"\n+                    \"JavaScript navigation via browser_console.\"\n+                ),\n+            }, ensure_ascii=False)\n     return json.dumps(_copy_fallback_warning(response, result), ensure_ascii=False, default=str)\n \n ","@@ -3284,6 +3431,37 @@ def browser_vision(question: str, annotate: bool = False, task_id: Optional[str]\n     screenshot_path = screenshots_dir / f\"browser_screenshot_{uuid_mod.uuid4().hex}.png\"\n     effective_task_id = _last_session_key(task_id or \"default\")\n \n+    # ── Private-network guard: block vision from eval-navigated private pages ──\n+    # After any eval (browser_console) that may have changed location.href to a\n+    # private/internal address, the screenshot would expose private page content\n+    # to the vision model.  Re-check the current URL before capturing anything.\n+    if (\n+        not _is_local_backend()\n+        and not _is_local_sidecar_key(effective_task_id)\n+        and not _allow_private_urls()\n+    ):\n+        try:\n+            _url_result = _run_browser_command(\n+                effective_task_id, \"eval\", [\"window.location.href\"],\n+                timeout=5, _engine_override=\"auto\",\n+            )\n+            if _url_result.get(\"success\"):\n+                _current_url = (\n+                    _url_result.get(\"data\", {}).get(\"result\", \"\")\n+                    .strip().strip('\"').strip(\"'\")\n+                )\n+                if _current_url and not _is_safe_url(_current_url):\n+                    return json.dumps({\n+                        \"success\": False,\n+                        \"error\": (\n+                            \"Blocked: page URL targets a private or internal address \"\n+                            f\"({_current_url}). This may have been caused by a \"\n+                            \"JavaScript navigation via browser_console.\"\n+                        ),\n+                    }, ensure_ascii=False)\n+        except Exception as _url_exc:\n+            logger.debug(\"browser_vision: URL safety check failed (%s)\", _url_exc)\n+\n     # Lightpanda has no graphical renderer — pre-route screenshots to Chrome\n     # via the fallback helper instead of letting the normal path fail with a\n     # CDP error or return a placeholder PNG.  The normal analysis path below\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}