fix(sandbox): search the skills root across category mounts (#7338)

* fix(sandbox): search skills across category mounts

* fix(sandbox): address virtual search mount review

Search explicitly configured category mounts regardless of ignored names,
while retaining descendant filters and host-parent exclusion. Warn with the
virtual mount path before re-raising broken-mount errors, defer ordinary
path resolution, and cover lookahead across mount boundaries.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Daoyuan Li 2026-10-23 06:41:24 -07:00 committed by GitHub
parent 48c884a533
commit 6e240b4bc3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 363 additions and 4 deletions

View File

@ -884,6 +884,14 @@ DeerFlow supports multiple sandbox execution modes:
- **Docker Execution** (runs sandbox code in isolated Docker containers)
- **Docker Execution with Kubernetes** (runs sandbox code in Kubernetes pods via provisioner service)
In Local Execution, `glob` and `grep` can search the skills root (default
`/mnt/skills`) across its mounted categories. Patterns stay relative to that
root, and one result limit covers all categories. User isolation, disabled-skill
filtering and read-only mounts still apply. Configured category mounts such as
`build` or `logs` remain searchable; ignore rules apply inside each mount. A broken
category mount stops the root search, and the Gateway warning log identifies its
virtual path.
Sandbox references in conversation state are server-owned. External run and
thread-state APIs reject caller-supplied `sandbox` values; when restoring a
checkpoint, the runtime resolves the reference against the authenticated user

View File

@ -8,6 +8,14 @@ PowerShell command mapping escapes host-path whitespace/metacharacters for the c
Local `grep` skips non-regular entries before reading. File reads and directory scans propagate errors, except `ENOENT`/`ENOTDIR`/`ELOOP`/`EISDIR`/`ESTALE` races (`ESTALE` when available). Error filenames are reverse-resolved at the local sandbox boundary, including custom mounts; permission errors identify the virtual failing path.
Unmapped virtual parents such as the default skills root search only direct
category mounts, never incidental host files. Shared search helpers accept a
relative prefix for root-anchored patterns; combine mounts under one result cap
with one lookahead match. Mapped roots keep their existing walk. Explicit
mounts bypass name ignores; descendants retain shared ignore rules. Warn with
an unavailable or non-directory mount's container path and re-raise; never
fall back to the host parent. Regression: `tests/test_local_sandbox_skill_search.py`.
**Network approval policy**: `SandboxMiddleware` uses
`resolve_run_interaction_policy()`. Autonomous, webhook, scheduled, legacy
unattended and GitHub-fallback runs auto-deny; explicit interactive overrides

View File

@ -23,7 +23,7 @@ from deerflow.sandbox.env_policy import build_sandbox_env
from deerflow.sandbox.local.list_dir import list_dir
from deerflow.sandbox.path_patterns import replace_output_path_matches
from deerflow.sandbox.sandbox import Sandbox, _validate_extra_env
from deerflow.sandbox.search import GrepMatch, find_glob_matches, find_grep_matches
from deerflow.sandbox.search import GrepMatch, find_glob_matches, find_grep_matches, path_matches
from deerflow.utils.atomic_write import publish_bytes_atomically
logger = logging.getLogger(__name__)
@ -1234,9 +1234,64 @@ class LocalSandbox(Sandbox):
# 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 _virtual_search_mounts(self, path: str) -> list[tuple[str, Path]] | None:
"""Search only mounted children of an unmapped virtual parent.
Default skills have four category mounts, not an aggregate host root.
Never substitute an incidental host directory for that virtual view.
Mapped roots (including policy-scoped skill views) keep their normal walk.
"""
if self._find_path_mapping(path) is not None:
return None
prefix = path.rstrip("/") + "/"
mounts = None
for mapping in self.path_mappings:
if not mapping.container_path.startswith(prefix):
continue
child = mapping.container_path[len(prefix) :]
if not child or "/" in child:
continue
if mounts is None:
mounts = []
# Explicit mounts stay visible; ignore rules apply within each mount.
root = Path(self._resolved_local_paths[mapping])
# A broken mount is an error, not permission to walk the host parent.
try:
if not stat.S_ISDIR(root.stat().st_mode):
raise NotADirectoryError(mapping.container_path)
except OSError:
logger.warning("Could not search virtual mount %s", mapping.container_path, exc_info=True)
raise
mounts.append((child, root))
if mounts is not None:
# As with list_dir, an existing host file is not a virtual directory.
try:
mode = Path(path).stat().st_mode
except FileNotFoundError:
pass
else:
if not stat.S_ISDIR(mode):
raise NotADirectoryError(path)
return mounts
def glob(self, path: str, pattern: str, *, include_dirs: bool = False, max_results: int = 200) -> tuple[list[str], bool]:
resolved_path = Path(self._resolve_path(path))
try:
mounts = self._virtual_search_mounts(path)
if mounts is not None:
results = []
for child, root in mounts:
virtual_dir = f"{path.rstrip('/')}/{child}"
if include_dirs and path_matches(pattern, child):
results.append(virtual_dir)
if len(results) <= max_results:
# One lookahead match proves whether the combined view
# exceeds the cap, without collecting a cap per mount.
matches, _ = find_glob_matches(root, pattern, include_dirs=include_dirs, max_results=max_results - len(results) + 1, relative_prefix=child)
results.extend(f"{virtual_dir}/{Path(match).relative_to(root).as_posix()}" for match in matches)
if len(results) > max_results:
return results[:max_results], True
return results, False
resolved_path = Path(self._resolve_path(path))
matches, truncated = find_glob_matches(resolved_path, pattern, include_dirs=include_dirs, max_results=max_results)
except OSError as e:
if e.filename is None:
@ -1257,8 +1312,25 @@ class LocalSandbox(Sandbox):
case_sensitive: bool = False,
max_results: int = 100,
) -> tuple[list[GrepMatch], bool]:
resolved_path = Path(self._resolve_path(path))
try:
mounts = self._virtual_search_mounts(path)
if mounts is not None:
results = []
for child, root in mounts:
matches, _ = find_grep_matches(
root,
pattern,
glob_pattern=glob,
literal=literal,
case_sensitive=case_sensitive,
max_results=max_results - len(results) + 1,
relative_prefix=child,
)
results.extend(GrepMatch(path=f"{path.rstrip('/')}/{child}/{Path(match.path).relative_to(root).as_posix()}", line_number=match.line_number, line=match.line) for match in matches)
if len(results) > max_results:
return results[:max_results], True
return results, False
resolved_path = Path(self._resolve_path(path))
matches, truncated = find_grep_matches(
resolved_path,
pattern,

View File

@ -159,7 +159,7 @@ def is_binary_file(path: Path, sample_size: int = 8192) -> bool:
return b"\0" in handle.read(sample_size)
def find_glob_matches(root: Path, pattern: str, *, include_dirs: bool = False, max_results: int = 200) -> tuple[list[str], bool]:
def find_glob_matches(root: Path, pattern: str, *, include_dirs: bool = False, max_results: int = 200, relative_prefix: str = "") -> tuple[list[str], bool]:
matches: list[str] = []
truncated = False
root = root.resolve()
@ -185,6 +185,8 @@ def find_glob_matches(root: Path, pattern: str, *, include_dirs: bool = False, m
if include_dirs:
for name in dirs:
rel_path = (rel_dir / name).as_posix()
if relative_prefix:
rel_path = f"{relative_prefix}/{rel_path}"
if path_matches(pattern, rel_path):
matches.append(str(Path(current_root) / name))
if len(matches) >= max_results:
@ -195,6 +197,8 @@ def find_glob_matches(root: Path, pattern: str, *, include_dirs: bool = False, m
if should_ignore_name(name):
continue
rel_path = (rel_dir / name).as_posix()
if relative_prefix:
rel_path = f"{relative_prefix}/{rel_path}"
if path_matches(pattern, rel_path):
matches.append(str(Path(current_root) / name))
if len(matches) >= max_results:
@ -248,6 +252,7 @@ def find_grep_matches(
max_results: int = 100,
max_file_size: int = DEFAULT_MAX_FILE_SIZE_BYTES,
line_summary_length: int = DEFAULT_LINE_SUMMARY_LENGTH,
relative_prefix: str = "",
) -> tuple[list[GrepMatch], bool]:
matches: list[GrepMatch] = []
truncated = False
@ -288,6 +293,8 @@ def find_grep_matches(
yield Path(current_root) / name, (rel_dir / name).as_posix()
for candidate_path, rel_path in candidate_files():
if relative_prefix:
rel_path = f"{relative_prefix}/{rel_path}"
if glob_pattern is not None and not path_matches(glob_pattern, rel_path):
continue

View File

@ -0,0 +1,264 @@
"""Search the default provider's category-only skill mounts through public tools."""
import errno
import logging
from pathlib import Path
from types import SimpleNamespace
import pytest
from deerflow.config import paths as paths_module
from deerflow.config.app_config import AppConfig
from deerflow.config.extensions_config import ExtensionsConfig, SkillStateConfig
from deerflow.sandbox import tools
from deerflow.sandbox.local.local_sandbox import LocalSandbox, PathMapping
from deerflow.sandbox.local.local_sandbox_provider import LocalSandboxProvider
def _write_skill(root, name):
target = root / name / "SKILL.md"
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(f"---\nname: {name}\ndescription: Search regression fixture\n---\nNEEDLE {name}\n", encoding="utf-8")
@pytest.fixture
def skill_search(tmp_path, monkeypatch):
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path / "home"))
monkeypatch.setattr(paths_module, "_paths", None)
source = tmp_path / "skills"
for name in ("alpha", "beta", "disabled"):
_write_skill(source / "public", name)
config = AppConfig.model_validate({"skills": {"path": str(source)}, "sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"}})
extensions = ExtensionsConfig(skills={"disabled": SkillStateConfig(enabled=False)})
monkeypatch.setattr("deerflow.config.get_app_config", lambda: config)
monkeypatch.setattr("deerflow.config.app_config.get_app_config", lambda: config)
monkeypatch.setattr("deerflow.config.extensions_config.get_extensions_config", lambda: extensions)
monkeypatch.setattr(ExtensionsConfig, "from_file", lambda *args, **kwargs: extensions)
paths = paths_module.get_paths()
for user in ("alice", "bob"):
_write_skill(paths.user_custom_skills_dir(user), f"{user}-only")
provider = LocalSandboxProvider()
sandboxes = {user: provider.get(provider.acquire("shared-thread", user_id=user)) for user in ("alice", "bob")}
monkeypatch.setattr(tools, "ensure_sandbox_initialized", lambda runtime: sandboxes[runtime.context["user_id"]])
def runtime(user="alice"):
return SimpleNamespace(
context={"thread_id": "shared-thread", "user_id": user},
config={},
state={
"sandbox": {"sandbox_id": sandboxes[user].id},
"thread_data": {
"workspace_path": str(paths.sandbox_work_dir("shared-thread", user_id=user)),
"uploads_path": str(paths.sandbox_uploads_dir("shared-thread", user_id=user)),
"outputs_path": str(paths.sandbox_outputs_dir("shared-thread", user_id=user)),
},
},
)
return SimpleNamespace(sandboxes=sandboxes, runtime=runtime, paths=paths)
@pytest.mark.parametrize("tool_name", ["glob", "grep"])
def test_public_search_of_category_only_root_preserves_visibility(skill_search, tool_name):
sandbox = skill_search.sandboxes["alice"]
assert "/mnt/skills" not in {m.container_path for m in sandbox.path_mappings}
assert "/mnt/skills/public/" in tools.ls_tool.func(runtime=skill_search.runtime(), path="/mnt/skills")
tool = tools.glob_tool if tool_name == "glob" else tools.grep_tool
pattern = "**/SKILL.md" if tool_name == "glob" else "NEEDLE"
for user, other in (("alice", "bob"), ("bob", "alice")):
result = tool.func(runtime=skill_search.runtime(user), path="/mnt/skills", pattern=pattern)
for name in ("alpha", "beta", f"{user}-only"):
assert f"/{name}/SKILL.md" in result
assert f"{other}-only" not in result
assert "disabled" not in result
assert str(skill_search.paths.base_dir) not in result
@pytest.mark.parametrize("pattern", ["public/**/*.md", "*/alpha/*.md", "**/SKILL.md"])
def test_glob_pattern_is_relative_to_aggregate_root(skill_search, pattern):
matches, truncated = skill_search.sandboxes["alice"].glob("/mnt/skills", pattern)
assert "/mnt/skills/public/alpha/SKILL.md" in matches
if pattern.startswith("public/"):
assert all(path.startswith("/mnt/skills/public/") for path in matches)
if "alpha" in pattern:
assert matches == ["/mnt/skills/public/alpha/SKILL.md"]
assert not truncated
def test_grep_glob_is_relative_to_aggregate_root(skill_search):
matches, truncated = skill_search.sandboxes["alice"].grep("/mnt/skills", "NEEDLE", glob="custom/**/*.md", literal=True)
assert [match.path for match in matches] == ["/mnt/skills/custom/alice-only/SKILL.md"]
assert not truncated
def test_glob_can_match_category_directories(skill_search):
sandbox = skill_search.sandboxes["alice"]
assert sandbox.glob("/mnt/skills", "public", include_dirs=True) == (["/mnt/skills/public"], False)
assert sandbox.glob("/mnt/skills", "public") == ([], False)
@pytest.mark.parametrize("method", ["glob", "grep"])
@pytest.mark.parametrize("limit", [1, 2, 3, 10])
def test_search_has_one_budget_across_category_mounts(skill_search, method, limit):
sandbox = skill_search.sandboxes["alice"]
pattern = "**/SKILL.md" if method == "glob" else "NEEDLE"
matches, truncated = getattr(sandbox, method)("/mnt/skills", pattern, max_results=limit)
assert len(matches) == min(limit, 3)
assert truncated is (limit < 3)
@pytest.mark.parametrize("method", ["glob", "grep"])
def test_category_search_missing_paths_and_no_matches_are_unchanged(skill_search, method):
search = getattr(skill_search.sandboxes["alice"], method)
pattern = "SKILL.md" if method == "glob" else "NEEDLE"
matches, _ = search("/mnt/skills/public", pattern)
assert len(matches) == 2
assert search("/mnt/skills", "NEVER_PRESENT_9381") == ([], False)
with pytest.raises(FileNotFoundError):
search("/mnt/skills/missing", pattern)
@pytest.mark.parametrize("method", ["glob", "grep"])
def test_public_search_still_refuses_user_data_root(skill_search, method):
tool = tools.glob_tool if method == "glob" else tools.grep_tool
result = tool.func(runtime=skill_search.runtime(), path="/mnt/user-data", pattern="*")
assert result.startswith("Error:")
@pytest.mark.parametrize("method", ["glob", "grep"])
def test_aggregate_mapping_keeps_its_existing_search_path(tmp_path, method):
_write_skill(tmp_path / "public", "allowed")
sandbox = LocalSandbox("local:scoped", path_mappings=[PathMapping("/mnt/skills", str(tmp_path), read_only=True)])
pattern = "**/SKILL.md" if method == "glob" else "NEEDLE"
matches, _ = getattr(sandbox, method)("/mnt/skills", pattern)
result_paths = matches if method == "glob" else [match.path for match in matches]
assert result_paths == ["/mnt/skills/public/allowed/SKILL.md"]
with pytest.raises(OSError):
sandbox.write_file("/mnt/skills/public/allowed/SKILL.md", "replacement")
@pytest.mark.parametrize("method", ["glob", "grep"])
def test_virtual_parent_search_does_not_walk_unmounted_host_files(tmp_path, method):
parent = tmp_path / "virtual-parent"
parent.mkdir()
(parent / "unmounted.md").write_text("NEEDLE private host file", encoding="utf-8")
mounted = tmp_path / "mounted"
_write_skill(mounted, "allowed")
virtual = parent.as_posix()
sandbox = LocalSandbox("local:overlay", path_mappings=[PathMapping(f"{virtual}/public", str(mounted), read_only=True)])
pattern = "**/*.md" if method == "glob" else "NEEDLE"
matches, _ = getattr(sandbox, method)(virtual, pattern)
result_paths = matches if method == "glob" else [match.path for match in matches]
assert result_paths == [f"{virtual}/public/allowed/SKILL.md"]
@pytest.mark.parametrize("method", ["glob", "grep"])
def test_search_does_not_write_through_readonly_category_mounts(skill_search, method):
sandbox = skill_search.sandboxes["alice"]
pattern = "**/SKILL.md" if method == "glob" else "NEEDLE"
getattr(sandbox, method)("/mnt/skills", pattern)
with pytest.raises(OSError):
sandbox.write_file("/mnt/skills/public/alpha/SKILL.md", "replacement")
@pytest.mark.parametrize("method", ["glob", "grep"])
def test_configured_virtual_root_and_empty_mounts(tmp_path, method):
mounted = tmp_path / "public"
mounted.mkdir()
sandbox = LocalSandbox("local:configured", path_mappings=[PathMapping("/opt/team-skills/public", str(mounted), read_only=True)])
search = getattr(sandbox, method)
pattern = "**/SKILL.md" if method == "glob" else "NEEDLE"
assert search("/opt/team-skills", pattern) == ([], False)
_write_skill(mounted, "allowed")
matches, _ = search("/opt/team-skills/", pattern)
result_paths = matches if method == "glob" else [match.path for match in matches]
assert result_paths == ["/opt/team-skills/public/allowed/SKILL.md"]
@pytest.mark.parametrize("method", ["glob", "grep"])
def test_search_errors_keep_virtual_paths(skill_search, monkeypatch, method):
def denied(root, *args, **kwargs):
raise PermissionError(errno.EACCES, "Permission denied", str(root / "blocked"))
name = "find_glob_matches" if method == "glob" else "find_grep_matches"
monkeypatch.setattr(f"deerflow.sandbox.local.local_sandbox.{name}", denied)
with pytest.raises(PermissionError) as caught:
getattr(skill_search.sandboxes["alice"], method)("/mnt/skills", "*")
assert caught.value.filename.startswith("/mnt/skills/")
assert str(skill_search.paths.base_dir) not in str(caught.value)
@pytest.mark.parametrize("method", ["glob", "grep"])
def test_existing_file_is_not_treated_as_a_virtual_parent(tmp_path, method):
parent = tmp_path / "not-a-directory"
parent.write_text("host file", encoding="utf-8")
mounted = tmp_path / "public"
mounted.mkdir()
virtual = parent.as_posix()
sandbox = LocalSandbox("local:invalid-parent", path_mappings=[PathMapping(f"{virtual}/public", str(mounted), read_only=True)])
with pytest.raises(NotADirectoryError):
getattr(sandbox, method)(virtual, "*")
@pytest.mark.parametrize("method", ["glob", "grep"])
def test_missing_mount_cannot_fall_back_to_unmounted_host_files(tmp_path, method):
parent = tmp_path / "virtual-parent"
parent.mkdir()
(parent / "unmounted.md").write_text("NEEDLE private host file", encoding="utf-8")
virtual = parent.as_posix()
sandbox = LocalSandbox("local:missing-mount", path_mappings=[PathMapping(f"{virtual}/public", str(tmp_path / "missing"), read_only=True)])
with pytest.raises(FileNotFoundError):
getattr(sandbox, method)(virtual, "*")
@pytest.mark.parametrize("method", ["glob", "grep"])
@pytest.mark.parametrize("mount_name", ["node_modules", "build", "dist", "out", "logs", "env", "target"])
def test_explicit_mount_with_ignored_name_is_searchable(tmp_path, method, mount_name):
parent = tmp_path / "virtual-parent"
parent.mkdir()
(parent / "unmounted.md").write_text("NEEDLE private host file", encoding="utf-8")
mounted = tmp_path / "mounted"
_write_skill(mounted, "allowed")
_write_skill(mounted / "node_modules", "hidden")
(mounted / "ignored.log").write_text("NEEDLE ignored file", encoding="utf-8")
virtual = parent.as_posix()
mount_path = f"{virtual}/{mount_name}"
sandbox = LocalSandbox("local:explicit-mount", path_mappings=[PathMapping(mount_path, str(mounted), read_only=True)])
expected = [f"{mount_path}/allowed/SKILL.md"]
assert f"{mount_path}/" in sandbox.list_dir(virtual)
assert "NEEDLE allowed" in sandbox.read_file(expected[0])
pattern = "**/*" if method == "glob" else "NEEDLE"
matches, truncated = getattr(sandbox, method)(virtual, pattern)
result_paths = matches if method == "glob" else [match.path for match in matches]
assert result_paths == expected
assert not truncated
root_pattern = f"{mount_name}/**/SKILL.md"
if method == "glob":
assert sandbox.glob(virtual, root_pattern) == (expected, False)
assert sandbox.glob(virtual, mount_name, include_dirs=True) == ([mount_path], False)
else:
matches, truncated = sandbox.grep(virtual, "NEEDLE", glob=root_pattern)
assert [match.path for match in matches] == expected
assert not truncated
@pytest.mark.parametrize("method", ["glob", "grep"])
@pytest.mark.parametrize("mount_state", ["missing", "file"])
def test_broken_mount_warns_with_its_virtual_category(skill_search, caplog, method, mount_state):
sandbox = skill_search.sandboxes["alice"]
mapping = next(mapping for mapping in sandbox.path_mappings if mapping.container_path == "/mnt/skills/public")
root = Path(mapping.local_path)
root.rename(root.with_name("public-backup"))
if mount_state == "file":
root.write_text("not a directory", encoding="utf-8")
tool = tools.glob_tool if method == "glob" else tools.grep_tool
pattern = "**/SKILL.md" if method == "glob" else "NEEDLE"
with caplog.at_level(logging.WARNING, logger="deerflow.sandbox.local.local_sandbox"):
result = tool.func(runtime=skill_search.runtime(), path="/mnt/skills", pattern=pattern)
expected_error = "Directory not found" if mount_state == "missing" else "Path is not a directory"
assert result == f"Error: {expected_error}: /mnt/skills"
warnings = [record for record in caplog.records if record.name == "deerflow.sandbox.local.local_sandbox" and record.levelno == logging.WARNING]
assert len(warnings) == 1
assert mapping.container_path in warnings[0].getMessage()
assert str(root) not in result