Hyeonsang Cho 1b9667ea0e
fix(sandbox): mask every host path in a colon-joined list (#5418)
* fix(sandbox): mask every host path in a colon-joined list

Host-to-virtual output masking matched a host root and then consumed the
path tail up to whitespace or shell punctuation, but not `:`. A
`:`-joined list such as $PATH or $PYTHONPATH was therefore swallowed into
the first match's tail, and scanning resumed after it, so every later
entry under the same root reached the model as a raw host path. The regex
matcher (process-stable skill roots) and the direct scanner (per-thread
roots, LocalSandbox) shared the gap. Each redundant masking pass --
separator variants, the realpath spelling, the /mnt/user-data root
mapping, LocalSandbox's own reverse resolution -- happened to recover one
entry, which hid the leak for short lists: bash output leaked from the
fourth entry, single-pass consumers from the third.

The shared tail in path_patterns.py now ends at `:` in both matchers.
`;`, the Windows list separator, already ended it. A `:` inside one path
(grep -n output, a file name) only shortens the match; the remaining text
is copied through verbatim.

Shortening the match exposed a second leak. LocalSandbox reverse
resolution realpaths the matched path and returned that realpath when no
mount contained it, so a symlink inside a mount whose target lies outside
every mount was shown as the target's host path. grep -n lines used to
hide this only because the whole line resolved as one nonexistent file;
whitespace-terminated output and LocalSandbox.glob results already leaked
it on main. Reverse resolution now falls back to the link's own spelling,
normalized so `mount/../x` does not pass, before giving up. A symlink into
another mount still reports that mount's path.

* docs(changelog): reference #5418 in the colon-joined path masking entry

* docs(changelog): split the #5418 and #5419 entries fused by the merge

Resolving the CHANGELOG conflict when main was merged in dropped the
opener of the #5419 entry, so the BoxLite grep fix continued inside this
PR's bullet in both CHANGELOG.md and CHANGELOG_zh.md. Restore it as its
own bullet; the #5419 entry is byte-identical to main again.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-14 14:05:21 +08:00

154 lines
6.5 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 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`` at a segment boundary, plus an
optional path tail.
"""
escaped = re.escape(base)
if separator_agnostic:
escaped = escaped.replace(r"\\", r"[/\\]")
return re.compile(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
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)