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 "