{"repo":"NousResearch/hermes-agent","public_id":"14073","display_label":"Orphan reaper PID file trust security fix","summary":"A security vulnerability was identified where the browser orphan reaper trusted /tmp PID files without verification, potentially allowing SIGTERM of arbitrary same-user processes. The members address adding daemon identity or PID verification before the orphan reaper acts.","subsystem":"agent","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":50417,"merged_at":"2026-06-21T22:23:48+00:00"},"resolution":{"state":"resolved","by":"pr-50417"},"freshness":{"state":"verified","last_verified_at":"2026-06-21T22:23:48+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":14073,"pr_number":50417,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-21T22:23:48+00:00"}],"members":[{"kind":"issue","number":14073,"title":"Security: browser orphan reaper trusts /tmp PID files and can SIGTERM arbitrary same-user processes","url":null,"state":"closed"},{"kind":"pr","number":14394,"title":"fix(browser): verify daemon pid before orphan reap","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#50417","number":50417}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":50417,"title":"fix(browser): verify daemon identity before orphan reaper kills a PID (#14073)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tools/test_browser_orphan_reaper.py","tools/browser_tool.py"],"fix_hunks":[{"file":"tests/tools/test_browser_orphan_reaper.py","hunks":["@@ -85,7 +85,10 @@ def mock_terminate(pid):\n         # Post-#21561 the liveness probe goes through\n         # ``gateway.status._pid_exists`` (which wraps ``psutil.pid_exists``\n         # so it's safe on Windows — ``os.kill(pid, 0)`` is bpo-14484).\n+        # The identity guard (#14073) is mocked True here — its own behavior\n+        # is covered by TestReaperIdentityGuard below.\n         with patch(\"gateway.status._pid_exists\", return_value=True), \\\n+             patch(\"tools.browser_tool._verify_reapable_browser_daemon\", return_value=True), \\\n              patch(\"tools.process_registry.ProcessRegistry._terminate_host_pid\", side_effect=mock_terminate):\n             _reap_orphaned_browser_sessions()\n ","@@ -136,6 +139,7 @@ def mock_terminate(pid):\n             terminate_calls.append(pid)\n \n         with patch(\"gateway.status._pid_exists\", return_value=True), \\\n+             patch(\"tools.browser_tool._verify_reapable_browser_daemon\", return_value=True), \\\n              patch(\"tools.process_registry.ProcessRegistry._terminate_host_pid\", side_effect=mock_terminate):\n             _reap_orphaned_browser_sessions()\n ","@@ -229,6 +233,7 @@ def mock_terminate(pid):\n         pid_alive = {999999999: False, 12345: True}\n         with patch(\"gateway.status._pid_exists\",\n                    side_effect=lambda pid: pid_alive.get(int(pid), False)), \\\n+             patch(\"tools.browser_tool._verify_reapable_browser_daemon\", return_value=True), \\\n              patch(\"tools.process_registry.ProcessRegistry._terminate_host_pid\", side_effect=mock_terminate):\n             _reap_orphaned_browser_sessions()\n ","@@ -380,6 +385,133 @@ def _spy(*a, **kw):\n         assert session_name in socket_dir_arg\n \n \n+class TestReaperIdentityGuard:\n+    \"\"\"Tests for _verify_reapable_browser_daemon — the #14073 fix.\n+\n+    The reaper reads daemon PIDs from world-writable, predictably-named temp\n+    dirs.  Before tree-killing a live PID it must confirm the process really is\n+    *this* session's agent-browser daemon, defeating planted pid files and\n+    recycled PIDs that would otherwise become an arbitrary same-user DoS.\n+    \"\"\"\n+\n+    class _FakeProc:\n+        def __init__(self, name=\"agent-browser\", cmdline=None, environ=None,\n+                     raise_environ=False):\n+            self._name = name\n+            self._cmdline = cmdline if cmdline is not None else []\n+            self._environ = environ or {}\n+            self._raise_environ = raise_environ\n+\n+        def name(self):\n+            return self._name\n+\n+        def cmdline(self):\n+            return self._cmdline\n+\n+        def environ(self):\n+            if self._raise_environ:\n+                import psutil\n+                raise psutil.AccessDenied()\n+            return self._environ\n+\n+    def _run(self, fake_proc, socket_dir, session_name=\"h_sess123456\",\n+             daemon_pid=12345, no_such=False, access_denied=False):\n+        import psutil\n+        from tools.browser_tool import _verify_reapable_browser_daemon\n+\n+        def _factory(pid):\n+            if no_such:\n+                raise psutil.NoSuchProcess(pid)\n+            if access_denied:\n+                raise psutil.AccessDenied(pid)\n+            return fake_proc\n+\n+        with patch(\"psutil.Process\", side_effect=_factory):\n+            return _verify_reapable_browser_daemon(\n+                daemon_pid, socket_dir, session_name)\n+\n+    def test_real_daemon_bound_via_cmdline_is_reapable(self):\n+        socket_dir = \"/tmp/agent-browser-h_sess123456\"\n+        proc = self._FakeProc(\n+            name=\"agent-browser\",\n+            cmdline=[\"agent-browser\", \"open\", \"--session\", \"h_sess123456\",\n+                     \"--socket-dir\", socket_dir],\n+        )\n+        assert self._run(proc, socket_dir) is True\n+\n+    def test_daemon_bound_via_environ_is_reapable(self):\n+        socket_dir = \"/tmp/agent-browser-h_sess123456\"\n+        proc = self._FakeProc(\n+            name=\"agent-browser-linux-x64\",\n+            cmdline=[\"agent-browser-linux-x64\", \"daemon\"],  # no dir in cmd\n+            environ={\"AGENT_BROWSER_SOCKET_DIR\": socket_dir},\n+        )\n+        assert self._run(proc, socket_dir) is True\n+\n+    def test_planted_pid_for_non_browser_process_is_refused(self):\n+        \"\"\"A planted .pid pointing at e.g. `sleep 600` must NOT be reaped.\"\"\"\n+        socket_dir = \"/tmp/agent-browser-h_sess123456\"\n+        proc = self._FakeProc(name=\"sleep\", cmdline=[\"/bin/sleep\", \"600\"])\n+        assert self._run(proc, socket_dir) is False\n+\n+    def test_recycled_pid_browser_not_bound_to_our_dir_is_refused(self):\n+        \"\"\"An agent-browser process for a DIFFERENT session must not be reaped.\n+\n+        Models PID reuse / a concurrent unrelated daemon: it looks like\n+        agent-browser but is bound to another socket dir.\n+        \"\"\"\n+        socket_dir = \"/tmp/agent-browser-h_sess123456\"\n+        proc = self._FakeProc(\n+            name=\"agent-browser\",\n+            cmdline=[\"agent-browser\", \"open\", \"--session\", \"h_OTHER999\",\n+                     \"--socket-dir\", \"/tmp/agent-browser-h_OTHER999\"],\n+            environ={\"AGENT_BROWSER_SOCKET_DIR\":\n+                     \"/tmp/agent-browser-h_OTHER999\"},\n+        )\n+        assert self._run(proc, socket_dir) is False\n+\n+    def test_browser_name_but_environ_denied_and_no_cmdline_bind_refused(self):\n+        \"\"\"Looks like browser, cmdline doesn't bind, environ() denied -> refuse.\"\"\"\n+        socket_dir = \"/tmp/agent-browser-h_sess123456\"\n+        proc = self._FakeProc(\n+            name=\"agent-browser\",\n+            cmdline=[\"agent-browser\", \"daemon\"],  # no dir\n+            raise_environ=True,\n+        )\n+        assert self._run(proc, socket_dir) is False\n+\n+    def test_vanished_process_is_not_reapable(self):\n+        socket_dir = \"/tmp/agent-browser-h_sess123456\"\n+        assert self._run(None, socket_dir, no_such=True) is False\n+\n+    def test_access_denied_on_identity_read_refuses(self):\n+        socket_dir = \"/tmp/agent-browser-h_sess123456\"\n+        assert self._run(None, socket_dir, access_denied=True) is False\n+\n+    def test_planted_pid_survives_full_reaper_path(self, fake_tmpdir):\n+        \"\"\"End-to-end through the reaper: a planted non-browser PID is spared.\n+\n+        No owner_pid (legacy path), not tracked, PID 'alive' — but the live\n+        process is `sleep`, not agent-browser, so it must be left alone and the\n+        socket dir retained.\n+        \"\"\"\n+        from tools.browser_tool import _reap_orphaned_browser_sessions\n+\n+        d = _make_socket_dir(fake_tmpdir, \"h_planted9999\", pid=12345)\n+\n+        terminate_calls = []\n+        proc = self._FakeProc(name=\"sleep\", cmdline=[\"/bin/sleep\", \"600\"])\n+\n+        with patch(\"gateway.status._pid_exists\", return_value=True), \\\n+             patch(\"psutil.Process\", return_value=proc), \\\n+             patch(\"tools.process_registry.ProcessRegistry._terminate_host_pid\",\n+                   side_effect=lambda pid: terminate_calls.append(pid)):\n+            _reap_orphaned_browser_sessions()\n+\n+        assert terminate_calls == [], \"planted non-browser PID must not be killed\"\n+        assert d.exists(), \"socket dir retained for a later sweep\"\n+\n+\n class TestEmergencyCleanupRunsReaper:\n     \"\"\"Verify atexit-registered cleanup sweeps orphans even without an active session.\"\"\"\n "]},{"file":"tools/browser_tool.py","hunks":["@@ -1320,6 +1320,92 @@ def _write_owner_pid(socket_dir: str, session_name: str) -> None:\n                      session_name, exc)\n \n \n+def _verify_reapable_browser_daemon(daemon_pid: int, socket_dir: str,\n+                                    session_name: str) -> bool:\n+    \"\"\"Confirm a live PID is genuinely *this* session's agent-browser daemon.\n+\n+    The orphan reaper scans world-writable, predictably-named temp paths\n+    (``/tmp/agent-browser-h_*`` etc.) and reads a daemon PID from a ``.pid``\n+    file we do not write ourselves — the agent-browser daemon writes it.  A\n+    same-user actor can therefore plant a fake socket dir whose ``.pid`` points\n+    at an arbitrary victim process, or a recycled PID can land on an unrelated\n+    process after the real daemon exits.  Either way, terminating that PID\n+    (a *tree* kill via ``_terminate_host_pid``) is an arbitrary-process DoS.\n+\n+    Before reaping we require, via ``psutil`` (a hard dependency, cross-platform\n+    for same-user processes — the only processes the reaper can signal):\n+\n+      1. **Identity** — the process looks like agent-browser: ``agent-browser``\n+         appears in its name or command line.\n+      2. **Binding** — the process is bound to *this* session's socket dir: the\n+         socket dir path (or its basename) appears in the command line, or in\n+         ``AGENT_BROWSER_SOCKET_DIR`` in the process environment.\n+\n+    Requirement (2) is the real spoof defense: a planted process pointing at a\n+    victim PID will not have the victim's cmdline/environ referencing our\n+    socket dir.  An attacker would need a process that genuinely embeds this\n+    exact session path — i.e. a real daemon they already own and could signal\n+    directly.  Fail-closed: any ambiguity (unreadable cmdline, no match) means\n+    we refuse to reap and leave the process and its socket dir alone.\n+\n+    Returns ``True`` only when both checks pass.\n+    \"\"\"\n+    try:\n+        import psutil\n+    except ImportError:  # psutil is a hard dep; defensive only\n+        logger.warning(\n+            \"Refusing to reap browser daemon PID %d (session %s): \"\n+            \"psutil unavailable for identity verification\",\n+            daemon_pid, session_name)\n+        return False\n+\n+    try:\n+        proc = psutil.Process(daemon_pid)\n+        name = (proc.name() or \"\").lower()\n+        cmdline = \" \".join(proc.cmdline() or []).lower()\n+    except psutil.NoSuchProcess:\n+        # Vanished between the liveness check and now — nothing to reap.\n+        return False\n+    except (psutil.AccessDenied, OSError) as exc:\n+        logger.warning(\n+            \"Refusing to reap browser daemon PID %d (session %s): \"\n+            \"could not read process identity (%s)\",\n+            daemon_pid, session_name, exc)\n+        return False\n+\n+    looks_like_browser = \"agent-browser\" in name or \"agent-browser\" in cmdline\n+    if not looks_like_browser:\n+        logger.warning(\n+            \"Refusing to reap PID %d (session %s): not an agent-browser \"\n+            \"process (name=%r)\", daemon_pid, session_name, name)\n+        return False\n+\n+    # Binding check: the live process must reference *this* socket dir.\n+    socket_dir_l = socket_dir.lower()\n+    socket_base_l = os.path.basename(socket_dir).lower()\n+    bound = socket_dir_l in cmdline or (\n+        socket_base_l and socket_base_l in cmdline)\n+    if not bound:\n+        try:\n+            env_dir = (proc.environ() or {}).get(\n+                \"AGENT_BROWSER_SOCKET_DIR\", \"\")\n+            bound = bool(env_dir) and os.path.normpath(env_dir) == \\\n+                os.path.normpath(socket_dir)\n+        except (psutil.AccessDenied, psutil.NoSuchProcess, OSError):\n+            # environ() can be denied even same-user on some platforms.\n+            # cmdline already failed to bind — fail closed.\n+            bound = False\n+\n+    if not bound:\n+        logger.warning(\n+            \"Refusing to reap agent-browser PID %d: not bound to session \"\n+            \"socket dir %s (possible recycled PID or planted pid file)\",\n+            daemon_pid, socket_dir)\n+        return False\n+\n+    return True\n+\n+\n def _reap_orphaned_browser_sessions():\n     \"\"\"Scan for orphaned agent-browser daemon processes from previous runs.\n ","@@ -1415,6 +1501,17 @@ def _reap_orphaned_browser_sessions():\n             shutil.rmtree(socket_dir, ignore_errors=True)\n             continue\n \n+        # The PID is live — but the .pid file lives in a world-writable,\n+        # predictably-named temp dir we don't write ourselves, and PIDs get\n+        # recycled after the real daemon exits.  Verify the process really is\n+        # *this* session's agent-browser daemon before tree-killing it; refuse\n+        # otherwise (don't touch the process, leave the socket dir for a later\n+        # sweep once the imposter PID is gone).  Fixes the arbitrary same-user\n+        # process DoS in issue #14073.\n+        if not _verify_reapable_browser_daemon(\n+                daemon_pid, socket_dir, session_name):\n+            continue\n+\n         # Daemon is alive and its owner is dead (or legacy + untracked).  Reap.\n         # Use the process-tree termination helper so Chromium children\n         # (renderer, GPU, etc.) are cleaned up, not just the daemon parent.\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}