{"repo":"NousResearch/hermes-agent","public_id":"57505","display_label":"Dashboard file access security hardening","summary":"A reported vulnerability exposed sensitive credential files (including .env) through the dashboard Files feature, prompting multiple PRs to restrict file system reads, block credential files, and close related directory traversal gaps.","subsystem":"dashboard","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":57660,"merged_at":"2026-07-03T10:27:48+00:00"},"resolution":{"state":"resolved","by":"pr-57660"},"freshness":{"state":"verified","last_verified_at":"2026-07-03T10:27:48+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":57505,"pr_number":57660,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-03T10:27:48+00:00"}],"members":[{"kind":"issue","number":57505,"title":"[Bug]: Security: Dashboard \"Files\" feature exposes .env containing API keys","url":null,"state":"closed"},{"kind":"pr","number":57507,"title":"fix(dashboard): block .env files from managed-files API","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#57660","number":57660},{"kind":"pr","value":"#57505","number":57505}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":57660,"title":"fix(security): salvage six P1 hardening PRs — browser guards, MEDIA anchoring, .env lockdown, delegate ACP transport, matrix sync isolation","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/api_server.py","hermes_cli/web_server.py","plugins/platforms/matrix/adapter.py","run_agent.py","tests/gateway/test_api_server_media_data_urls.py","tests/gateway/test_matrix.py","tests/hermes_cli/test_web_server_files.py","tests/tools/test_browser_camofox_private_page_guard.py","tests/tools/test_browser_cdp_tool.py","tests/tools/test_delegate.py","tools/browser_camofox.py","tools/browser_cdp_tool.py","tools/delegate_tool.py"],"fix_hunks":[{"file":"gateway/platforms/api_server.py","hunks":["@@ -54,9 +54,11 @@\n \n from gateway.config import Platform, PlatformConfig\n from gateway.platforms.base import (\n+    MEDIA_TAG_CLEANUP_RE,\n     BasePlatformAdapter,\n     SendResult,\n     is_network_accessible,\n+    validate_media_delivery_path,\n )\n from agent.redact import redact_sensitive_text\n ","@@ -581,9 +583,6 @@ async def cors_middleware(request, handler):\n     \".webp\": \"image/webp\",\n     \".bmp\": \"image/bmp\",\n }\n-_MEDIA_TAG_RE = re.compile(\n-    r\"[`\\\"']?MEDIA:\\s*(`[^`\\n]+`|\\\"[^\\\"\\n]+\\\"|'[^'\\n]+'|\\S+)[`\\\"']?\"\n-)\n _MEDIA_DATA_URL_MAX_BYTES = 5 * 1024 * 1024  # skip images larger than 5MB\n \n ","@@ -594,18 +593,35 @@ def _resolve_media_to_data_urls(text: str) -> str:\n     ``MEDIA:`` tags referencing images on the server are useless to them.\n     Inline small local images as markdown data URLs; non-image or unreadable\n     paths are left untouched.\n+\n+    Uses the same anchored ``MEDIA_TAG_CLEANUP_RE`` matcher and\n+    ``validate_media_delivery_path`` safety check every other platform\n+    adapter's media delivery already goes through (gateway/platforms/base.py)\n+    — an absolute-path anchor plus a known-extension requirement, and a\n+    resolved-path check against the credential/system-path denylist. The\n+    prior pattern here matched any bare token after ``MEDIA:`` (including a\n+    relative/traversal path like ``../../etc/passwd.png``) and read the file\n+    directly with no denylist, so any image-suffixed, readable file the\n+    process could see was base64-exfiltrated to the API caller if its path\n+    merely appeared in the model's own final reply text.\n     \"\"\"\n     if not text or \"MEDIA:\" not in text:\n         return text\n     import base64\n \n     def _to_data_url(path_str: str) -> Optional[str]:\n-        p = Path(path_str.strip().strip(\"`\\\"'\")).expanduser()\n+        # validate_media_delivery_path() strips wrapping quotes/backticks\n+        # and trailing punctuation internally, same as MEDIA_TAG_CLEANUP_RE's\n+        # other callers (extract_media / _strip_media_tag_directives) rely on.\n+        safe_path = validate_media_delivery_path(path_str)\n+        if not safe_path:\n+            return None\n+        p = Path(safe_path)\n         suffix = p.suffix.lower()\n         if suffix not in _MEDIA_IMG_EXT:\n             return None\n         try:\n-            if not p.is_file() or p.stat().st_size > _MEDIA_DATA_URL_MAX_BYTES:\n+            if p.stat().st_size > _MEDIA_DATA_URL_MAX_BYTES:\n                 return None\n             b64 = base64.b64encode(p.read_bytes()).decode()\n         except OSError:","@@ -613,10 +629,10 @@ def _to_data_url(path_str: str) -> Optional[str]:\n         return f\"![image](data:{_MEDIA_MIME[suffix]};base64,{b64})\"\n \n     def _repl(m: \"re.Match[str]\") -> str:\n-        return _to_data_url(m.group(1)) or m.group(0)\n+        return _to_data_url(m.group(\"path\")) or m.group(0)\n \n     try:\n-        return _MEDIA_TAG_RE.sub(_repl, text)\n+        return MEDIA_TAG_CLEANUP_RE.sub(_repl, text)\n     except Exception:\n         return text\n "]},{"file":"hermes_cli/web_server.py","hunks":["@@ -1186,6 +1186,19 @@ class ManagedFilesPolicy:\n     \"target\",\n     \"venv\",\n }\n+\n+# Filenames that must never be listed, read, or downloaded through the\n+# managed-files API.  These typically contain credentials (API keys, tokens)\n+# and exposing them through the dashboard file browser is a security leak —\n+# see issue #57505.\n+def _is_sensitive_filename(name: str) -> bool:\n+    \"\"\"Return True for ``.env`` and any ``.env.<suffix>`` variant.\n+\n+    Case-insensitive so ``.ENV`` / ``.Env.local`` on case-insensitive\n+    filesystems (macOS/Windows mounts) can't slip past the guard.\n+    \"\"\"\n+    lowered = name.lower()\n+    return lowered == \".env\" or lowered.startswith(\".env.\")\n _FS_DATA_URL_MAX_BYTES = 16 * 1024 * 1024\n _FS_TEXT_SOURCE_MAX_BYTES = 64 * 1024 * 1024\n _FS_TEXT_PREVIEW_MAX_BYTES = 512 * 1024","@@ -1616,7 +1629,11 @@ async def list_managed_files(request: Request, path: Optional[str] = None):\n         raise HTTPException(status_code=400, detail=\"Path is not a directory\")\n \n     try:\n-        entries = [_managed_file_entry(policy, child) for child in target.iterdir()]\n+        entries = [\n+            _managed_file_entry(policy, child)\n+            for child in target.iterdir()\n+            if not _is_sensitive_filename(child.name)\n+        ]\n     except PermissionError:\n         raise HTTPException(status_code=403, detail=\"Directory is not readable\")\n     except OSError as exc:","@@ -1642,6 +1659,8 @@ async def read_managed_file(request: Request, path: str):\n         raise HTTPException(status_code=404, detail=\"File not found\")\n     if not target.is_file():\n         raise HTTPException(status_code=400, detail=\"Path is not a file\")\n+    if _is_sensitive_filename(target.name):\n+        raise HTTPException(status_code=403, detail=\"Access to sensitive files is not allowed\")\n \n     try:\n         size = target.stat().st_size","@@ -1684,6 +1703,8 @@ async def download_managed_file(request: Request, path: str):\n         raise HTTPException(status_code=404, detail=\"File not found\")\n     if not target.is_file():\n         raise HTTPException(status_code=400, detail=\"Path is not a file\")\n+    if _is_sensitive_filename(target.name):\n+        raise HTTPException(status_code=403, detail=\"Access to sensitive files is not allowed\")\n \n     try:\n         size = target.stat().st_size"]},{"file":"plugins/platforms/matrix/adapter.py","hunks":["@@ -2351,7 +2351,18 @@ async def _dispatch_sync(self, sync_data: Dict[str, Any]) -> None:\n         if inspect.isawaitable(tasks):\n             tasks = await tasks\n         if tasks:\n-            await asyncio.gather(*tasks)\n+            # return_exceptions=True so one failing event handler doesn't abort\n+            # the whole gather and silently drop the SIBLING events in the same\n+            # sync response (a bare gather re-raises the first exception, leaving\n+            # the rest of the batch unprocessed). Mirrors the invite/redaction\n+            # gathers above. Surface each failure instead of swallowing it.\n+            results = await asyncio.gather(*tasks, return_exceptions=True)\n+            for result in results:\n+                if isinstance(result, Exception):\n+                    logger.warning(\n+                        \"Matrix: event handler failed during sync dispatch: %s\",\n+                        result,\n+                    )\n \n     def _is_self_sender(self, sender: str) -> bool:\n         \"\"\"Return True if the sender refers to the bot's own account."]},{"file":"run_agent.py","hunks":["@@ -5623,7 +5623,10 @@ def _dispatch_delegate_task(self, function_args: dict) -> str:\n         New DELEGATE_TASK_SCHEMA fields only need to be added here to reach all\n         invocation paths (concurrent, sequential, inline).\n         \"\"\"\n-        from tools.delegate_tool import delegate_task as _delegate_task\n+        from tools.delegate_tool import (\n+            _strip_model_hidden_task_fields,\n+            delegate_task as _delegate_task,\n+        )\n         # Delegations from the top-level MODEL always run in the background —\n         # the model does not get to choose. delegate_task returns immediately\n         # with a handle (one per task) and each subagent's result re-enters the","@@ -5639,10 +5642,8 @@ def _dispatch_delegate_task(self, function_args: dict) -> str:\n         return _delegate_task(\n             goal=function_args.get(\"goal\"),\n             context=function_args.get(\"context\"),\n-            tasks=function_args.get(\"tasks\"),\n+            tasks=_strip_model_hidden_task_fields(function_args.get(\"tasks\")),\n             max_iterations=function_args.get(\"max_iterations\"),\n-            acp_command=function_args.get(\"acp_command\"),\n-            acp_args=function_args.get(\"acp_args\"),\n             role=function_args.get(\"role\"),\n             background=(not _is_subagent),\n             parent_agent=self,"]},{"file":"tests/gateway/test_api_server_media_data_urls.py","hunks":["@@ -72,6 +72,40 @@ def test_multiple_tags(self):\n         out = _resolve_media_to_data_urls(f\"MEDIA:{p1}\\nand MEDIA:{p2}\")\n         self.assertEqual(out.count(\"data:image/png;base64,\"), 2)\n \n+    def test_relative_traversal_path_not_inlined(self):\n+        \"\"\"A relative/traversal path must never be inlined — the anchored\n+        MEDIA_TAG_CLEANUP_RE matcher requires an absolute-path prefix\n+        (~/, /, or a Windows drive letter), so a bare relative token after\n+        MEDIA: is left as literal text rather than resolved against cwd.\"\"\"\n+        text = \"MEDIA:../../../../etc/passwd.png\"\n+        self.assertEqual(_resolve_media_to_data_urls(text), text)\n+\n+    def test_credential_path_not_inlined_even_with_image_extension(self):\n+        \"\"\"An absolute path under the credential/system-path denylist\n+        (validate_media_delivery_path) must not be inlined even though it\n+        has an allowed image extension and the tag matcher's shape.\"\"\"\n+        text = \"MEDIA:~/.ssh/id_rsa.png\"\n+        self.assertEqual(_resolve_media_to_data_urls(text), text)\n+\n+    def test_symlink_escaping_to_denylisted_target_not_inlined(self):\n+        \"\"\"A symlink whose resolved target lands under a denylisted system\n+        prefix (/etc) must not be inlined — validate_media_delivery_path\n+        resolves symlinks before the containment/denylist check runs, so\n+        the traversal can't be laundered through an innocuous-looking\n+        image-suffixed symlink name.\"\"\"\n+        import os\n+        import tempfile\n+        from pathlib import Path\n+\n+        d = Path(tempfile.mkdtemp(prefix=\"hermes_media_test_symlink\"))\n+        link = d / \"shot.png\"\n+        try:\n+            os.symlink(\"/etc/hosts\", link)\n+        except OSError:\n+            self.skipTest(\"symlink creation not supported in this environment\")\n+        text = f\"MEDIA:{link}\"\n+        self.assertEqual(_resolve_media_to_data_urls(text), text)\n+\n \n if __name__ == \"__main__\":\n     unittest.main()"]},{"file":"tests/gateway/test_matrix.py","hunks":["@@ -5276,3 +5276,36 @@ async def test_flag_clears_when_second_connect_resolves_device_id(self):\n         assert None not in _verify_call.args[0][\"@bot:example.org\"]\n \n         await adapter.disconnect()\n+\n+\n+class TestMatrixDispatchSyncIsolation:\n+    \"\"\"A failing mautrix event handler must not abort the whole sync batch.\n+\n+    ``_dispatch_sync`` gathers the per-event handler tasks. Without\n+    ``return_exceptions=True`` the first exception aborts the gather and the\n+    sibling events in the same sync response are silently dropped.\n+    \"\"\"\n+\n+    @pytest.mark.asyncio\n+    async def test_dispatch_sync_isolates_failing_handler(self, caplog):\n+        import logging\n+\n+        adapter = _make_adapter()\n+        ran = {\"ok\": False}\n+\n+        async def _boom():\n+            raise RuntimeError(\"handler boom\")\n+\n+        async def _ok():\n+            ran[\"ok\"] = True\n+\n+        client = MagicMock()\n+        client.handle_sync = MagicMock(return_value=[_boom(), _ok()])\n+        adapter._client = client\n+\n+        with caplog.at_level(logging.WARNING):\n+            # Must not raise despite the failing handler.\n+            await adapter._dispatch_sync({\"next_batch\": \"s1\"})\n+\n+        assert ran[\"ok\"] is True  # the sibling handler still ran\n+        assert \"event handler failed\" in caplog.text  # failure surfaced, not swallowed"]},{"file":"tests/hermes_cli/test_web_server_files.py","hunks":["@@ -488,3 +488,75 @@ async def close(self):\n     # ... and no .upload temp file was left behind.\n     leftovers = [p.name for p in target.parent.iterdir() if \".upload\" in p.name]\n     assert leftovers == [], f\"temp upload files leaked on cancellation: {leftovers}\"\n+\n+\n+def test_sensitive_env_files_hidden_from_listing(forced_files_client):\n+    \"\"\"Regression test for #57505: .env files must not appear in directory listings.\"\"\"\n+    client, root = forced_files_client\n+\n+    # Create a regular file and .env variants including shorthand suffixes.\n+    root.mkdir(parents=True, exist_ok=True)\n+    regular = root / \"config.txt\"\n+    regular.write_text(\"safe content\")\n+    env_file = root / \".env\"\n+    env_file.write_text(\"SECRET_KEY=[redacted:secret-assignment]\")\n+    env_local = root / \".env.local\"\n+    env_local.write_text(\"LOCAL_SECRET=[redacted:secret-assignment]\")\n+    env_prod = root / \".env.prod\"\n+    env_prod.write_text(\"PROD_SECRET=[redacted:secret-assignment]\")\n+\n+    listing = client.get(\"/api/files\", params={\"path\": str(root)})\n+    assert listing.status_code == 200\n+    names = [e[\"name\"] for e in listing.json()[\"entries\"]]\n+    assert \"config.txt\" in names\n+    assert \".env\" not in names\n+    assert \".env.local\" not in names\n+    assert \".env.prod\" not in names\n+\n+\n+def test_sensitive_env_files_blocked_read(forced_files_client):\n+    \"\"\"Regression test for #57505: .env files must not be readable.\"\"\"\n+    client, root = forced_files_client\n+\n+    root.mkdir(parents=True, exist_ok=True)\n+    env_file = root / \".env\"\n+    env_file.write_text(\"SECRET_KEY=[redacted:secret-assignment]\")\n+\n+    resp = client.get(\"/api/files/read\", params={\"path\": str(env_file)})\n+    assert resp.status_code == 403\n+\n+\n+def test_sensitive_env_files_blocked_download(forced_files_client):\n+    \"\"\"Regression test for #57505: .env files must not be downloadable.\"\"\"\n+    client, root = forced_files_client\n+\n+    root.mkdir(parents=True, exist_ok=True)\n+    env_file = root / \".env\"\n+    env_file.write_text(\"SECRET_KEY=[redacted:secret-assignment]\")\n+\n+    resp = client.get(\"/api/files/download\", params={\"path\": str(env_file)})\n+    assert resp.status_code == 403\n+\n+\n+def test_sensitive_env_suffix_variants_blocked(forced_files_client):\n+    \"\"\"Regression: .env.<suffix> shorthand variants (e.g. .env.prod) must also be blocked.\"\"\"\n+    client, root = forced_files_client\n+\n+    root.mkdir(parents=True, exist_ok=True)\n+    for suffix in (\"prod\", \"dev\", \"staging.local\", \"ci\"):\n+        p = root / f\".env.{suffix}\"\n+        p.write_text(f\"SECRET_{suffix}=abc123\")\n+        assert client.get(\"/api/files/read\", params={\"path\": str(p)}).status_code == 403\n+        assert client.get(\"/api/files/download\", params={\"path\": str(p)}).status_code == 403\n+\n+\n+def test_sensitive_env_case_insensitive_blocked(forced_files_client):\n+    \"\"\"Regression: .ENV / .Env.local casings must be blocked too (case-insensitive FS mounts).\"\"\"\n+    client, root = forced_files_client\n+\n+    root.mkdir(parents=True, exist_ok=True)\n+    for name in (\".ENV\", \".Env.local\", \".eNv.PROD\"):\n+        p = root / name\n+        p.write_text(\"SECRET=[redacted:secret-assignment]\")\n+        assert client.get(\"/api/files/read\", params={\"path\": str(p)}).status_code == 403\n+        assert client.get(\"/api/files/download\", params={\"path\": str(p)}).status_code == 403"]},{"file":"tests/tools/test_browser_camofox_private_page_guard.py","hunks":["@@ -83,6 +83,34 @@ def fail_http(*_args, **_kwargs):\n     assert action_phrase in out[\"error\"]\n \n \n[redacted:email](\n+    (\"tool_call\", \"action_phrase\"),\n+    [\n+        (lambda: browser_camofox.camofox_click(\"@e1\", task_id=\"t1\"), \"click\"),\n+        (\n+            lambda: browser_camofox.camofox_type(\"@e1\", \"do-not-send-this\", task_id=\"t1\"),\n+            \"type\",\n+        ),\n+        (lambda: browser_camofox.camofox_press(\"Enter\", task_id=\"t1\"), \"press\"),\n+    ],\n+)\n+def test_private_page_blocks_camofox_input_actions(monkeypatch, _session, tool_call, action_phrase):\n+    _block_active(monkeypatch)\n+\n+    def fail_post(*_args, **_kwargs):\n+        raise AssertionError(\"Camofox action HTTP call should not run on a private page\")\n+\n+    monkeypatch.setattr(browser_camofox, \"_post\", fail_post)\n+\n+    out = json.loads(tool_call())\n+\n+    assert out[\"success\"] is False\n+    assert PRIVATE_URL in out[\"error\"]\n+    assert \"private or internal address\" in out[\"error\"]\n+    assert action_phrase in out[\"error\"]\n+    assert \"do-not-send-this\" not in json.dumps(out)\n+\n+\n def test_snapshot_still_runs_when_page_is_public(monkeypatch, _session):\n     _public_page(monkeypatch)\n ","@@ -98,6 +126,29 @@ def test_snapshot_still_runs_when_page_is_public(monkeypatch, _session):\n     assert out[\"element_count\"] == 1\n \n \n+def test_camofox_click_still_runs_when_page_is_public(monkeypatch, _session):\n+    _public_page(monkeypatch)\n+    calls = []\n+\n+    def fake_post(path, body=None, timeout=None):\n+        calls.append((path, body, timeout))\n+        return {\"url\": \"https://example.test/\"}\n+\n+    monkeypatch.setattr(browser_camofox, \"_post\", fake_post)\n+\n+    out = json.loads(browser_camofox.camofox_click(\"@e1\", task_id=\"t1\"))\n+\n+    assert out[\"success\"] is True\n+    assert out[\"clicked\"] == \"e1\"\n+    assert calls == [\n+        (\n+            \"/tabs/tab-1/click\",\n+            {\"userId\": \"user-1\", \"ref\": \"e1\"},\n+            None,\n+        )\n+    ]\n+\n+\n def test_guard_inactive_does_not_probe(monkeypatch, _session):\n     \"\"\"When the SSRF guard is inactive the read proceeds WITHOUT probing the URL.\n "]},{"file":"tests/tools/test_browser_cdp_tool.py","hunks":["@@ -430,6 +430,70 @@ async def fake_call(*args, **kwargs):\n     assert calls == []\n \n \n+def test_frame_id_route_blocked_when_current_page_is_private(monkeypatch):\n+    \"\"\"frame_id routing (OOPIF via supervisor) must not bypass the guard\n+    applied to the stateless path — same private-page boundary either way.\"\"\"\n+    supervisor_calls = []\n+\n+    import tools.browser_tool as bt\n+\n+    monkeypatch.setattr(bt, \"_eval_ssrf_guard_active\", lambda task_id: True)\n+    monkeypatch.setattr(bt, \"_current_page_private_url\", lambda task_id: PRIVATE_URL)\n+\n+    def fake_supervisor_route(**kwargs):\n+        supervisor_calls.append(kwargs)\n+        return json.dumps({\"success\": True, \"result\": {\"value\": \"private data\"}})\n+\n+    monkeypatch.setattr(\n+        browser_cdp_tool, \"_browser_cdp_via_supervisor\", fake_supervisor_route\n+    )\n+\n+    result = json.loads(\n+        browser_cdp_tool.browser_cdp(\n+            method=\"Runtime.evaluate\",\n+            params={\"expression\": \"document.body.innerText\"},\n+            frame_id=\"frame-1\",\n+            task_id=\"task-1\",\n+        )\n+    )\n+\n+    assert \"error\" in result\n+    assert PRIVATE_URL in result[\"error\"]\n+    assert \"private or internal address\" in result[\"error\"]\n+    assert supervisor_calls == []\n+\n+\n+def test_frame_id_route_allowed_when_page_is_not_private(monkeypatch):\n+    \"\"\"Sanity check: the new guard call must not block ordinary frame_id\n+    routing when the current page isn't private.\"\"\"\n+    supervisor_calls = []\n+\n+    import tools.browser_tool as bt\n+\n+    monkeypatch.setattr(bt, \"_eval_ssrf_guard_active\", lambda task_id: True)\n+    monkeypatch.setattr(bt, \"_current_page_private_url\", lambda task_id: None)\n+\n+    def fake_supervisor_route(**kwargs):\n+        supervisor_calls.append(kwargs)\n+        return json.dumps({\"success\": True, \"result\": {\"value\": \"ok\"}})\n+\n+    monkeypatch.setattr(\n+        browser_cdp_tool, \"_browser_cdp_via_supervisor\", fake_supervisor_route\n+    )\n+\n+    result = json.loads(\n+        browser_cdp_tool.browser_cdp(\n+            method=\"Runtime.evaluate\",\n+            params={\"expression\": \"document.title\"},\n+            frame_id=\"frame-1\",\n+            task_id=\"task-1\",\n+        )\n+    )\n+\n+    assert result.get(\"success\") is True\n+    assert len(supervisor_calls) == 1\n+\n+\n def test_page_navigate_to_private_url_blocked_before_cdp(monkeypatch):\n     calls = []\n "]},{"file":"tests/tools/test_delegate.py","hunks":["@@ -78,6 +78,12 @@ def test_schema_valid(self):\n         # config-authoritative via delegation.max_iterations so users get\n         # predictable budgets.\n         self.assertNotIn(\"max_iterations\", props)\n+        # ACP subprocess transport is operator-controlled via config.yaml, not\n+        # model-controlled via delegate_task arguments.\n+        self.assertNotIn(\"acp_command\", props)\n+        self.assertNotIn(\"acp_args\", props)\n+        self.assertNotIn(\"acp_command\", props[\"tasks\"][\"items\"][\"properties\"])\n+        self.assertNotIn(\"acp_args\", props[\"tasks\"][\"items\"][\"properties\"])\n         self.assertNotIn(\"maxItems\", props[\"tasks\"])  # removed — limit is now runtime-configurable\n \n     def test_schema_description_advertises_runtime_limits(self):","@@ -522,16 +528,7 @@ def test_build_child_agent_does_not_raise_name_error(self):\n                 )\n \n     def test_build_child_agent_ignores_acp_command_when_binary_missing(self):\n-        \"\"\"Regression: _build_child_agent must not force provider='copilot-acp'\n-        when the override_acp_command binary is not on PATH.\n-\n-        Without this guard, a model that hallucinates\n-        ``delegate_task(acp_command=\"copilot\")`` on a host without the Copilot\n-        CLI installed (Railway / headless containers / fresh VPS) would route\n-        the subagent through CopilotACPClient, which spawns the binary via\n-        subprocess and raises RuntimeError. After 3 retries the asyncio loop\n-        teardown can take the entire gateway down.\n-        \"\"\"\n+        \"\"\"Stale delegation.command config must not force ACP subprocess mode.\"\"\"\n         parent = _make_mock_parent(depth=0)\n         # The crash scenario is a TG/cron agent on a host with no ACP CLI —\n         # parent itself has no acp_command, so clearing the override must NOT","@@ -606,68 +603,20 @@ def test_build_child_agent_honors_acp_command_when_binary_present(self):\n         self.assertEqual(captured[\"provider\"], \"copilot-acp\")\n         self.assertEqual(captured[\"acp_command\"], \"copilot\")\n \n-    def test_schema_prunes_acp_command_when_no_acp_binary(self):\n-        \"\"\"Schema-level defense: delegate_task tool schema must NOT advertise\n-        acp_command / acp_args to the model when no ACP binary is installed.\n-\n-        Headless deploys (Railway / Fly / Docker / fresh VPS) typically have\n-        none of copilot / claude / codex. Without the schema prune, models\n-        occasionally hallucinate ``acp_command=\"copilot\"`` from the field's\n-        description and crash subagent runs.\n-        \"\"\"\n-        from tools.delegate_tool import _build_dynamic_schema_overrides\n-\n-        with patch(\"tools.delegate_tool._acp_binary_available\", return_value=False):\n-            overrides = _build_dynamic_schema_overrides()\n-\n-        props = overrides[\"parameters\"][\"properties\"]\n-        self.assertNotIn(\"acp_command\", props, \"top-level acp_command must be pruned\")\n-        self.assertNotIn(\"acp_args\", props, \"top-level acp_args must be pruned\")\n-\n-        task_item_props = props[\"tasks\"][\"items\"][\"properties\"]\n-        self.assertNotIn(\n-            \"acp_command\", task_item_props, \"per-task acp_command must be pruned\"\n-        )\n-        self.assertNotIn(\n-            \"acp_args\", task_item_props, \"per-task acp_args must be pruned\"\n-        )\n-\n-    def test_schema_keeps_acp_command_when_binary_available(self):\n-        \"\"\"Backward compat: when an ACP CLI IS on PATH, schema is unchanged.\n-        Users with working ACP setups must still be able to invoke it.\n-        \"\"\"\n+    def test_schema_never_exposes_acp_transport_fields(self):\n+        \"\"\"delegate_task must never make ACP transport model-facing.\"\"\"\n         from tools.delegate_tool import _build_dynamic_schema_overrides\n \n-        with patch(\"tools.delegate_tool._acp_binary_available\", return_value=True):\n+        with patch(\"shutil.which\", return_value=\"/usr/local/bin/copilot\"):\n             overrides = _build_dynamic_schema_overrides()\n \n         props = overrides[\"parameters\"][\"properties\"]\n-        self.assertIn(\"acp_command\", props)\n-        self.assertIn(\"acp_args\", props)\n+        self.assertNotIn(\"acp_command\", props)\n+        self.assertNotIn(\"acp_args\", props)\n \n         task_item_props = props[\"tasks\"][\"items\"][\"properties\"]\n-        self.assertIn(\"acp_command\", task_item_props)\n-        self.assertIn(\"acp_args\", task_item_props)\n-\n-    def test_acp_binary_available_checks_known_clis(self):\n-        \"\"\"_acp_binary_available must check the known ACP CLI names via\n-        shutil.which — guards against typos or accidental list trimming.\n-        \"\"\"\n-        from tools.delegate_tool import _KNOWN_ACP_BINARIES, _acp_binary_available\n-\n-        self.assertIn(\"copilot\", _KNOWN_ACP_BINARIES)\n-\n-        calls = []\n-\n-        def fake_which(name):\n-            calls.append(name)\n-            return None\n-\n-        with patch(\"shutil.which\", side_effect=fake_which):\n-            self.assertFalse(_acp_binary_available())\n-\n-        for name in _KNOWN_ACP_BINARIES:\n-            self.assertIn(name, calls)\n+        self.assertNotIn(\"acp_command\", task_item_props)\n+        self.assertNotIn(\"acp_args\", task_item_props)\n \n     def test_saved_tool_names_set_on_child_before_run(self):\n         \"\"\"_run_single_child must set _delegate_saved_tool_names on the child","@@ -2281,37 +2230,39 @@ def test_invalid_reasoning_effort_falls_back_to_parent(self, MockAgent, mock_cfg\n class TestDispatchDelegateTask(unittest.TestCase):\n     \"\"\"Tests for the _dispatch_delegate_task helper and full param forwarding.\"\"\"\n \n-    @patch(\"tools.delegate_tool._load_config\", return_value={})\n-    @patch(\"tools.delegate_tool._resolve_delegation_credentials\")\n-    def test_acp_args_forwarded(self, mock_creds, mock_cfg):\n-        \"\"\"Both acp_command and acp_args reach delegate_task via the helper.\"\"\"\n-        mock_creds.return_value = {\n-            \"provider\": None, \"base_url\": None,\n-            \"api_key\": None, \"api_mode\": None, \"model\": None,\n-        }\n-        parent = _make_mock_parent(depth=0)\n-        with patch(\"tools.delegate_tool._build_child_agent\") as mock_build:\n-            mock_child = MagicMock()\n-            mock_child.run_conversation.return_value = {\n-                \"final_response\": \"done\", \"completed\": True,\n-                \"api_calls\": 1, \"messages\": [],\n-            }\n-            mock_child._delegate_saved_tool_names = []\n-            mock_child._credential_pool = None\n-            mock_child.session_prompt_tokens = 0\n-            mock_child.session_completion_tokens = 0\n-            mock_child.model = \"test\"\n-            mock_build.return_value = mock_child\n+    def test_model_acp_args_not_forwarded(self):\n+        \"\"\"The live model dispatch path strips hidden ACP transport args.\"\"\"\n+        import run_agent\n \n-            delegate_task(\n-                goal=\"test\",\n-                acp_command=\"claude\",\n-                acp_args=[\"--acp\", \"--stdio\"],\n-                parent_agent=parent,\n+        captured = {}\n+\n+        def fake_delegate_task(**kwargs):\n+            captured.update(kwargs)\n+            return \"{}\"\n+\n+        parent = _make_mock_parent(depth=0)\n+        with patch(\"tools.delegate_tool.delegate_task\", fake_delegate_task):\n+            run_agent.AIAgent._dispatch_delegate_task(\n+                parent,\n+                {\n+                    \"goal\": \"test\",\n+                    \"acp_command\": \"claude\",\n+                    \"acp_args\": [\"--acp\", \"--stdio\"],\n+                    \"tasks\": [\n+                        {\n+                            \"goal\": \"nested\",\n+                            \"acp_command\": \"codex\",\n+                            \"acp_args\": [\"--acp\"],\n+                        },\n+                    ],\n+                },\n             )\n-            _, kwargs = mock_build.call_args\n-            self.assertEqual(kwargs[\"override_acp_command\"], \"claude\")\n-            self.assertEqual(kwargs[\"override_acp_args\"], [\"--acp\", \"--stdio\"])\n+\n+        self.assertNotIn(\"acp_command\", captured)\n+        self.assertNotIn(\"acp_args\", captured)\n+        self.assertEqual(captured[\"goal\"], \"test\")\n+        self.assertNotIn(\"acp_command\", captured[\"tasks\"][0])\n+        self.assertNotIn(\"acp_args\", captured[\"tasks\"][0])\n \n class TestDelegateEventEnum(unittest.TestCase):\n     \"\"\"Tests for DelegateEvent enum and back-compat aliases.\"\"\"","@@ -2600,31 +2551,15 @@ def test_schema_has_role_top_level_and_per_task(self):\n         self.assertIn(\"role\", task_props)\n         self.assertEqual(task_props[\"role\"][\"enum\"], [\"leaf\", \"orchestrator\"])\n \n-    def test_acp_command_description_has_do_not_set_guidance(self):\n-        # acp_command/acp_args descriptions must NOT bias the model toward\n-        # assuming an ACP CLI (Claude, Copilot, etc.) is installed. They must\n-        # carry explicit \"do not set unless told\" guidance so the model doesn't\n-        # hallucinate ACP availability (#22013).\n+    def test_schema_omits_acp_transport_fields(self):\n         from tools.delegate_tool import DELEGATE_TASK_SCHEMA\n         props = DELEGATE_TASK_SCHEMA[\"parameters\"][\"properties\"]\n \n-        top_acp_desc = props[\"acp_command\"][\"description\"]\n-        self.assertIn(\"Do NOT set\", top_acp_desc)\n-        self.assertIn(\"explicitly told you\", top_acp_desc)\n-\n         task_props = props[\"tasks\"][\"items\"][\"properties\"]\n-        per_task_acp_desc = task_props[\"acp_command\"][\"description\"]\n-        self.assertIn(\"Do NOT set\", per_task_acp_desc)\n-\n-    def test_acp_command_description_has_no_claude_as_example(self):\n-        # Descriptions must not list 'claude' as a canonical example value —\n-        # that directly primes the model to attempt Claude ACP even when it is\n-        # not installed (#22013).\n-        from tools.delegate_tool import DELEGATE_TASK_SCHEMA\n-        props = DELEGATE_TASK_SCHEMA[\"parameters\"][\"properties\"]\n-        top_acp_desc = props[\"acp_command\"][\"description\"].lower()\n-        self.assertNotIn(\"e.g. 'claude'\", top_acp_desc)\n-        self.assertNotIn(\"e.g. \\\"claude\\\"\", top_acp_desc)\n+        self.assertNotIn(\"acp_command\", props)\n+        self.assertNotIn(\"acp_args\", props)\n+        self.assertNotIn(\"acp_command\", task_props)\n+        self.assertNotIn(\"acp_args\", task_props)\n \n \n # Sentinel used to distinguish \"role kwarg omitted\" from \"role=None\"."]},{"file":"tools/browser_camofox.py","hunks":["@@ -653,6 +653,10 @@ def camofox_click(ref: str, task_id: Optional[str] = None) -> str:\n         if not session[\"tab_id\"]:\n             return tool_error(\"No browser session. Call browser_navigate first.\", success=False)\n \n+        blocked = _camofox_private_page_block(session, task_id, \"click\")\n+        if blocked:\n+            return blocked\n+\n         # Strip @ prefix if present (our tool convention)\n         clean_ref = ref.lstrip(\"@\")\n ","@@ -676,6 +680,10 @@ def camofox_type(ref: str, text: str, task_id: Optional[str] = None) -> str:\n         if not session[\"tab_id\"]:\n             return tool_error(\"No browser session. Call browser_navigate first.\", success=False)\n \n+        blocked = _camofox_private_page_block(session, task_id, \"type\")\n+        if blocked:\n+            return blocked\n+\n         clean_ref = ref.lstrip(\"@\")\n \n         _post(","@@ -745,6 +753,10 @@ def camofox_press(key: str, task_id: Optional[str] = None) -> str:\n         if not session[\"tab_id\"]:\n             return tool_error(\"No browser session. Call browser_navigate first.\", success=False)\n \n+        blocked = _camofox_private_page_block(session, task_id, \"press\")\n+        if blocked:\n+            return blocked\n+\n         _post(\n             f\"/tabs/{session['tab_id']}/press\",\n             {\"userId\": session[\"user_id\"], \"key\": key},"]},{"file":"tools/browser_cdp_tool.py","hunks":["@@ -428,6 +428,15 @@ def browser_cdp(\n \n     # --- Route iframe-scoped calls through the supervisor ---------------\n     if frame_id:\n+        # Same private-page/SSRF boundary as the stateless path below —\n+        # frame_id routing must not become the sibling bypass for it.\n+        blocked = _browser_cdp_private_guard(\n+            task_id=effective_task_id,\n+            method=method,\n+            params=params or {},\n+        )\n+        if blocked:\n+            return blocked\n         return _browser_cdp_via_supervisor(\n             task_id=effective_task_id,\n             frame_id=frame_id,"]},{"file":"tools/delegate_tool.py","hunks":["@@ -1055,7 +1055,7 @@ def _build_child_agent(\n     override_base_url: Optional[str] = None,\n     override_api_key: [redacted:secret-assignment] = None,\n     override_api_mode: Optional[str] = None,\n-    # ACP transport overrides — lets a non-ACP parent spawn ACP child agents\n+    # ACP transport overrides from trusted delegation config.\n     override_acp_command: Optional[str] = None,\n     override_acp_args: Optional[List[str]] = None,\n     # Per-call role controlling whether the child can further delegate.","@@ -1212,11 +1212,9 @@ def _child_thinking(text: str) -> None:\n         effective_api_mode = None  # force re-derivation from provider's defaults\n     else:\n         effective_api_mode = getattr(parent_agent, \"api_mode\", None)\n-    # Defensive: validate override_acp_command exists on PATH before honoring\n-    # it. Models occasionally pass acp_command=\"copilot\" / \"claude\" / etc. in\n-    # delegate_task tool calls despite the schema saying not to, which forces\n-    # the subagent onto the copilot-acp transport below and crashes the\n-    # gateway when the binary is missing (e.g. headless container deploys).\n+    # Defensive: validate trusted delegation.command exists on PATH before\n+    # honoring it. Stale config should not force a child onto the ACP transport\n+    # and then fail at subprocess startup.\n     if override_acp_command:\n         import shutil as _shutil\n ","@@ -2346,8 +2344,6 @@ def delegate_task(\n     context: Optional[str] = None,\n     tasks: Optional[List[Dict[str, Any]]] = None,\n     max_iterations: Optional[int] = None,\n-    acp_command: Optional[str] = None,\n-    acp_args: Optional[List[str]] = None,\n     role: Optional[str] = None,\n     background: Optional[bool] = None,\n     parent_agent=None,","@@ -2486,7 +2482,6 @@ def delegate_task(\n     children = []\n     try:\n         for i, t in enumerate(task_list):\n-            task_acp_args = t.get(\"acp_args\") if \"acp_args\" in t else None\n             # Per-task role beats top-level; normalise again so unknown\n             # per-task values warn and degrade to leaf uniformly.\n             effective_role = _normalize_role(t.get(\"role\") or top_role)","@@ -2505,14 +2500,8 @@ def delegate_task(\n                 override_base_url=creds[\"base_url\"],\n                 override_api_key=[redacted:secret-assignment]\"api_key\"],\n                 override_api_mode=creds[\"api_mode\"],\n-                override_acp_command=t.get(\"acp_command\")\n-                or acp_command\n-                or creds.get(\"command\"),\n-                override_acp_args=(\n-                    task_acp_args\n-                    if task_acp_args is not None\n-                    else (acp_args if acp_args is not None else creds.get(\"args\"))\n-                ),\n+                override_acp_command=creds.get(\"command\"),\n+                override_acp_args=creds.get(\"args\"),\n                 role=effective_role,\n             )\n             # Override with correct parent tool names (before child construction mutated global)","@@ -3292,30 +3281,6 @@ def _build_role_param_description() -> str:\n     )\n \n \n-# Known ACP-compatible CLIs that delegate_task can shell out to. Kept\n-# narrow on purpose: only the ones agent/copilot_acp_client.py and friends\n-# actually understand. Add new entries here when a new ACP CLI ships.\n-_KNOWN_ACP_BINARIES: tuple[str, ...] = (\"copilot\", \"claude\", \"codex\")\n-\n-\n-def _acp_binary_available() -> bool:\n-    \"\"\"True iff at least one known ACP CLI is on PATH.\n-\n-    Used to gate inclusion of ``acp_command`` / ``acp_args`` in the\n-    delegate_task schema. On headless hosts (Railway / Fly / Docker /\n-    fresh VPS) without any of these binaries, exposing the fields invites\n-    the model to hallucinate ``acp_command=\"copilot\"`` from the schema's\n-    description, which used to crash subagent runs and take the gateway\n-    down. Pruning the fields from the schema removes the temptation.\n-\n-    Not cached: ``shutil.which`` is cheap and we want the schema to react\n-    to mid-session installs without forcing a process restart.\n-    \"\"\"\n-    import shutil as _shutil\n-\n-    return any(_shutil.which(name) for name in _KNOWN_ACP_BINARIES)\n-\n-\n def _build_dynamic_schema_overrides() -> dict:\n     \"\"\"Return per-call schema overrides reflecting current config.\n ","@@ -3333,24 +3298,6 @@ def _build_dynamic_schema_overrides() -> dict:\n     overrides_params[\"properties\"][\"tasks\"][\"description\"] = _build_tasks_param_description()\n     overrides_params[\"properties\"][\"role\"][\"description\"] = _build_role_param_description()\n \n-    # Prune ACP overrides from the schema when no known ACP CLI is on PATH.\n-    # The runtime guard in _build_child_agent remains as defense-in-depth for\n-    # internal callers / tests / future code paths that skip the schema layer.\n-    if not _acp_binary_available():\n-        overrides_params[\"properties\"].pop(\"acp_command\", None)\n-        overrides_params[\"properties\"].pop(\"acp_args\", None)\n-        tasks_schema = dict(overrides_params[\"properties\"].get(\"tasks\", {}))\n-        if \"items\" in tasks_schema:\n-            items = dict(tasks_schema[\"items\"])\n-            if \"properties\" in items:\n-                items[\"properties\"] = {\n-                    k: v\n-                    for k, v in items[\"properties\"].items()\n-                    if k not in (\"acp_command\", \"acp_args\")\n-                }\n-            tasks_schema[\"items\"] = items\n-            overrides_params[\"properties\"][\"tasks\"] = tasks_schema\n-\n     return {\n         \"description\": _build_top_level_description(),\n         \"parameters\": overrides_params,","@@ -3401,19 +3348,6 @@ def _build_dynamic_schema_overrides() -> dict:\n                             \"type\": \"string\",\n                             \"description\": \"Task-specific context\",\n                         },\n-                        \"acp_command\": {\n-                            \"type\": \"string\",\n-                            \"description\": (\n-                                \"Per-task ACP command override (e.g. 'copilot'). \"\n-                                \"Overrides the top-level acp_command for this task only. \"\n-                                \"Do NOT set unless the user explicitly told you an ACP CLI is installed.\"\n-                            ),\n-                        },\n-                        \"acp_args\": {\n-                            \"type\": \"array\",\n-                            \"items\": {\"type\": \"string\"},\n-                            \"description\": \"Per-task ACP args override. Leave empty unless acp_command is set.\",\n-                        },\n                         \"role\": {\n                             \"type\": \"string\",\n                             \"enum\": [\"leaf\", \"orchestrator\"],","@@ -3444,28 +3378,6 @@ def _build_dynamic_schema_overrides() -> dict:\n                     \"compatibility.\"\n                 ),\n             },\n-            \"acp_command\": {\n-                \"type\": \"string\",\n-                \"description\": (\n-                    \"Override ACP command for child agents (e.g. 'copilot'). \"\n-                    \"When set, children use ACP subprocess transport instead of inheriting \"\n-                    \"the parent's transport. Requires an ACP-compatible CLI \"\n-                    \"(currently GitHub Copilot CLI via 'copilot --acp --stdio'). \"\n-                    \"See agent/copilot_acp_client.py for the implementation. \"\n-                    \"IMPORTANT: Do NOT set this unless the user has explicitly told you \"\n-                    \"a specific ACP-compatible CLI is installed and configured. \"\n-                    \"Leave empty to use the parent's default transport (Hermes subagents).\"\n-                ),\n-            },\n-            \"acp_args\": {\n-                \"type\": \"array\",\n-                \"items\": {\"type\": \"string\"},\n-                \"description\": (\n-                    \"Arguments for the ACP command (default: ['--acp', '--stdio']). \"\n-                    \"Only used when acp_command is set. \"\n-                    \"Leave empty unless acp_command is explicitly provided.\"\n-                ),\n-            },\n         },\n         \"required\": [],\n     },","@@ -3492,6 +3404,28 @@ def _model_background_value(args: dict, parent_agent=None) -> bool:\n     return not is_subagent\n \n \n+_MODEL_HIDDEN_TASK_FIELDS = {\"acp_command\", \"acp_args\"}\n+\n+\n+def _strip_model_hidden_task_fields(tasks: Any) -> Any:\n+    if not isinstance(tasks, list):\n+        return tasks\n+    stripped_tasks = []\n+    changed = False\n+    for task in tasks:\n+        if not isinstance(task, dict):\n+            stripped_tasks.append(task)\n+            continue\n+        stripped = {\n+            key: value\n+            for key, value in task.items()\n+            if key not in _MODEL_HIDDEN_TASK_FIELDS\n+        }\n+        changed = changed or len(stripped) != len(task)\n+        stripped_tasks.append(stripped)\n+    return stripped_tasks if changed else tasks\n+\n+\n registry.register(\n     name=\"delegate_task\",\n     toolset=\"delegation\",","@@ -3499,10 +3433,8 @@ def _model_background_value(args: dict, parent_agent=None) -> bool:\n     handler=lambda args, **kw: delegate_task(\n         goal=args.get(\"goal\"),\n         context=args.get(\"context\"),\n-        tasks=args.get(\"tasks\"),\n+        tasks=_strip_model_hidden_task_fields(args.get(\"tasks\")),\n         max_iterations=args.get(\"max_iterations\"),\n-        acp_command=args.get(\"acp_command\"),\n-        acp_args=args.get(\"acp_args\"),\n         role=args.get(\"role\"),\n         background=_model_background_value(args, kw.get(\"parent_agent\")),\n         parent_agent=kw.get(\"parent_agent\"),\n"]}]},{"kind":"pr","number":58726,"title":"fix(dashboard): confine fs preview and git diff reads to the managed root","url":null,"state":"closed","close_reason":{"category":"merged_via","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}