mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
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:
parent
a77a454ca7
commit
3c08a9f93e
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user