From d38063b49cadd1733f6d8bee416fc04559145061 Mon Sep 17 00:00:00 2001 From: hetaoBackend Date: Thu, 6 Aug 2026 21:04:22 +0800 Subject: [PATCH] fix: enforce local removal mappings --- .../deerflow/sandbox/local/local_sandbox.py | 23 +++++++++ .../test_local_sandbox_provider_mounts.py | 49 +++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/backend/packages/harness/deerflow/sandbox/local/local_sandbox.py b/backend/packages/harness/deerflow/sandbox/local/local_sandbox.py index 9f70e8a3c..076e241a2 100644 --- a/backend/packages/harness/deerflow/sandbox/local/local_sandbox.py +++ b/backend/packages/harness/deerflow/sandbox/local/local_sandbox.py @@ -804,3 +804,26 @@ class LocalSandbox(Sandbox): except OSError as e: # Re-raise with the original path for clearer error messages, hiding internal resolved paths raise type(e)(e.errno, e.strerror, path) from None + + def remove_file(self, path: str) -> None: + mapping_match = self._find_path_mapping(path) + if mapping_match is None: + resolved = ResolvedPath(str(path), None) + else: + mapping, relative = mapping_match + local_root = Path(self._resolved_local_paths[mapping]) + candidate = local_root / relative if relative else local_root + try: + candidate.parent.resolve().relative_to(local_root) + except ValueError as exc: + raise PermissionError(errno.EACCES, "Access denied: path escapes mounted directory", path) from exc + resolved = ResolvedPath(str(candidate), mapping) + + if self._is_resolved_path_read_only(resolved): + raise OSError(errno.EROFS, "Read-only file system", path) + try: + os.unlink(resolved.path) + except FileNotFoundError: + return + except OSError as exc: + raise type(exc)(exc.errno, exc.strerror, path) from None diff --git a/backend/tests/test_local_sandbox_provider_mounts.py b/backend/tests/test_local_sandbox_provider_mounts.py index 0cc243fe9..44963ce2b 100644 --- a/backend/tests/test_local_sandbox_provider_mounts.py +++ b/backend/tests/test_local_sandbox_provider_mounts.py @@ -182,8 +182,57 @@ class TestReadOnlyPath: sandbox.update_file("/mnt/skills/existing.py", b"updated") assert exc_info.value.errno == errno.EROFS + def test_remove_file_blocked_on_read_only(self, tmp_path): + skills_dir = tmp_path / "skills" + skills_dir.mkdir() + existing_file = skills_dir / "existing.py" + existing_file.write_bytes(b"original") + sandbox = LocalSandbox( + "test", + [PathMapping(container_path="/mnt/skills", local_path=str(skills_dir), read_only=True)], + ) + + with pytest.raises(OSError) as exc_info: + sandbox.remove_file("/mnt/skills/existing.py") + + assert exc_info.value.errno == errno.EROFS + assert existing_file.read_bytes() == b"original" + + def test_remove_file_deletes_exact_writable_path(self, tmp_path): + data_dir = tmp_path / "data" + data_dir.mkdir() + existing_file = data_dir / "existing.txt" + existing_file.write_bytes(b"payload") + sandbox = LocalSandbox( + "test", + [PathMapping(container_path="/mnt/data", local_path=str(data_dir), read_only=False)], + ) + + sandbox.remove_file("/mnt/data/existing.txt") + + assert not existing_file.exists() + class TestSymlinkEscapes: + def test_remove_file_blocks_symlinked_parent_escape(self, tmp_path): + mount_dir = tmp_path / "mount" + mount_dir.mkdir() + outside_dir = tmp_path / "outside" + outside_dir.mkdir() + victim = outside_dir / "victim.txt" + victim.write_text("protected", encoding="utf-8") + _symlink_to(outside_dir, mount_dir / "escape", target_is_directory=True) + sandbox = LocalSandbox( + "test", + [PathMapping(container_path="/mnt/data", local_path=str(mount_dir), read_only=False)], + ) + + with pytest.raises(PermissionError) as exc_info: + sandbox.remove_file("/mnt/data/escape/victim.txt") + + assert exc_info.value.errno == errno.EACCES + assert victim.read_text(encoding="utf-8") == "protected" + def test_read_file_blocks_symlink_escape_from_mount(self, tmp_path): mount_dir = tmp_path / "mount" mount_dir.mkdir()