fix(sandbox): mask output tails into POSIX-style virtual paths (#5247)

* fix(sandbox): mask output tails into POSIX-style virtual paths

The output maskers slice the matched path tail from the original
output. With separator-agnostic matching, a Windows-spelled nested
tail kept its backslashes and was spliced into the POSIX-style virtual
path, so glob results and masked read output showed mixed paths like
/mnt/user-data/workspace/pkg\util.py or
/mnt/skills/integrations/lark-cli\lark-doc\SKILL.md. Virtual paths are
always POSIX-style, so normalize nested tails to forward slashes the
same way depth-1 tails already end up. Depth-1 tails and the callable
replacer (LocalSandbox._reverse_resolve_path) were unaffected.

Pin the nested-tail contract in test_sandbox_path_patterns; the
previously failing glob-tool and skills-masking regressions now pass
on Windows hosts.

* refactor(sandbox): share the mask tail-splicing rule; guard it on Linux CI

Review follow-up for #5247:

- hoist the tail-splicing rule (slice off the base, strip leading
  separators, normalize the rest to "/") into
  path_patterns.normalize_mask_tail and import it at both call sites,
  so the two maskers can only drift in their matching logic, not in
  the splice;
- add test_mask_local_paths_normalizes_windows_spelled_skill_tails,
  which spells the skills host root and the output with Windows-style
  strings so the nested tail keeps backslashes on every platform.
  Reverting the mask_local_paths_in_output-side normalization now goes
  red on Linux CI too, not only on Windows hosts.
This commit is contained in:
Shxiao 2026-09-09 11:09:28 +09:00 committed by GitHub
parent 0b3dadbc9b
commit fa89a12526
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 58 additions and 3 deletions

View File

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

View File

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

View File

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

View File

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