diff --git a/README.md b/README.md index d1f66e6beb..b9c0f90697 100644 --- a/README.md +++ b/README.md @@ -2003,9 +2003,10 @@ Once the file is published, a temporary-file cleanup failure is logged without failing the upload; hidden staging files are left for the startup sweep. Uploads, new skill support files, and new local sandbox paths reject Windows -reserved device names on every platform, including `COM¹`, `LPT²` and names with -extensions such as `com³.txt`. Rename these files before creating or uploading -them so the same file tree remains usable on Windows. +reserved device names on every platform, including `COM¹`, `LPT²`, the console +aliases `CONIN$` and `CONOUT$`, and names with extensions such as `com³.txt`. +Rename these files before creating or uploading them so the same file tree +remains usable on Windows. Uploaded filenames matching `.upload-*.part` are rejected because that pattern is reserved for temporary staging files. Rename such a file before uploading it. diff --git a/backend/packages/harness/deerflow/utils/AGENTS.md b/backend/packages/harness/deerflow/utils/AGENTS.md index a7121e5250..d7b103ed72 100644 --- a/backend/packages/harness/deerflow/utils/AGENTS.md +++ b/backend/packages/harness/deerflow/utils/AGENTS.md @@ -57,6 +57,9 @@ lives in `tests/test_file_conversion_cancellation.py`. `host_paths.py` rejects Windows device names for host-visible creation paths on every platform, including the `COM`/`LPT` aliases with superscript ¹, ² and ³. +The console aliases `CONIN$` and `CONOUT$` are reserved too; match them +case-insensitively before the first dot, without rejecting longer ordinary +names such as `CONIN$notes.txt`. Do not normalize arbitrary Unicode digits into device numbers: names such as `COM⁴.txt` and `COM¹notes.txt` are ordinary portable names. Read/removal callers retain their existing portability exemptions. diff --git a/backend/packages/harness/deerflow/utils/host_paths.py b/backend/packages/harness/deerflow/utils/host_paths.py index a30aea01a2..f998a7f707 100644 --- a/backend/packages/harness/deerflow/utils/host_paths.py +++ b/backend/packages/harness/deerflow/utils/host_paths.py @@ -4,7 +4,9 @@ from __future__ import annotations # Windows also treats the ISO-8859-1 superscript digits ¹, ² and ³ as device numbers. # https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file -_WINDOWS_RESERVED_NAMES = frozenset({"CON", "PRN", "AUX", "NUL"} | {f"COM{i}" for i in "123456789¹²³"} | {f"LPT{i}" for i in "123456789¹²³"}) +# CONIN$ and CONOUT$ open the console input and output buffers, not ordinary files. +# https://learn.microsoft.com/en-us/windows/console/console-handles +_WINDOWS_RESERVED_NAMES = frozenset({"CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$"} | {f"COM{i}" for i in "123456789¹²³"} | {f"LPT{i}" for i in "123456789¹²³"}) def windows_incompatible_segment(segment: str) -> str | None: diff --git a/backend/tests/test_sandbox_tools_security.py b/backend/tests/test_sandbox_tools_security.py index 8f7e3583e7..47bb1223d6 100644 --- a/backend/tests/test_sandbox_tools_security.py +++ b/backend/tests/test_sandbox_tools_security.py @@ -511,6 +511,14 @@ def test_validate_local_tool_path_rejects_new_superscript_device_names(name: str validate_local_tool_path(path, _THREAD_DATA, read_only=True) +@pytest.mark.parametrize("name", ["CONIN$", "conout$", "CONIN$.txt", "CONOUT$.log"]) +def test_validate_local_tool_path_rejects_new_console_device_names(name: str) -> None: + path = f"{VIRTUAL_PATH_PREFIX}/workspace/{name}" + with pytest.raises(PermissionError, match="Access denied"): + validate_local_tool_path(path, _THREAD_DATA, read_only=False) + validate_local_tool_path(path, _THREAD_DATA, read_only=True) + + def test_validate_local_tool_path_rejects_traversal_in_user_data() -> None: """Path traversal via .. in user-data paths must be rejected.""" with pytest.raises(PermissionError, match="path traversal"): diff --git a/backend/tests/test_skill_storage_support_files.py b/backend/tests/test_skill_storage_support_files.py index 95305b908a..a269f6a368 100644 --- a/backend/tests/test_skill_storage_support_files.py +++ b/backend/tests/test_skill_storage_support_files.py @@ -80,6 +80,12 @@ def test_ensure_safe_support_path_rejects_windows_incompatible_names(storage, sk storage.ensure_safe_support_path("demo-skill", path) +@pytest.mark.parametrize("path", ["assets/CONIN$", "scripts/conout$", "assets/CONIN$.txt", "assets/CONOUT$/icon.png"]) +def test_ensure_safe_support_path_rejects_console_device_names(storage, skill_dir, path): + with pytest.raises(ValueError, match="reserved Windows device name"): + storage.ensure_safe_support_path("demo-skill", path) + + def test_read_text_or_none_decodes_text_and_returns_none_for_binary(tmp_path: Path): text = tmp_path / "t.md" text.write_text("héllo", encoding="utf-8") diff --git a/backend/tests/test_uploads_manager.py b/backend/tests/test_uploads_manager.py index 861520512a..42a11bf061 100644 --- a/backend/tests/test_uploads_manager.py +++ b/backend/tests/test_uploads_manager.py @@ -100,6 +100,15 @@ class TestNormalizeFilename: with pytest.raises(ValueError, match="not portable to Windows"): normalize_filename(filename) + @pytest.mark.parametrize("filename", ["CONIN$", "conin$", "CONOUT$", "conout$", "CONIN$.txt", "folder/CONOUT$.log"]) + def test_rejects_windows_console_device_names(self, filename): + with pytest.raises(ValueError, match="reserved Windows device name"): + normalize_filename(filename) + + @pytest.mark.parametrize("filename", ["CONIN", "CONOUT", "CONIN$notes.txt", "CONOUT$notes.log"]) + def test_allows_names_resembling_console_devices(self, filename): + assert normalize_filename(filename) == filename + @pytest.mark.parametrize("filename", ["report.pdf", "contour.txt", "console.log", "COM0", "COM10", "COM⁴.txt", "LPT⁵", "COM¹notes.txt", "LPT²0.md", ".gitignore"]) def test_allows_portable_names(self, filename): assert normalize_filename(filename) == filename