diff --git a/README.md b/README.md index d899c6c851..cb21249f32 100644 --- a/README.md +++ b/README.md @@ -2124,6 +2124,9 @@ 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²`, the console aliases `CONIN$` and `CONOUT$`, and names with extensions such as `com³.txt`. +ASCII spaces before the extension do not make a device name portable: +`NUL .txt` and `COM1 .log` are rejected too. Ordinary names such as +`report .txt` are preserved unchanged. Rename these files before creating or uploading them so the same file tree remains usable on Windows. diff --git a/backend/packages/harness/deerflow/utils/AGENTS.md b/backend/packages/harness/deerflow/utils/AGENTS.md index b254801cf0..0d67eda661 100644 --- a/backend/packages/harness/deerflow/utils/AGENTS.md +++ b/backend/packages/harness/deerflow/utils/AGENTS.md @@ -94,6 +94,10 @@ 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`. +Device-name comparison ignores ASCII spaces before the first dot, so +`NUL .txt` and `COM1 .log` remain reserved. Trim the comparison stem only, +not the supplied filename; ordinary names such as `report .txt` and +non-ASCII whitespace remain unchanged. 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 b65553a74b..55f1b20511 100644 --- a/backend/packages/harness/deerflow/utils/host_paths.py +++ b/backend/packages/harness/deerflow/utils/host_paths.py @@ -52,7 +52,9 @@ def windows_incompatible_segment(segment: str) -> str | None: return None if segment.endswith((" ", ".")): return "trailing dot or space" - stem = segment.split(".", 1)[0] + # Windows ignores ASCII spaces before a device name's extension (NUL .txt). + # Trim only for comparison; ordinary filenames must retain their spaces. + stem = segment.split(".", 1)[0].rstrip(" ") if stem.upper() in _WINDOWS_RESERVED_NAMES: return "reserved Windows device name" return None diff --git a/backend/tests/test_sandbox_tools_security.py b/backend/tests/test_sandbox_tools_security.py index 47bb1223d6..0ab937e32e 100644 --- a/backend/tests/test_sandbox_tools_security.py +++ b/backend/tests/test_sandbox_tools_security.py @@ -519,6 +519,14 @@ def test_validate_local_tool_path_rejects_new_console_device_names(name: str) -> validate_local_tool_path(path, _THREAD_DATA, read_only=True) +@pytest.mark.parametrize("name", ["NUL .txt", "con .log", "COM1 .txt", "lpt² .md", "CONIN$ .txt", "conout$ .log"]) +def test_validate_local_tool_path_rejects_new_padded_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 a269f6a368..94fbbcdd15 100644 --- a/backend/tests/test_skill_storage_support_files.py +++ b/backend/tests/test_skill_storage_support_files.py @@ -86,6 +86,12 @@ def test_ensure_safe_support_path_rejects_console_device_names(storage, skill_di storage.ensure_safe_support_path("demo-skill", path) +@pytest.mark.parametrize("path", ["assets/NUL .txt", "scripts/con .log", "assets/COM1 .txt", "assets/lpt² .md", "assets/CONIN$ .txt", "assets/conout$ .log", "assets/COM1 .txt/icon.png"]) +def test_ensure_safe_support_path_rejects_padded_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 10c8e3806f..0b75362dbb 100644 --- a/backend/tests/test_uploads_manager.py +++ b/backend/tests/test_uploads_manager.py @@ -108,6 +108,15 @@ class TestNormalizeFilename: with pytest.raises(ValueError, match="reserved Windows device name"): normalize_filename(filename) + @pytest.mark.parametrize("filename", ["NUL .txt", "con .log", "COM1 .txt", "lpt² .md", "CONIN$ .txt", "conout$ .log"]) + def test_rejects_windows_device_names_with_spaces_before_extension(self, filename): + with pytest.raises(ValueError, match="reserved Windows device name"): + normalize_filename(filename) + + @pytest.mark.parametrize("filename", ["report .txt", "NULnotes .txt", "COM¹notes .txt", "NUL\u00a0.txt"]) + def test_preserves_spaces_in_ordinary_filename_stems(self, filename): + assert normalize_filename(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