{"repo":"NousResearch/hermes-agent","public_id":"10141","display_label":"Hardening read_file device path blocking bypass","summary":"These issues and pull requests address a security vulnerability where the `_is_blocked_device` check in the `read_file` tool could be bypassed using device path aliases or symlinks due to use of `expanduser` instead of proper path normalization. The work involves tightening the path resolution logic to prevent circumvention of the device blocklist.","subsystem":"agent","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":50221},"resolution":{"state":"partially_resolved","by":"pr-50221"},"freshness":{"state":"verified","last_verified_at":"2026-06-21T18:11:20+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.7266666666666666},"fix_edges":[{"issue_number":10141,"pr_number":50221,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":29158,"pr_number":50221,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":10141,"title":"[Security] _is_blocked_device  verify bypass","url":null,"state":"closed"},{"kind":"issue","number":29158,"title":"[Security tracking] Denial of Service via Device Path Blocklist Bypass in `read_file` Tool (`_is_blocked_device` uses `expanduser` instead of `normpath`) (GHSA-3p9q-rxhm-5x3g)","url":null,"state":"closed"},{"kind":"pr","number":34466,"title":"fix(file): harden read_file device alias blocking","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"9078b4bbd","number":0},{"kind":"pr","value":"#50221","number":50221}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":50221,"title":"fix(file): harden read_file device alias blocking","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tools/test_file_read_guards.py","tools/file_tools.py"],"fix_hunks":[{"file":"tests/tools/test_file_read_guards.py","hunks":["@@ -109,6 +109,10 @@ def test_proc_legitimate_files_not_blocked(self):\n         for path in (\"/proc/cpuinfo\", \"/proc/meminfo\", \"/proc/uptime\", \"/proc/version\"):\n             self.assertFalse(_is_blocked_device(path), f\"{path} should not be blocked\")\n \n+    def test_normpath_alias_to_blocked_device_is_blocked(self):\n+        self.assertTrue(_is_blocked_device(\"/dev/../dev/zero\"))\n+        self.assertTrue(_is_blocked_device(\"/dev/./urandom\"))\n+\n     def test_normal_files_not_blocked(self):\n         self.assertFalse(_is_blocked_device(\"/tmp/test.py\"))\n         self.assertFalse(_is_blocked_device(\"/home/user/.bashrc\"))","@@ -134,6 +138,17 @@ def test_symlink_to_regular_file_not_blocked(self):\n                 self.skipTest(f\"symlink unavailable: {exc}\")\n             self.assertFalse(_is_blocked_device(link_path))\n \n+    def test_symlink_to_blocked_alias_is_blocked_before_realpath(self):\n+        if not os.path.exists(\"/dev/stdin\"):\n+            self.skipTest(\"/dev/stdin is not available on this platform\")\n+        with tempfile.TemporaryDirectory() as tmpdir:\n+            link_path = os.path.join(tmpdir, \"stdin-link\")\n+            try:\n+                os.symlink(\"/dev/../dev/stdin\", link_path)\n+            except OSError as exc:\n+                self.skipTest(f\"symlink unavailable: {exc}\")\n+            self.assertTrue(_is_blocked_device(link_path))\n+\n     def test_read_file_tool_rejects_device(self):\n         \"\"\"read_file_tool returns an error without any file I/O.\"\"\"\n         result = json.loads(read_file_tool(\"/dev/zero\", task_id=\"dev_test\"))"]},{"file":"tools/file_tools.py","hunks":["@@ -285,7 +285,7 @@ def _path_resolution_warning(filepath: str, resolved: Path, task_id: str = \"defa\n \n def _is_blocked_device_path(path: str) -> bool:\n     \"\"\"Return True for concrete device/fd paths that can hang reads.\"\"\"\n-    normalized = os.path.expanduser(path)\n+    normalized = os.path.normpath(os.path.expanduser(path))\n     if normalized in _BLOCKED_DEVICE_PATHS:\n         return True\n     # /proc/self/fd/0-2 and /proc/<pid>/fd/0-2 are Linux aliases for stdio","@@ -306,17 +306,35 @@ def _is_blocked_device(filepath: str) -> bool:\n     \"\"\"Return True if the path would hang the process (infinite output or blocking input).\n \n     Check the literal path first so aliases like /dev/stdin are caught before\n-    they resolve to terminal-specific paths. Then check the resolved path so a\n-    workspace symlink to /dev/zero cannot bypass the guard.\n+    they resolve to terminal-specific paths. Then check each symlink hop before\n+    the final resolved path so aliases to devices cannot bypass the guard.\n     \"\"\"\n-    normalized = os.path.expanduser(filepath)\n+    normalized = os.path.normpath(os.path.expanduser(filepath))\n     if _is_blocked_device_path(normalized):\n         return True\n+\n+    seen: set[str] = set()\n+    current = normalized\n+    for _ in range(20):\n+        try:\n+            target = os.readlink(current)\n+        except OSError:\n+            break\n+        if not os.path.isabs(target):\n+            target = os.path.join(os.path.dirname(current), target)\n+        target = os.path.normpath(target)\n+        if _is_blocked_device_path(target):\n+            return True\n+        if target in seen:\n+            break\n+        seen.add(target)\n+        current = target\n+\n     try:\n-        resolved = os.path.realpath(normalized)\n+        resolved = os.path.normpath(os.path.realpath(normalized))\n     except (OSError, ValueError):\n         return False\n-    if resolved != normalized and _is_blocked_device_path(resolved):\n+    if _is_blocked_device_path(resolved):\n         return True\n     return False\n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}