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"])
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

View file

@ -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 "