diff --git a/backend/packages/harness/deerflow/sandbox/path_patterns.py b/backend/packages/harness/deerflow/sandbox/path_patterns.py index f80696b96..9c0b13592 100644 --- a/backend/packages/harness/deerflow/sandbox/path_patterns.py +++ b/backend/packages/harness/deerflow/sandbox/path_patterns.py @@ -47,6 +47,18 @@ _SEGMENT_BOUNDARY_CHAR = re.compile(r"[^\w./-]") _PATH_TAIL_TERMINATORS = frozenset("\"';&|<>()") +def normalize_mask_tail(tail: str) -> str: + """Normalize a matched output tail for splicing onto a virtual prefix. + + Virtual paths are always POSIX-style, so drop leading separators and + convert any remaining backslashes (Windows-spelled output) to forward + slashes. Shared by the static-pattern closure in ``sandbox.tools`` and + the direct scanner here so the splicing rule exists in exactly one copy + and the two sites can only drift in their *matching* logic. + """ + return tail.lstrip("/\\").replace("\\", "/") + + def build_output_mask_pattern(base: str, *, separator_agnostic: bool = False) -> re.Pattern[str]: """Compile the matcher for one host ``base`` in model-visible output. @@ -118,7 +130,7 @@ def replace_output_path_matches( if callable(replacement): replaced_path = replacement(matched_path) else: - relative = matched_path[len(base) :].lstrip("/\\") + relative = normalize_mask_tail(matched_path[len(base) :]) replaced_path = f"{replacement}/{relative}" if relative else replacement chunks.append(output[copied_until:match_start]) diff --git a/backend/packages/harness/deerflow/sandbox/tools.py b/backend/packages/harness/deerflow/sandbox/tools.py index c255f0091..49986914a 100644 --- a/backend/packages/harness/deerflow/sandbox/tools.py +++ b/backend/packages/harness/deerflow/sandbox/tools.py @@ -39,7 +39,7 @@ from deerflow.sandbox.lease import ( sandbox_lease_owner, ) from deerflow.sandbox.overwrite import unwrap_sandbox -from deerflow.sandbox.path_patterns import build_output_mask_pattern, replace_output_path_matches +from deerflow.sandbox.path_patterns import build_output_mask_pattern, normalize_mask_tail, replace_output_path_matches from deerflow.sandbox.sandbox import Sandbox from deerflow.sandbox.sandbox_provider import SandboxProvider, get_sandbox_provider from deerflow.sandbox.search import GrepMatch @@ -867,7 +867,7 @@ def mask_local_paths_in_output(output: str, thread_data: ThreadDataState | None) matched_path = match.group(0) if matched_path == _base: return _virtual - relative = matched_path[len(_base) :].lstrip("/\\") + relative = normalize_mask_tail(matched_path[len(_base) :]) return f"{_virtual}/{relative}" if relative else _virtual result = pattern.sub(replace_match, result) diff --git a/backend/tests/test_sandbox_path_patterns.py b/backend/tests/test_sandbox_path_patterns.py index fbf3731bf..6c71a82f9 100644 --- a/backend/tests/test_sandbox_path_patterns.py +++ b/backend/tests/test_sandbox_path_patterns.py @@ -110,6 +110,31 @@ def test_direct_replacer_matches_the_shared_boundary_and_tail_contract() -> None assert replacer("root /host/skills, done", "/host/skills", "/mnt/skills", separator_agnostic=True) == "root /mnt/skills, done" +def test_direct_replacer_normalizes_nested_tail_to_virtual_posix_style() -> None: + # The tail is sliced from the original output, so a Windows-spelled nested + # path kept its backslashes and was spliced into the POSIX-style virtual + # path as e.g. /mnt/skills/pkg\\a.md. Virtual paths are always POSIX, so + # nested tails must be normalized the same way depth-1 tails already are. + assert ( + path_patterns_module.replace_output_path_matches( + "see \\host\\skills\\pkg\\a.md", + "/host/skills", + "/mnt/skills", + separator_agnostic=True, + ) + == "see /mnt/skills/pkg/a.md" + ) + assert ( + path_patterns_module.replace_output_path_matches( + "see C:\\host\\skills\\pkg\\a.md", + "C:\\host\\skills", + "/mnt/skills", + separator_agnostic=True, + ) + == "see /mnt/skills/pkg/a.md" + ) + + def test_separator_agnostic_replacer_avoids_normalization_without_backslashes() -> None: class ReplaceTrackingString(str): def __init__(self, value: str) -> None: diff --git a/backend/tests/test_sandbox_tools_security.py b/backend/tests/test_sandbox_tools_security.py index 18e7496a7..5fc51951e 100644 --- a/backend/tests/test_sandbox_tools_security.py +++ b/backend/tests/test_sandbox_tools_security.py @@ -309,6 +309,24 @@ def test_mask_local_paths_no_thread_data_still_masks_skills() -> None: assert "/mnt/skills/a/b.md" in masked +def test_mask_local_paths_normalizes_windows_spelled_skill_tails() -> None: + """The static-pattern splice normalizes nested Windows-spelled tails. + + This is the Linux-CI guard for the ``mask_local_paths_in_output`` splice: + the host root and the output are Windows-spelled *strings*, so the tail + keeps backslashes on every platform. Reverting the normalization here + turns this test red on Linux CI too, not only on Windows hosts. + """ + windows_root = "C:\\Users\\alice\\deer-flow\\skills" + with ( + patch("deerflow.sandbox.tools._get_skills_container_path", return_value="/mnt/skills"), + patch("deerflow.sandbox.tools._get_skills_host_path", return_value=windows_root), + ): + masked = mask_local_paths_in_output(f"Reading: {windows_root}\\lark-cli\\lark-doc\\SKILL.md", None) + + assert masked == "Reading: /mnt/skills/lark-cli/lark-doc/SKILL.md" + + def test_mask_local_paths_hides_global_integration_skill_paths(tmp_path: Path) -> None: from deerflow.config.paths import Paths