mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-30 23:20:35 +00:00
* fix(sandbox): require a path start before translating virtual and host roots Path translation only checked the right-hand segment boundary (#4035, #4053), so a root embedded in a longer path or URL was rewritten too. DeerFlow's own artifact URLs contain /mnt/user-data, so curl .../artifacts/mnt/user-data/outputs/x.html had the thread's host directory spliced into the URL, and a mount at /data turned an unrelated /var/data/app.log into /var/mnt/data/app.log in model-visible output. Add a shared left-hand PATH_START_BOUNDARY: a root must not follow a word character, '.', '~' or '-'. '/' is still allowed so file:///mnt/user-data keeps mapping. It applies to the bash command rewrite, LocalSandbox command/content resolution, and both reverse masking paths. * refactor(sandbox): derive the path-start lookbehind from one class Build PATH_START_BOUNDARY from _PATH_START_BLOCKERS so the regex and scanner paths cannot drift, and document the attached short-option value (-o/data/x) as a known false negative of the start rule. --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
177 lines
7.9 KiB
Python
177 lines
7.9 KiB
Python
"""Shared host→virtual output-path matching rules.
|
|
|
|
The boundary and tail are deliberately private. Callers use either
|
|
``build_output_mask_pattern`` for low-cardinality stable roots or
|
|
``replace_output_path_matches`` for high-cardinality dynamic roots, so a third
|
|
site cannot hand-roll a variant that drifts from the other two.
|
|
|
|
Two independent call sites rewrite host paths back to their virtual form in
|
|
text that flows to the model: ``LocalSandbox`` and ``sandbox.tools``. They must
|
|
agree on where a host base is allowed to end, because both feed the same
|
|
downstream contract — a match that stops short of a real segment boundary is
|
|
rewritten to a container path that forward resolution then refuses to map back.
|
|
|
|
Keeping one copy of that rule per file is what let it drift: #4035 added the
|
|
segment boundary to the reverse patterns and missed the masking patterns, and
|
|
#4053 had to add the same boundary to the other copy. This module holds the
|
|
rule once so a third copy cannot silently disagree.
|
|
|
|
Both sites match separator-agnostically: ``sandbox.tools`` derives its bases
|
|
from ``_path_variants``, and ``LocalSandbox``'s forward resolution spells
|
|
resolved paths with forward slashes in commands and file content, so the
|
|
reverse direction has to accept both spellings of the same host path — see
|
|
``separator_agnostic``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from collections.abc import Callable
|
|
|
|
# Only match where a host base ends at a real path-segment boundary, so a mount
|
|
# root does not match inside a sibling that merely shares its prefix
|
|
# (``.../skills`` inside ``.../skills-extra``).
|
|
#
|
|
# The class is text-oriented, not shell-oriented (contrast
|
|
# ``LocalSandbox._command_pattern``): both callers run over arbitrary command
|
|
# output or file listings, where a root can legitimately be followed by ``,``
|
|
# ``:`` or ``\``, all of which a shell-oriented class would reject.
|
|
#
|
|
# ``$`` is load-bearing: output ending exactly at a mount root would otherwise
|
|
# fail the lookahead and be emitted as the raw host path.
|
|
_SEGMENT_BOUNDARY = r"(?=/|$|[^\w./-])"
|
|
|
|
# The mirror rule on the left: a base must start a path, not continue one.
|
|
# Without it ``/mnt/user-data`` matches inside the artifact URL
|
|
# ``.../artifacts/mnt/user-data/outputs/x.html`` or a relative
|
|
# ``backup/mnt/user-data``, and a mount at ``/data`` inside ``/var/data``.
|
|
# ``/`` is deliberately allowed so ``file:///mnt/user-data`` still maps; ``.``
|
|
# and ``~`` reject ``./`` and ``~/`` relative spellings. Forward-resolution
|
|
# patterns (container → host) in ``LocalSandbox`` and ``sandbox.tools`` share
|
|
# this rule, so it is public.
|
|
#
|
|
# Known false negative: a value attached directly to a short option
|
|
# (``curl -o/data/x``, ``ssh -i/data/key``, ``gcc -I/data/inc``) follows a
|
|
# letter, so it is left virtual instead of being translated. Spell those with a
|
|
# space (``-o /data/x``) or ``=``. Rejecting embedded roots matters more: the
|
|
# alternative rewrites unrelated paths and URLs.
|
|
#
|
|
# The scanner and the regex lookbehind derive from one character class so
|
|
# ``replace_output_path_matches`` and ``build_output_mask_pattern`` cannot drift.
|
|
_PATH_START_BLOCKERS = re.compile(r"[\w.~-]")
|
|
PATH_START_BOUNDARY = rf"(?<!{_PATH_START_BLOCKERS.pattern})"
|
|
|
|
# The path tail following the base. ``[/\\]`` keeps Windows-separated paths
|
|
# matching; the negated class stops at whitespace and shell punctuation so a
|
|
# path embedded in a larger line is not over-consumed.
|
|
#
|
|
# ``:`` ends the tail as well. Scanning resumes after a match, so a tail that
|
|
# ran on through a ``:``-joined list ($PATH, $PYTHONPATH) carried every later
|
|
# entry under the same base to the model unmasked. ``;``, the Windows list
|
|
# separator, already ended it. A ``:`` inside one path (``grep -n`` output,
|
|
# a file name) only shortens the match; the rest is copied through verbatim.
|
|
_PATH_TAIL = r"(?:[/\\][^\s\"';&|<>():]*)?"
|
|
|
|
_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.
|
|
|
|
Args:
|
|
base: Host path root to match (already resolved by the caller).
|
|
separator_agnostic: Accept either separator *inside* the base, so a
|
|
base captured with ``\\`` still matches output that spells the same
|
|
path with ``/``. Both call sites need this: ``sandbox.tools``
|
|
derives its bases from ``_path_variants`` (which yields
|
|
Windows-style spellings) and matches them against output whose
|
|
separators it does not control, and ``LocalSandbox``'s forward
|
|
resolution emits forward-slash spellings on Windows even though its
|
|
bases are resolved with native separators.
|
|
|
|
Returns:
|
|
A compiled pattern matching ``base`` where it starts a path and ends
|
|
at a segment boundary, plus an optional path tail.
|
|
"""
|
|
escaped = re.escape(base)
|
|
if separator_agnostic:
|
|
escaped = escaped.replace(r"\\", r"[/\\]")
|
|
return re.compile(PATH_START_BOUNDARY + escaped + _SEGMENT_BOUNDARY + _PATH_TAIL)
|
|
|
|
|
|
def replace_output_path_matches(
|
|
output: str,
|
|
base: str,
|
|
replacement: str | Callable[[str], str],
|
|
*,
|
|
separator_agnostic: bool = False,
|
|
) -> str:
|
|
"""Replace ``base`` path matches without compiling a path-specific regex.
|
|
|
|
Dynamic thread roots are high-cardinality. Compiling one regex per root
|
|
leaves those roots in Python's global ``re`` caches after DeerFlow evicts
|
|
the owning sandbox. This scanner preserves the same boundary and path-tail
|
|
contract while keeping no process-level reference to ``base``.
|
|
"""
|
|
if not output or not base:
|
|
return output
|
|
|
|
searchable_output = output.replace("\\", "/") if separator_agnostic and "\\" in output else output
|
|
searchable_base = base.replace("\\", "/") if separator_agnostic and "\\" in base else base
|
|
chunks: list[str] = []
|
|
copied_until = 0
|
|
search_from = 0
|
|
|
|
while True:
|
|
match_start = searchable_output.find(searchable_base, search_from)
|
|
if match_start < 0:
|
|
break
|
|
if match_start > 0 and _PATH_START_BLOCKERS.fullmatch(searchable_output[match_start - 1]):
|
|
search_from = match_start + 1
|
|
continue
|
|
|
|
base_end = match_start + len(searchable_base)
|
|
match_end = base_end
|
|
if base_end < len(searchable_output):
|
|
next_char = searchable_output[base_end]
|
|
if next_char in "/\\":
|
|
match_end += 1
|
|
while match_end < len(output):
|
|
char = output[match_end]
|
|
if char.isspace() or char in _PATH_TAIL_TERMINATORS:
|
|
break
|
|
match_end += 1
|
|
elif _SEGMENT_BOUNDARY_CHAR.fullmatch(next_char) is None:
|
|
search_from = match_start + 1
|
|
continue
|
|
|
|
matched_path = output[match_start:match_end]
|
|
if callable(replacement):
|
|
replaced_path = replacement(matched_path)
|
|
else:
|
|
relative = normalize_mask_tail(matched_path[len(base) :])
|
|
replaced_path = f"{replacement}/{relative}" if relative else replacement
|
|
|
|
chunks.append(output[copied_until:match_start])
|
|
chunks.append(replaced_path)
|
|
copied_until = match_end
|
|
search_from = match_end
|
|
|
|
if not chunks:
|
|
return output
|
|
chunks.append(output[copied_until:])
|
|
return "".join(chunks)
|