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
This commit is contained in:
Eugeniusz Gilewski 2026-06-21 19:15:21 +02:00 committed by Teknium
parent e267237671
commit def3f6388f
2 changed files with 34 additions and 3 deletions

View File

@ -170,6 +170,33 @@ class TestDevicePathBlocking(unittest.TestCase):
self.assertIn("device file", result["error"]) self.assertIn("device file", result["error"])
mock_ops.assert_not_called() 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 # Character-count limits

View File

@ -302,14 +302,17 @@ def _is_blocked_device_path(path: str) -> bool:
return False 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). """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 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 they resolve to terminal-specific paths. Then check each symlink hop before
the final resolved path so aliases to devices cannot bypass the guard. 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): if _is_blocked_device_path(normalized):
return True return True
@ -850,7 +853,8 @@ def read_file_tool(path: str, offset: int = 1, limit: int = 500, task_id: str =
# ── Device path guard ───────────────────────────────────────── # ── Device path guard ─────────────────────────────────────────
# Block paths that would hang the process (infinite output, # Block paths that would hang the process (infinite output,
# blocking on input). Pure path check — no I/O. # 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({ return json.dumps({
"error": ( "error": (
f"Cannot read '{path}': this is a device file that would " f"Cannot read '{path}': this is a device file that would "