fix(sandbox): let the local ls tool list the /mnt/user-data root (#6991)

* fix(sandbox): let local file tools read the /mnt/user-data root

The local provider maps the aggregate /mnt/user-data root so listing it
matches AIO, but the tool layer only accepted paths below it: ls, glob
and grep on the root answered "Permission denied" (with a trailing
slash, from the resolved-path "path traversal detected" check).

validate_local_tool_path now accepts the bare root for reads, and the
user-data resolver accepts the shared parent of workspace/uploads/
outputs itself when the caller opts in. Only ls and the shared read
path opt in; writes to the root and files placed directly in it stay
rejected.

* docs(changelog): reference #6991 for the /mnt/user-data root fix

* fix(sandbox): list the /mnt/user-data root through its allowed dirs only

The first version let glob, grep and ls start at the user-data root
after validating only the root itself. The walkers then descended into
everything under the host user-data/ directory, so a file or directory
stored directly under it was listed and searched although read_file
rejects it.

ls now lists the root one allowed directory at a time (workspace,
uploads and outputs, each one level deep) through the sandbox's own
list_dir, so the host root is never walked and list_dir's existing
per-directory containment check still filters symlinks. The resolver
is back to main's behavior, so glob, grep and read_file keep rejecting
the root.
This commit is contained in:
Hyeonsang Cho 2026-10-18 08:48:06 +09:00 committed by GitHub
parent a77a454ca7
commit 3c08a9f93e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 192 additions and 5 deletions

View File

@ -5982,6 +5982,14 @@ This release closes that milestone with **621 merged pull requests**.
names the same file as the equivalent relative path, unless the command
rebinds `PWD` itself. Variables inside URLs are unaffected. The guard
remains best-effort and is not an isolation boundary. ([#6930])
- **sandbox:** On the local sandbox, `ls` now accepts the `/mnt/user-data`
root itself and lists `workspace`, `uploads` and `outputs` as it does on
AIO. The provider maps that root on purpose, but the tool layer allowed
only paths below it and answered `Permission denied` (with a trailing
slash, from an internal "path traversal detected" check). The listing
covers only those three directories, so anything else stored directly
under the root stays hidden. `glob`, `grep`, `read_file` and writes still
reject the root, because a search would read every file below it. ([#6991])
### Security
@ -11778,3 +11786,4 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#6897]: https://github.com/bytedance/deer-flow/pull/6897
[#6909]: https://github.com/bytedance/deer-flow/pull/6909
[#6930]: https://github.com/bytedance/deer-flow/pull/6930
[#6991]: https://github.com/bytedance/deer-flow/pull/6991

View File

@ -4538,6 +4538,12 @@
因为它会把相对路径的 `cd` 移出工作区。`$PWD/...` 仍然允许,因为它与对应的相对路径
指向同一文件,除非命令自己重新绑定了 `PWD`。URL 中的变量不受影响。该守卫仍是尽力
而为的检查,并非隔离边界。([#6930])
- **沙箱:** 在本地沙箱中,`ls` 现在接受 `/mnt/user-data` 根目录本身,并像 AIO 一样
列出 `workspace`、`uploads` 和 `outputs`。提供方有意映射了这个根目录,但工具层只允许
其下的路径,因此返回 `Permission denied`(带结尾斜杠时来自内部的
“path traversal detected” 检查)。列表只包含这三个目录,直接存放在根目录下的其他内容
仍然不可见。`glob`、`grep`、`read_file` 和写入仍然拒绝根目录,因为搜索会读取其下的
所有文件。([#6991])
### 安全
@ -9391,3 +9397,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#6897]: https://github.com/bytedance/deer-flow/pull/6897
[#6909]: https://github.com/bytedance/deer-flow/pull/6909
[#6930]: https://github.com/bytedance/deer-flow/pull/6930
[#6991]: https://github.com/bytedance/deer-flow/pull/6991

View File

@ -20,7 +20,7 @@ cancellation before propagating it.
Holders stay bound through scope cleanup; AIO reserves idle teardown atomically.
Fork lookups reject invalidated clients; cancelled acquires leave borrowers a pending release.
**Implementations**:
- `LocalSandboxProvider` - Local filesystem execution. `acquire(thread_id)` returns a per-user/thread `LocalSandbox` (id `local:{user_id}:{thread_id}`) whose `path_mappings` resolve `/mnt/user-data/{workspace,uploads,outputs}` and `/mnt/acp-workspace` to that thread's host directories, so the public `Sandbox` API honours the `/mnt/user-data` contract uniformly with AIO. `acquire()` / `acquire(None)` keeps the legacy generic singleton (id `local`) for callers without a thread context. Per-thread sandboxes are held in an LRU cache (default 256 entries) guarded by a `threading.Lock`. Shared runs use category mappings; a policy-scoped run replaces them with one `/mnt/skills` root mapping to the coherent thread view, so structured file tools resolve through one managed boundary. This is not a host filesystem security boundary: an enabled host `bash` subprocess can use canonical paths without `PathMapping`, so `supports_agent_skill_isolation` is dynamic and explicit Agent policies fail closed while host bash is enabled; unrestricted runs then get the shared mappings, never a leftover thread view. Host-to-virtual output masking scans dynamic per-user/per-thread roots directly instead of compiling path-specific regexes, so evicted thread IDs do not remain in Python's global regex caches; a separate 256-entry root cache prevents repeated `realpath()` walks for every glob/grep match while bounding dynamic-path retention, and only the small process-stable skill/integration source set uses a bounded compiled cache. The shared `path_patterns.py` tail stops at `:`, so `$PATH`-style lists mask every entry; a mount symlink resolving outside all mounts keeps its mount path. On Windows, Git Bash/MSYS argument-conversion exclusions are limited to safe non-root virtual path prefixes; do not restore a blanket conversion disable, because host-native CLI launchers need normal MSYS path conversion for their own installation paths.
- `LocalSandboxProvider` - Local filesystem execution. `acquire(thread_id)` returns a per-user/thread `LocalSandbox` (id `local:{user_id}:{thread_id}`) whose `path_mappings` resolve `/mnt/user-data` (file tools: `ls` lists only these three; search/read reject the root), its `{workspace,uploads,outputs}`, and `/mnt/acp-workspace` to that thread's host directories, so the public `Sandbox` API honours the `/mnt/user-data` contract uniformly with AIO. `acquire()` / `acquire(None)` keeps the legacy generic singleton (id `local`) for callers without a thread context. Per-thread sandboxes are held in an LRU cache (default 256 entries) guarded by a `threading.Lock`. Shared runs use category mappings; a policy-scoped run replaces them with one `/mnt/skills` root mapping to the coherent thread view, so structured file tools resolve through one managed boundary. This is not a host filesystem security boundary: an enabled host `bash` subprocess can use canonical paths without `PathMapping`, so `supports_agent_skill_isolation` is dynamic and explicit Agent policies fail closed while host bash is enabled; unrestricted runs then get the shared mappings, never a leftover thread view. Host-to-virtual output masking scans dynamic per-user/per-thread roots directly instead of compiling path-specific regexes, so evicted thread IDs do not remain in Python's global regex caches; a separate 256-entry root cache prevents repeated `realpath()` walks for every glob/grep match while bounding dynamic-path retention, and only the small process-stable skill/integration source set uses a bounded compiled cache. The shared `path_patterns.py` tail stops at `:`, so `$PATH`-style lists mask every entry; a mount symlink resolving outside all mounts keeps its mount path. On Windows, Git Bash/MSYS argument-conversion exclusions are limited to safe non-root virtual path prefixes; do not restore a blanket conversion disable, because host-native CLI launchers need normal MSYS path conversion for their own installation paths.
PowerShell fallback pairs guarded console UTF-8 setters with explicit UTF-8 pipe decoding; MSYS prefers UTF-8 with an ANSI fallback. Cmd builtin output uses `GetConsoleOutputCP()`, then `GetOEMCP()` when no console page is available, and finally `locale.getencoding()` if the APIs cannot supply a page. Query per command because the console page can change; keep `_run_windows_command(encoding=None)` aligned with Python text-mode semantics. Native tools may use their own encodings. Encoding regressions include real pipes with distinct console/ANSI pages and Windows-only cmd roundtrips in private consoles, plus PowerShell roundtrips with and without a console.
- `AioSandboxProvider` (`packages/harness/deerflow/community/`) - Docker isolation. Reuse enforces minimum persisted shell capacity even without image-env overrides. Upgrades require ownership-fenced teardown; provisioner create returns 409 for undersized Pods without deleting them. Acquire/reuse drops definitively dead active/warm entries before rediscovery/creation. Health-check failures mean unknown, so local discovery skips unverifiable containers. Acquire/reclaim publishes ownership; `_renew_owned_leases` refreshes it. Text appends use native AIO append, never read-modify-write. `reset()` closes acquire pools; `shutdown()` performs full teardown. Async acquire separates lock waiters from holder work; started cache/warm workers drain, queued work/skills projection stay cancellable. Create polls flock; owned work/readiness/registration/cleanup drain before unlock. `uses_thread_data_mounts` defaults to backend detection (`LocalContainerBackend=True`, remote/provisioner=False); `sandbox.thread_data_mounts` overrides this for deployments sharing thread user-data directories. `true` skips upload acquire/sync; a false positive makes uploads unavailable. Explicit Agent policies use four thread projection category mounts and a distinct deterministic identity, excluding older shared-mount containers. The startup snapshot of `skills.container_path` drives mounts, identity, remote requests, and provisioner validation; root changes prevent container/Pod reuse. Gateway and provisioner independently require a canonical absolute root outside reserved platform mounts and derive four category allowlist entries from it. All four category overrides suppress the provisioner's default hostPath/skills-PVC mount; with `USERDATA_PVC_NAME`, categories use subpaths on that shared PVC. Readiness probes and `agent_sandbox` clients set `trust_env=False` for direct control-plane destinations (loopback/private IPs, single-label cluster hosts, Docker/Podman internal hostnames); external FQDNs and public IPs retain environment proxies.
- `E2BSandboxProvider` (`packages/harness/deerflow/community/e2b_sandbox/`) provides E2B remote isolation.

View File

@ -1013,6 +1013,7 @@ def validate_local_tool_path(path: str, thread_data: ThreadDataState | None, *,
Allowed virtual-path families:
- ``/mnt/user-data/*`` — always allowed (read + write)
- ``/mnt/user-data`` — the bare root, allowed only when *read_only* is True
- ``/mnt/skills/*`` — allowed only when *read_only* is True
- ``/mnt/acp-workspace/*`` — allowed only when *read_only* is True
- Custom mount paths (from config.yaml) — respects per-mount ``read_only`` flag
@ -1047,8 +1048,10 @@ def validate_local_tool_path(path: str, thread_data: ThreadDataState | None, *,
raise PermissionError(f"Write access to ACP workspace is not allowed: {path}")
return
# User-data paths
if path.startswith(f"{VIRTUAL_PATH_PREFIX}/"):
# User-data paths. The bare root passes for reads because the local
# provider maps it for parity with AIO; the resolver still rejects it,
# and only ls lists it, one allowed directory at a time.
if path.startswith(f"{VIRTUAL_PATH_PREFIX}/") or (read_only and path == VIRTUAL_PATH_PREFIX):
return
# Custom mount paths — respect read_only config
@ -1100,6 +1103,29 @@ def _resolve_and_validate_user_data_path(path: str, thread_data: ThreadDataState
return str(resolved)
def _user_data_root_dirs(path: str, thread_data: ThreadDataState) -> dict[str, str] | None:
"""Map virtual to host workspace/uploads/outputs when *path* is the user-data root.
The local provider maps the root so ``ls`` matches AIO, but its host
directory can hold more than the three allowed directories. Callers list
each of them instead of walking the root. Any other path returns None.
"""
mappings = _thread_virtual_to_actual_mappings(thread_data)
user_data_root = mappings.pop(VIRTUAL_PATH_PREFIX, None)
if user_data_root is None or Path(replace_virtual_path(path, thread_data)).resolve() != Path(user_data_root).resolve():
return None
return mappings
def _list_user_data_root(sandbox: Sandbox, user_data_dirs: dict[str, str]) -> list[str]:
"""List each allowed directory and one level below it, matching ls depth 2."""
entries: list[str] = []
for virtual_dir, actual_dir in user_data_dirs.items():
entries.append(f"{virtual_dir}/")
entries.extend(sandbox.list_dir(actual_dir, max_depth=1))
return entries
def _is_non_file_url_token(token: str) -> bool:
"""Return True for URL tokens that should not be interpreted as paths."""
values = [token]
@ -2577,6 +2603,7 @@ def ls_tool(runtime: Runtime, path: str, description: str = "") -> str:
ensure_thread_directories_exist(runtime)
requested_path = path
thread_data = None
user_data_dirs = None
if is_local_sandbox(runtime):
thread_data = get_thread_data(runtime)
validate_local_tool_path(path, thread_data, read_only=True)
@ -2588,9 +2615,11 @@ def ls_tool(runtime: Runtime, path: str, description: str = "") -> str:
# from the sandbox mapping's user_id).
pass
elif not _is_custom_mount_path(path):
path = _resolve_and_validate_user_data_path(path, thread_data)
user_data_dirs = _user_data_root_dirs(path, thread_data)
if user_data_dirs is None:
path = _resolve_and_validate_user_data_path(path, thread_data)
# Custom mount paths and skills/ACP paths are resolved by LocalSandbox._resolve_path()
children = sandbox.list_dir(path)
children = _list_user_data_root(sandbox, user_data_dirs) if user_data_dirs is not None else sandbox.list_dir(path)
if not children:
return "(empty)"
output = "\n".join(children)

View File

@ -143,6 +143,43 @@ def test_execute_command_lists_aggregate_user_data_root(provider):
assert "outputs" in output
@pytest.mark.parametrize("path", ["/mnt/user-data", "/mnt/user-data/"])
def test_ls_tool_lists_aggregate_user_data_root(provider, path):
"""The tool layer lists the root the provider maps, but only its three
allowed dirs: files and directories planted directly under it stay hidden."""
from deerflow.config.paths import get_paths
from deerflow.sandbox.tools import ls_tool
sandbox_id = provider.acquire("alpha", user_id="alice")
sbx = provider.get(sandbox_id)
sbx.write_file("/mnt/user-data/uploads/report.txt", "ready")
paths = get_paths()
user_data = paths.sandbox_user_data_dir("alpha", user_id="alice")
(user_data / "private.txt").write_text("PROVIDER_SECRET", encoding="utf-8")
(user_data / "extra").mkdir()
runtime = SimpleNamespace(
state={
"sandbox": {"sandbox_id": sandbox_id},
"thread_data": {
"workspace_path": str(paths.sandbox_work_dir("alpha", user_id="alice")),
"uploads_path": str(paths.sandbox_uploads_dir("alpha", user_id="alice")),
"outputs_path": str(paths.sandbox_outputs_dir("alpha", user_id="alice")),
},
},
context={"thread_id": "alpha", "user_id": "alice"},
)
with patch("deerflow.sandbox.tools.ensure_sandbox_initialized", return_value=sbx):
result = ls_tool.func(runtime=runtime, description="list user data", path=path)
for name in ("workspace", "uploads", "outputs"):
assert f"/mnt/user-data/{name}" in result
assert "/mnt/user-data/uploads/report.txt" in result
assert "private.txt" not in result
assert "extra" not in result
assert str(paths.base_dir) not in result
def test_list_dir_on_user_data_root_does_not_duplicate_subdir_mounts(provider):
"""Regression: ``list_dir``'s virtual sub-directory overlay must not
double-list a mount that the underlying scan already found.

View File

@ -790,6 +790,71 @@ def test_ls_tool_filters_upload_staging_files(tmp_path, monkeypatch) -> None:
assert ".upload-active.part" not in result
@pytest.mark.parametrize("path", ["/mnt/user-data", "/mnt/user-data/"])
def test_ls_tool_lists_user_data_root(tmp_path, monkeypatch, path) -> None:
"""The local provider maps the aggregate root, so ls must list it like AIO."""
runtime = _make_runtime(tmp_path)
(tmp_path / "uploads" / "report.txt").write_text("ready\n", encoding="utf-8")
(tmp_path / "uploads" / ".upload-active.part").write_text("partial\n", encoding="utf-8")
(tmp_path / "workspace" / "src").mkdir()
(tmp_path / "workspace" / "src" / "deep.py").write_text("x = 1\n", encoding="utf-8")
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox(id="local"))
result = ls_tool.func(runtime=runtime, description="list user data", path=path)
for name in ("workspace", "uploads", "outputs"):
assert f"/mnt/user-data/{name}" in result
assert "/mnt/user-data/uploads/report.txt" in result
assert "/mnt/user-data/workspace/src" in result
# Two levels from the root, like any other ls.
assert "deep.py" not in result
assert ".upload-active.part" not in result
assert str(tmp_path) not in result
def _plant_outside_allowed_dirs(tmp_path) -> None:
"""Content in user-data/ that is not under workspace, uploads, or outputs."""
(tmp_path / "private.txt").write_text("ROOT_SECRET\n", encoding="utf-8")
(tmp_path / "extra").mkdir()
(tmp_path / "extra" / "notes.txt").write_text("EXTRA_SECRET\n", encoding="utf-8")
@pytest.mark.parametrize("path", ["/mnt/user-data", "/mnt/user-data/"])
def test_ls_tool_on_user_data_root_lists_only_allowed_dirs(tmp_path, monkeypatch, path) -> None:
runtime = _make_runtime(tmp_path)
_plant_outside_allowed_dirs(tmp_path)
symlink_or_skip(tmp_path / "workspace" / "alias.txt", tmp_path / "private.txt")
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox(id="local"))
result = ls_tool.func(runtime=runtime, description="list user data", path=path)
assert "/mnt/user-data/workspace" in result
assert "private.txt" not in result
assert "extra" not in result
@pytest.mark.parametrize(
("tool", "arguments"),
[
(glob_tool, {"pattern": "**/*.txt"}),
(grep_tool, {"pattern": "SECRET"}),
],
)
@pytest.mark.parametrize("path", ["/mnt/user-data", "/mnt/user-data/"])
def test_search_tools_reject_user_data_root(tmp_path, monkeypatch, tool, arguments, path) -> None:
"""A search walks every descendant, so it must not start above the allowed dirs."""
runtime = _make_runtime(tmp_path)
_plant_outside_allowed_dirs(tmp_path)
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox(id="local"))
result = tool.func(runtime=runtime, description="search user data", path=path, **arguments)
assert result == f"Error: Permission denied: {path}"
def _make_skills_sandbox(tmp_path, monkeypatch, *, disabled: str):
"""Skills tree with one disabled and one enabled public skill.

View File

@ -24,6 +24,7 @@ from deerflow.sandbox.tools import (
_resolve_and_validate_user_data_path,
_resolve_skills_path,
bash_tool,
ls_tool,
mask_local_paths_in_output,
replace_virtual_path,
replace_virtual_paths_in_command,
@ -475,6 +476,45 @@ def test_validate_local_tool_path_rejects_bare_virtual_root() -> None:
validate_local_tool_path(VIRTUAL_PATH_PREFIX, _THREAD_DATA)
def test_validate_local_tool_path_allows_bare_virtual_root_for_reads() -> None:
"""The local provider maps the aggregate root so it can be listed like AIO."""
validate_local_tool_path(VIRTUAL_PATH_PREFIX, _THREAD_DATA, read_only=True)
@pytest.mark.parametrize("path", [VIRTUAL_PATH_PREFIX, f"{VIRTUAL_PATH_PREFIX}/", f"{VIRTUAL_PATH_PREFIX}/stray.txt"])
def test_resolve_user_data_path_keeps_rejecting_the_root(path: str) -> None:
"""Read and search tools resolve here; only ls lists the root, one allowed dir at a time."""
with pytest.raises(PermissionError, match="path traversal"):
_resolve_and_validate_user_data_path(path, _THREAD_DATA)
def test_ls_tool_rejects_user_data_root_without_a_shared_parent(tmp_path) -> None:
"""Without a common parent there is no host root to list, so stay closed."""
thread_data = {name: str(tmp_path / name / name.removesuffix("_path")) for name in ("workspace_path", "uploads_path", "outputs_path")}
for directory in thread_data.values():
Path(directory).mkdir(parents=True)
runtime = SimpleNamespace(state={"sandbox": {"sandbox_id": "local"}, "thread_data": thread_data}, context={"thread_id": "thread-1"})
with patch("deerflow.sandbox.tools.ensure_sandbox_initialized", return_value=LocalSandbox(id="local")):
result = ls_tool.func(runtime=runtime, description="list", path=VIRTUAL_PATH_PREFIX)
assert result == f"Error: Permission denied: {VIRTUAL_PATH_PREFIX}"
@pytest.mark.parametrize("path", [VIRTUAL_PATH_PREFIX, f"{VIRTUAL_PATH_PREFIX}/"])
def test_write_file_tool_rejects_user_data_root(tmp_path, path: str) -> None:
thread_data = {name: str(tmp_path / "user-data" / name.removesuffix("_path")) for name in ("workspace_path", "uploads_path", "outputs_path")}
for directory in thread_data.values():
Path(directory).mkdir(parents=True)
runtime = SimpleNamespace(state={"sandbox": {"sandbox_id": "local"}, "thread_data": thread_data}, context={"thread_id": "thread-1"})
sandbox = SimpleNamespace(write_file=lambda *args, **kwargs: pytest.fail("write reached the sandbox"))
with patch("deerflow.sandbox.tools.ensure_sandbox_initialized", return_value=sandbox):
result = write_file_tool.func(runtime=runtime, description="write", path=path, content="x")
assert result == f"Error: Permission denied writing to file: {path}"
def test_validate_local_tool_path_allows_user_data_paths() -> None:
# Should not raise — user-data paths are always allowed
validate_local_tool_path(f"{VIRTUAL_PATH_PREFIX}/workspace/file.txt", _THREAD_DATA)