[Security] Address critical host-shell escape in LocalSandboxProvider (#1547)

* fix(security): disable host bash by default in local sandbox

* fix(security): address review feedback for local bash hardening

* fix(ci): sort live test imports for lint

* style: apply backend formatter

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
13ernkastel 2026-03-29 21:03:58 +08:00 committed by GitHub
parent 8b6c333afc
commit 92c7a20cb7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 322 additions and 28 deletions

View File

@ -428,7 +428,7 @@ That told us something important: DeerFlow wasn't just a research tool. It was a
So we rebuilt it from scratch.
DeerFlow 2.0 is no longer a framework you wire together. It's a super agent harness — batteries included, fully extensible. Built on LangGraph and LangChain, it ships with everything an agent needs out of the box: a filesystem, memory, skills, sandboxed execution, and the ability to plan and spawn sub-agents for complex, multi-step tasks.
DeerFlow 2.0 is no longer a framework you wire together. It's a super agent harness — batteries included, fully extensible. Built on LangGraph and LangChain, it ships with everything an agent needs out of the box: a filesystem, memory, skills, sandbox-aware execution, and the ability to plan and spawn sub-agents for complex, multi-step tasks.
Use it as-is. Or tear it apart and make it yours.
@ -502,7 +502,9 @@ This is how DeerFlow handles tasks that take minutes to hours: a research task m
DeerFlow doesn't just *talk* about doing things. It has its own computer.
Each task runs inside an isolated Docker container with a full filesystem — skills, workspace, uploads, outputs. The agent reads, writes, and edits files. It executes bash commands and codes. It views images. All sandboxed, all auditable, zero contamination between sessions.
Each task gets its own execution environment with a full filesystem view — skills, workspace, uploads, outputs. The agent reads, writes, and edits files. It can view images and, when configured safely, execute shell commands.
With `AioSandboxProvider`, shell execution runs inside isolated containers. With `LocalSandboxProvider`, file tools still map to per-thread directories on the host, but host `bash` is disabled by default because it is not a secure isolation boundary. Re-enable host bash only for fully trusted local workflows.
This is the difference between a chatbot with tool access and an agent with an actual execution environment.

View File

@ -78,13 +78,13 @@ Per-thread isolated execution with virtual path translation:
- **Virtual paths**: `/mnt/user-data/{workspace,uploads,outputs}` → thread-specific physical directories
- **Skills path**: `/mnt/skills``deer-flow/skills/` directory
- **Skills loading**: Recursively discovers nested `SKILL.md` files under `skills/{public,custom}` and preserves nested container paths
- **Tools**: `bash`, `ls`, `read_file`, `write_file`, `str_replace`
- **Tools**: `bash`, `ls`, `read_file`, `write_file`, `str_replace` (`bash` is disabled by default when using `LocalSandboxProvider`; use `AioSandboxProvider` for isolated shell access)
### Subagent System
Async task delegation with concurrent execution:
- **Built-in agents**: `general-purpose` (full toolset) and `bash` (command specialist)
- **Built-in agents**: `general-purpose` (full toolset) and `bash` (command specialist, exposed only when shell access is available)
- **Concurrency**: Max 3 subagents per turn, 15-minute timeout
- **Execution**: Background thread pools with status tracking and SSE events
- **Flow**: Agent calls `task()` tool → executor runs subagent in background → polls for completion → returns result

View File

@ -208,6 +208,7 @@ DeerFlow supports multiple sandbox execution modes. Configure your preferred mod
```yaml
sandbox:
use: deerflow.sandbox.local:LocalSandboxProvider # Local execution
allow_host_bash: false # default; host bash is disabled unless explicitly re-enabled
```
**Docker Execution** (runs sandbox code in isolated Docker containers):
@ -236,8 +237,11 @@ Choose between local execution or Docker-based isolation:
```yaml
sandbox:
use: deerflow.sandbox.local:LocalSandboxProvider
allow_host_bash: false
```
`allow_host_bash` is intentionally `false` by default. DeerFlow's local sandbox is a host-side convenience mode, not a secure shell isolation boundary. If you need `bash`, prefer `AioSandboxProvider`. Only set `allow_host_bash: true` for fully trusted single-user local workflows.
**Option 2: Docker Sandbox** (isolated, more secure):
```yaml
sandbox:

View File

@ -3,6 +3,7 @@ from datetime import datetime
from deerflow.config.agents_config import load_agent_soul
from deerflow.skills import load_skills
from deerflow.subagents import get_available_subagent_names
logger = logging.getLogger(__name__)
@ -17,6 +18,19 @@ def _build_subagent_section(max_concurrent: int) -> str:
Formatted subagent section string.
"""
n = max_concurrent
bash_available = "bash" in get_available_subagent_names()
available_subagents = (
"- **general-purpose**: For ANY non-trivial task - web research, code exploration, file operations, analysis, etc.\n- **bash**: For command execution (git, build, test, deploy operations)"
if bash_available
else "- **general-purpose**: For ANY non-trivial task - web research, code exploration, file operations, analysis, etc.\n"
"- **bash**: Not available in the current sandbox configuration. Use direct file/web tools or switch to AioSandboxProvider for isolated shell access."
)
direct_tool_examples = "bash, ls, read_file, web_search, etc." if bash_available else "ls, read_file, web_search, etc."
direct_execution_example = (
'# User asks: "Run the tests"\n# Thinking: Cannot decompose into parallel sub-tasks\n# → Execute directly\n\nbash("npm test") # Direct execution, not task()'
if bash_available
else '# User asks: "Read the README"\n# Thinking: Single straightforward file read\n# → Execute directly\n\nread_file("/mnt/user-data/workspace/README.md") # Direct execution, not task()'
)
return f"""<subagent_system>
**🚀 SUBAGENT MODE ACTIVE - DECOMPOSE, DELEGATE, SYNTHESIZE**
@ -40,8 +54,7 @@ You are running with subagent capabilities enabled. Your role is to be a **task
- **Example thinking pattern**: "I identified 6 sub-tasks. Since the limit is {n} per turn, I will launch the first {n} now, and the rest in the next turn."
**Available Subagents:**
- **general-purpose**: For ANY non-trivial task - web research, code exploration, file operations, analysis, etc.
- **bash**: For command execution (git, build, test, deploy operations)
{available_subagents}
**Your Orchestration Strategy:**
@ -89,7 +102,7 @@ For complex queries, break them down into focused sub-tasks and execute in paral
3. **EXECUTE**: Launch ONLY the current batch (max {n} `task` calls). Do NOT launch sub-tasks from future batches.
4. **REPEAT**: After results return, launch the next batch. Continue until all batches complete.
5. **SYNTHESIZE**: After ALL batches are done, synthesize all results.
6. **Cannot decompose** Execute directly using available tools (bash, read_file, web_search, etc.)
6. **Cannot decompose** Execute directly using available tools ({direct_tool_examples})
** VIOLATION: Launching more than {n} `task` calls in a single response is a HARD ERROR. The system WILL discard excess calls and you WILL lose work. Always batch.**
@ -135,11 +148,7 @@ task(description="Oracle Cloud analysis", prompt="...", subagent_type="general-p
**Counter-Example - Direct Execution (NO subagents):**
```python
# User asks: "Run the tests"
# Thinking: Cannot decompose into parallel sub-tasks
# → Execute directly
bash("npm test") # Direct execution, not task()
{direct_execution_example}
```
**CRITICAL**:

View File

@ -14,6 +14,8 @@ class SandboxConfig(BaseModel):
Common options:
use: Class path of the sandbox provider (required)
allow_host_bash: Enable host-side bash execution for LocalSandboxProvider.
Dangerous and intended only for fully trusted local workflows.
AioSandboxProvider specific options:
image: Docker image to use (default: enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:latest)
@ -29,6 +31,10 @@ class SandboxConfig(BaseModel):
...,
description="Class path of the sandbox provider (e.g. deerflow.sandbox.local:LocalSandboxProvider)",
)
allow_host_bash: bool = Field(
default=False,
description="Allow the bash tool to execute directly on the host when using LocalSandboxProvider. Dangerous; intended only for fully trusted local environments.",
)
image: str | None = Field(
default=None,
description="Docker image to use for the sandbox container",

View File

@ -0,0 +1,45 @@
"""Security helpers for sandbox capability gating."""
from deerflow.config import get_app_config
_LOCAL_SANDBOX_PROVIDER_MARKERS = (
"deerflow.sandbox.local:LocalSandboxProvider",
"deerflow.sandbox.local.local_sandbox_provider:LocalSandboxProvider",
)
LOCAL_HOST_BASH_DISABLED_MESSAGE = (
"Host bash execution is disabled for LocalSandboxProvider because it is not a secure "
"sandbox boundary. Switch to AioSandboxProvider for isolated bash access, or set "
"sandbox.allow_host_bash: true only in a fully trusted local environment."
)
LOCAL_BASH_SUBAGENT_DISABLED_MESSAGE = (
"Bash subagent is disabled for LocalSandboxProvider because host bash execution is not "
"a secure sandbox boundary. Switch to AioSandboxProvider for isolated bash access, or "
"set sandbox.allow_host_bash: true only in a fully trusted local environment."
)
def uses_local_sandbox_provider(config=None) -> bool:
"""Return True when the active sandbox provider is the host-local provider."""
if config is None:
config = get_app_config()
sandbox_cfg = getattr(config, "sandbox", None)
sandbox_use = getattr(sandbox_cfg, "use", "")
if sandbox_use in _LOCAL_SANDBOX_PROVIDER_MARKERS:
return True
return sandbox_use.endswith(":LocalSandboxProvider") and "deerflow.sandbox.local" in sandbox_use
def is_host_bash_allowed(config=None) -> bool:
"""Return whether host bash execution is explicitly allowed."""
if config is None:
config = get_app_config()
sandbox_cfg = getattr(config, "sandbox", None)
if sandbox_cfg is None:
return True
if not uses_local_sandbox_provider(config):
return True
return bool(getattr(sandbox_cfg, "allow_host_bash", False))

View File

@ -14,6 +14,7 @@ from deerflow.sandbox.exceptions import (
)
from deerflow.sandbox.sandbox import Sandbox
from deerflow.sandbox.sandbox_provider import get_sandbox_provider
from deerflow.sandbox.security import LOCAL_HOST_BASH_DISABLED_MESSAGE, is_host_bash_allowed
_ABSOLUTE_PATH_PATTERN = re.compile(r"(?<![:\w])/(?:[^\s\"'`;&|<>()]+)")
_LOCAL_BASH_SYSTEM_PATH_PREFIXES = (
@ -499,6 +500,10 @@ def _resolve_and_validate_user_data_path(path: str, thread_data: ThreadDataState
def validate_local_bash_command_paths(command: str, thread_data: ThreadDataState | None) -> None:
"""Validate absolute paths in local-sandbox bash commands.
This validation is only a best-effort guard for the explicit
``sandbox.allow_host_bash: true`` opt-in. It is not a secure sandbox
boundary and must not be treated as isolation from the host filesystem.
In local mode, commands must use virtual paths under /mnt/user-data for
user data access. Skills paths under /mnt/skills and ACP workspace paths
under /mnt/acp-workspace are allowed (path-traversal checks only; write
@ -750,13 +755,16 @@ def bash_tool(runtime: ToolRuntime[ContextT, ThreadState], description: str, com
"""
try:
sandbox = ensure_sandbox_initialized(runtime)
ensure_thread_directories_exist(runtime)
thread_data = get_thread_data(runtime)
if is_local_sandbox(runtime):
if not is_host_bash_allowed():
return f"Error: {LOCAL_HOST_BASH_DISABLED_MESSAGE}"
ensure_thread_directories_exist(runtime)
thread_data = get_thread_data(runtime)
validate_local_bash_command_paths(command, thread_data)
command = replace_virtual_paths_in_command(command, thread_data)
output = sandbox.execute_command(command)
return mask_local_paths_in_output(output, thread_data)
ensure_thread_directories_exist(runtime)
return sandbox.execute_command(command)
except SandboxError as e:
return f"Error: {e}"

View File

@ -1,11 +1,12 @@
from .config import SubagentConfig
from .executor import SubagentExecutor, SubagentResult
from .registry import get_subagent_config, list_subagents
from .registry import get_available_subagent_names, get_subagent_config, list_subagents
__all__ = [
"SubagentConfig",
"SubagentExecutor",
"SubagentResult",
"get_available_subagent_names",
"get_subagent_config",
"list_subagents",
]

View File

@ -3,6 +3,7 @@
import logging
from dataclasses import replace
from deerflow.sandbox.security import is_host_bash_allowed
from deerflow.subagents.builtins import BUILTIN_SUBAGENTS
from deerflow.subagents.config import SubagentConfig
@ -50,3 +51,21 @@ def get_subagent_names() -> list[str]:
List of subagent names.
"""
return list(BUILTIN_SUBAGENTS.keys())
def get_available_subagent_names() -> list[str]:
"""Get subagent names that should be exposed to the active runtime.
Returns:
List of subagent names visible to the current sandbox configuration.
"""
names = list(BUILTIN_SUBAGENTS.keys())
try:
host_bash_allowed = is_host_bash_allowed()
except Exception:
logger.debug("Could not determine host bash availability; exposing all built-in subagents")
return names
if not host_bash_allowed:
names = [name for name in names if name != "bash"]
return names

View File

@ -4,7 +4,7 @@ import asyncio
import logging
import uuid
from dataclasses import replace
from typing import Annotated, Literal
from typing import Annotated
from langchain.tools import InjectedToolCallId, ToolRuntime, tool
from langgraph.config import get_stream_writer
@ -12,7 +12,8 @@ from langgraph.typing import ContextT
from deerflow.agents.lead_agent.prompt import get_skills_prompt_section
from deerflow.agents.thread_state import ThreadState
from deerflow.subagents import SubagentExecutor, get_subagent_config
from deerflow.sandbox.security import LOCAL_BASH_SUBAGENT_DISABLED_MESSAGE, is_host_bash_allowed
from deerflow.subagents import SubagentExecutor, get_available_subagent_names, get_subagent_config
from deerflow.subagents.executor import SubagentStatus, cleanup_background_task, get_background_task_result
logger = logging.getLogger(__name__)
@ -23,7 +24,7 @@ async def task_tool(
runtime: ToolRuntime[ContextT, ThreadState],
description: str,
prompt: str,
subagent_type: Literal["general-purpose", "bash"],
subagent_type: str,
tool_call_id: Annotated[str, InjectedToolCallId],
max_turns: int | None = None,
) -> str:
@ -34,12 +35,13 @@ async def task_tool(
- Handle complex multi-step tasks autonomously
- Execute commands or operations in isolated contexts
Available subagent types:
Available subagent types depend on the active sandbox configuration:
- **general-purpose**: A capable agent for complex, multi-step tasks that require
both exploration and action. Use when the task requires complex reasoning,
multiple dependent steps, or would benefit from isolated context.
- **bash**: Command execution specialist for running bash commands. Use for
git operations, build processes, or when command output would be verbose.
- **bash**: Command execution specialist for running bash commands. This is only
available when host bash is explicitly allowed or when using an isolated shell
sandbox such as `AioSandboxProvider`.
When to use this tool:
- Complex tasks requiring multiple steps or tools
@ -57,10 +59,15 @@ async def task_tool(
subagent_type: The type of subagent to use. ALWAYS PROVIDE THIS PARAMETER THIRD.
max_turns: Optional maximum number of agent turns. Defaults to subagent's configured max.
"""
available_subagent_names = get_available_subagent_names()
# Get subagent configuration
config = get_subagent_config(subagent_type)
if config is None:
return f"Error: Unknown subagent type '{subagent_type}'. Available: general-purpose, bash"
available = ", ".join(available_subagent_names)
return f"Error: Unknown subagent type '{subagent_type}'. Available: {available}"
if subagent_type == "bash" and not is_host_bash_allowed():
return f"Error: {LOCAL_BASH_SUBAGENT_DISABLED_MESSAGE}"
# Build config overrides
overrides: dict = {}

View File

@ -4,6 +4,7 @@ from langchain.tools import BaseTool
from deerflow.config import get_app_config
from deerflow.reflection import resolve_variable
from deerflow.sandbox.security import is_host_bash_allowed
from deerflow.tools.builtins import ask_clarification_tool, present_file_tool, task_tool, view_image_tool
from deerflow.tools.builtins.tool_search import reset_deferred_registry
@ -20,6 +21,17 @@ SUBAGENT_TOOLS = [
]
def _is_host_bash_tool(tool: object) -> bool:
"""Return True if the tool config represents a host-bash execution surface."""
group = getattr(tool, "group", None)
use = getattr(tool, "use", None)
if group == "bash":
return True
if use == "deerflow.sandbox.tools:bash_tool":
return True
return False
def get_available_tools(
groups: list[str] | None = None,
include_mcp: bool = True,
@ -41,7 +53,13 @@ def get_available_tools(
List of available tools.
"""
config = get_app_config()
loaded_tools = [resolve_variable(tool.use, BaseTool) for tool in config.tools if groups is None or tool.group in groups]
tool_configs = [tool for tool in config.tools if groups is None or tool.group in groups]
# Do not expose host bash by default when LocalSandboxProvider is active.
if not is_host_bash_allowed(config):
tool_configs = [tool for tool in tool_configs if not _is_host_bash_tool(tool)]
loaded_tools = [resolve_variable(tool.use, BaseTool) for tool in tool_configs]
# Conditionally add tools based on config
builtin_tools = BUILTIN_TOOLS.copy()

View File

@ -72,7 +72,7 @@ def _make_e2e_config() -> AppConfig:
supports_vision=False,
)
],
sandbox=SandboxConfig(use="deerflow.sandbox.local:LocalSandboxProvider"),
sandbox=SandboxConfig(use="deerflow.sandbox.local:LocalSandboxProvider", allow_host_bash=True),
)

View File

@ -13,6 +13,7 @@ from pathlib import Path
import pytest
from deerflow.client import DeerFlowClient, StreamEvent
from deerflow.sandbox.security import is_host_bash_allowed
from deerflow.uploads.manager import PathTraversalError
# Skip entire module in CI or when no config.yaml exists
@ -100,6 +101,9 @@ class TestLiveStreaming:
class TestLiveToolUse:
def test_agent_uses_bash_tool(self, client):
"""Agent uses bash tool when asked to run a command."""
if not is_host_bash_allowed():
pytest.skip("Host bash is disabled for LocalSandboxProvider in the active config")
events = list(client.stream("Use the bash tool to run: echo 'LIVE_TEST_OK'. Then tell me the output."))
types = [e.type for e in events]

View File

@ -0,0 +1,81 @@
from types import SimpleNamespace
from deerflow.tools.tools import get_available_tools
def _make_config(*, allow_host_bash: bool, sandbox_use: str = "deerflow.sandbox.local:LocalSandboxProvider", extra_tools: list[SimpleNamespace] | None = None):
return SimpleNamespace(
tools=[
SimpleNamespace(name="bash", group="bash", use="deerflow.sandbox.tools:bash_tool"),
SimpleNamespace(name="ls", group="file:read", use="tests:ls_tool"),
*(extra_tools or []),
],
models=[],
sandbox=SimpleNamespace(
use=sandbox_use,
allow_host_bash=allow_host_bash,
),
tool_search=SimpleNamespace(enabled=False),
get_model_config=lambda name: None,
)
def test_get_available_tools_hides_bash_for_default_local_sandbox(monkeypatch):
monkeypatch.setattr("deerflow.tools.tools.get_app_config", lambda: _make_config(allow_host_bash=False))
monkeypatch.setattr(
"deerflow.tools.tools.resolve_variable",
lambda use, _: SimpleNamespace(name="bash" if "bash" in use else "ls"),
)
names = [tool.name for tool in get_available_tools(include_mcp=False, subagent_enabled=False)]
assert "bash" not in names
assert "ls" in names
def test_get_available_tools_keeps_bash_when_explicitly_enabled(monkeypatch):
monkeypatch.setattr("deerflow.tools.tools.get_app_config", lambda: _make_config(allow_host_bash=True))
monkeypatch.setattr(
"deerflow.tools.tools.resolve_variable",
lambda use, _: SimpleNamespace(name="bash" if "bash" in use else "ls"),
)
names = [tool.name for tool in get_available_tools(include_mcp=False, subagent_enabled=False)]
assert "bash" in names
assert "ls" in names
def test_get_available_tools_hides_renamed_host_bash_alias(monkeypatch):
config = _make_config(
allow_host_bash=False,
extra_tools=[SimpleNamespace(name="shell", group="bash", use="deerflow.sandbox.tools:bash_tool")],
)
monkeypatch.setattr("deerflow.tools.tools.get_app_config", lambda: config)
monkeypatch.setattr(
"deerflow.tools.tools.resolve_variable",
lambda use, _: SimpleNamespace(name="bash" if "bash_tool" in use else "ls"),
)
names = [tool.name for tool in get_available_tools(include_mcp=False, subagent_enabled=False)]
assert "bash" not in names
assert "shell" not in names
assert "ls" in names
def test_get_available_tools_keeps_bash_for_aio_sandbox(monkeypatch):
config = _make_config(
allow_host_bash=False,
sandbox_use="deerflow.community.aio_sandbox:AioSandboxProvider",
)
monkeypatch.setattr("deerflow.tools.tools.get_app_config", lambda: config)
monkeypatch.setattr(
"deerflow.tools.tools.resolve_variable",
lambda use, _: SimpleNamespace(name="bash" if "bash_tool" in use else "ls"),
)
names = [tool.name for tool in get_available_tools(include_mcp=False, subagent_enabled=False)]
assert "bash" in names
assert "ls" in names

View File

@ -1,4 +1,5 @@
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import pytest
@ -11,6 +12,7 @@ from deerflow.sandbox.tools import (
_resolve_acp_workspace_path,
_resolve_and_validate_user_data_path,
_resolve_skills_path,
bash_tool,
mask_local_paths_in_output,
replace_virtual_path,
replace_virtual_paths_in_command,
@ -255,6 +257,27 @@ def test_validate_local_bash_command_paths_blocks_traversal_in_skills() -> None:
)
def test_bash_tool_rejects_host_bash_when_local_sandbox_default(monkeypatch) -> None:
runtime = SimpleNamespace(
state={"sandbox": {"sandbox_id": "local"}, "thread_data": _THREAD_DATA.copy()},
context={"thread_id": "thread-1"},
)
monkeypatch.setattr(
"deerflow.sandbox.tools.ensure_sandbox_initialized",
lambda runtime: SimpleNamespace(execute_command=lambda command: pytest.fail("host bash should not execute")),
)
monkeypatch.setattr("deerflow.sandbox.tools.is_host_bash_allowed", lambda: False)
result = bash_tool.func(
runtime=runtime,
description="run command",
command="/bin/echo hello",
)
assert "Host bash execution is disabled" in result
# ---------- Skills path tests ----------

View File

@ -0,0 +1,41 @@
"""Tests for subagent availability and prompt exposure under local bash hardening."""
from deerflow.agents.lead_agent import prompt as prompt_module
from deerflow.subagents import registry as registry_module
def test_get_available_subagent_names_hides_bash_when_host_bash_disabled(monkeypatch) -> None:
monkeypatch.setattr(registry_module, "is_host_bash_allowed", lambda: False)
names = registry_module.get_available_subagent_names()
assert names == ["general-purpose"]
def test_get_available_subagent_names_keeps_bash_when_allowed(monkeypatch) -> None:
monkeypatch.setattr(registry_module, "is_host_bash_allowed", lambda: True)
names = registry_module.get_available_subagent_names()
assert names == ["general-purpose", "bash"]
def test_build_subagent_section_hides_bash_examples_when_unavailable(monkeypatch) -> None:
monkeypatch.setattr(prompt_module, "get_available_subagent_names", lambda: ["general-purpose"])
section = prompt_module._build_subagent_section(3)
assert "Not available in the current sandbox configuration" in section
assert 'bash("npm test")' not in section
assert 'read_file("/mnt/user-data/workspace/README.md")' in section
assert "available tools (ls, read_file, web_search, etc.)" in section
def test_build_subagent_section_includes_bash_when_available(monkeypatch) -> None:
monkeypatch.setattr(prompt_module, "get_available_subagent_names", lambda: ["general-purpose", "bash"])
section = prompt_module._build_subagent_section(3)
assert "For command execution (git, build, test, deploy operations)" in section
assert 'bash("npm test")' in section
assert "available tools (bash, ls, read_file, web_search, etc.)" in section

View File

@ -64,8 +64,12 @@ def _make_result(
)
def _run_task_tool(**kwargs):
return asyncio.run(task_tool_module.task_tool.coroutine(**kwargs))
def _run_task_tool(**kwargs) -> str:
"""Execute the task tool across LangChain sync/async wrapper variants."""
coroutine = getattr(task_tool_module.task_tool, "coroutine", None)
if coroutine is not None:
return asyncio.run(coroutine(**kwargs))
return task_tool_module.task_tool.func(**kwargs)
async def _no_sleep(_: float) -> None:
@ -79,6 +83,7 @@ class _DummyScheduledTask:
def test_task_tool_returns_error_for_unknown_subagent(monkeypatch):
monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: None)
monkeypatch.setattr(task_tool_module, "get_available_subagent_names", lambda: ["general-purpose"])
result = _run_task_tool(
runtime=None,
@ -88,7 +93,22 @@ def test_task_tool_returns_error_for_unknown_subagent(monkeypatch):
tool_call_id="tc-1",
)
assert result.startswith("Error: Unknown subagent type")
assert result == "Error: Unknown subagent type 'general-purpose'. Available: general-purpose"
def test_task_tool_rejects_bash_subagent_when_host_bash_disabled(monkeypatch):
monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: _make_subagent_config())
monkeypatch.setattr(task_tool_module, "is_host_bash_allowed", lambda: False)
result = _run_task_tool(
runtime=_make_runtime(),
description="执行任务",
prompt="run commands",
subagent_type="bash",
tool_call_id="tc-bash",
)
assert result.startswith("Error: Bash subagent is disabled")
def test_task_tool_emits_running_and_completed_events(monkeypatch):

View File

@ -12,7 +12,7 @@
# ============================================================================
# Bump this number when the config schema changes.
# Run `make config-upgrade` to merge new fields into your local config.yaml.
config_version: 3
config_version: 4
# ============================================================================
# Logging
@ -330,6 +330,8 @@ tools:
use: deerflow.sandbox.tools:str_replace_tool
# Bash execution tool
# Active only when using an isolated shell sandbox or when
# sandbox.allow_host_bash: true explicitly opts into host bash.
- name: bash
group: bash
use: deerflow.sandbox.tools:bash_tool
@ -355,6 +357,10 @@ tool_search:
# Executes commands directly on the host machine
sandbox:
use: deerflow.sandbox.local:LocalSandboxProvider
# Host bash execution is disabled by default because LocalSandboxProvider is
# not a secure isolation boundary for shell access. Enable only for fully
# trusted, single-user local workflows.
allow_host_bash: false
# Option 2: Container-based AIO Sandbox
# Executes commands in isolated containers (Docker or Apple Container)