From def3f6388f8a8a1c8e4e9ff415a4e6a9b8fdd626 Mon Sep 17 00:00:00 2001 From: Eugeniusz Gilewski Date: Sun, 21 Jun 2026 19:15:21 +0200 Subject: [PATCH] fix(file): anchor device symlink guard to task cwd The read_file device guard now walks symlink hops before the file operation layer, but that hop walk still interpreted relative paths against the Python process cwd. In sessions where TERMINAL_CWD points at the task workspace, a relative workspace symlink to a blocked alias such as /dev/../dev/stdin could therefore miss the intermediate device target before later task-cwd resolution. Anchor relative device checks to the task base before symlink-hop inspection so the pre-I/O guard sees the same workspace path that read_file would otherwise read. Absolute device paths and the existing final realpath fallback remain unchanged. Refs #10141 Refs #29158 --- tests/tools/test_file_read_guards.py | 27 +++++++++++++++++++++++++++ tools/file_tools.py | 10 +++++++--- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/tests/tools/test_file_read_guards.py b/tests/tools/test_file_read_guards.py index 8c05413065e..3a8e2a0c1ab 100644 --- a/tests/tools/test_file_read_guards.py +++ b/tests/tools/test_file_read_guards.py @@ -170,6 +170,33 @@ class TestDevicePathBlocking(unittest.TestCase): self.assertIn("device file", result["error"]) mock_ops.assert_not_called() + @patch("tools.file_tools._get_file_ops") + def test_read_file_tool_rejects_task_cwd_relative_device_alias_symlink(self, mock_ops): + if not os.path.exists("/dev/stdin"): + self.skipTest("/dev/stdin is not available on this platform") + with tempfile.TemporaryDirectory() as tmpdir: + workspace = os.path.join(tmpdir, "workspace") + process_cwd = os.path.join(tmpdir, "process") + os.mkdir(workspace) + os.mkdir(process_cwd) + link_path = os.path.join(workspace, "stdin-link") + try: + os.symlink("/dev/../dev/stdin", link_path) + except OSError as exc: + self.skipTest(f"symlink unavailable: {exc}") + + old_cwd = os.getcwd() + try: + os.chdir(process_cwd) + with patch.dict(os.environ, {"TERMINAL_CWD": workspace}, clear=False): + result = json.loads(read_file_tool("stdin-link", task_id="dev_rel_link_test")) + finally: + os.chdir(old_cwd) + + self.assertIn("error", result) + self.assertIn("device file", result["error"]) + mock_ops.assert_not_called() + # --------------------------------------------------------------------------- # Character-count limits diff --git a/tools/file_tools.py b/tools/file_tools.py index f427132451e..a28c057e63a 100644 --- a/tools/file_tools.py +++ b/tools/file_tools.py @@ -302,14 +302,17 @@ def _is_blocked_device_path(path: str) -> bool: return False -def _is_blocked_device(filepath: str) -> bool: +def _is_blocked_device(filepath: str, base_dir: str | Path | None = None) -> bool: """Return True if the path would hang the process (infinite output or blocking input). Check the literal path first so aliases like /dev/stdin are caught before they resolve to terminal-specific paths. Then check each symlink hop before the final resolved path so aliases to devices cannot bypass the guard. """ - normalized = os.path.normpath(os.path.expanduser(filepath)) + expanded = os.path.expanduser(filepath) + if base_dir is not None and not os.path.isabs(expanded): + expanded = os.path.join(os.fspath(base_dir), expanded) + normalized = os.path.normpath(expanded) if _is_blocked_device_path(normalized): return True @@ -850,7 +853,8 @@ def read_file_tool(path: str, offset: int = 1, limit: int = 500, task_id: str = # ── Device path guard ───────────────────────────────────────── # Block paths that would hang the process (infinite output, # blocking on input). Pure path check — no I/O. - if _is_blocked_device(path): + device_base = None if Path(path).expanduser().is_absolute() else _resolve_base_dir(task_id) + if _is_blocked_device(path, base_dir=device_base): return json.dumps({ "error": ( f"Cannot read '{path}': this is a device file that would "