From 92c7a20cb74addc3038d2131da78f2e239ef542e Mon Sep 17 00:00:00 2001 From: 13ernkastel Date: Sun, 29 Mar 2026 21:03:58 +0800 Subject: [PATCH] [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 --- README.md | 6 +- backend/README.md | 4 +- backend/docs/CONFIGURATION.md | 4 + .../deerflow/agents/lead_agent/prompt.py | 25 ++++-- .../harness/deerflow/config/sandbox_config.py | 6 ++ .../harness/deerflow/sandbox/security.py | 45 +++++++++++ .../harness/deerflow/sandbox/tools.py | 12 ++- .../harness/deerflow/subagents/__init__.py | 3 +- .../harness/deerflow/subagents/registry.py | 19 +++++ .../deerflow/tools/builtins/task_tool.py | 21 +++-- .../packages/harness/deerflow/tools/tools.py | 20 ++++- backend/tests/test_client_e2e.py | 2 +- backend/tests/test_client_live.py | 4 + backend/tests/test_local_bash_tool_loading.py | 81 +++++++++++++++++++ backend/tests/test_sandbox_tools_security.py | 23 ++++++ .../tests/test_subagent_prompt_security.py | 41 ++++++++++ backend/tests/test_task_tool_core_logic.py | 26 +++++- config.example.yaml | 8 +- 18 files changed, 322 insertions(+), 28 deletions(-) create mode 100644 backend/packages/harness/deerflow/sandbox/security.py create mode 100644 backend/tests/test_local_bash_tool_loading.py create mode 100644 backend/tests/test_subagent_prompt_security.py diff --git a/README.md b/README.md index 82f16c3a7..efb72a656 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/backend/README.md b/backend/README.md index d3b26158b..f2903c3bb 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 diff --git a/backend/docs/CONFIGURATION.md b/backend/docs/CONFIGURATION.md index 5721fffaa..cd9d1c5a4 100644 --- a/backend/docs/CONFIGURATION.md +++ b/backend/docs/CONFIGURATION.md @@ -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: diff --git a/backend/packages/harness/deerflow/agents/lead_agent/prompt.py b/backend/packages/harness/deerflow/agents/lead_agent/prompt.py index add4374f0..0f73c6321 100644 --- a/backend/packages/harness/deerflow/agents/lead_agent/prompt.py +++ b/backend/packages/harness/deerflow/agents/lead_agent/prompt.py @@ -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 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**: diff --git a/backend/packages/harness/deerflow/config/sandbox_config.py b/backend/packages/harness/deerflow/config/sandbox_config.py index d025b4413..d5447f3dc 100644 --- a/backend/packages/harness/deerflow/config/sandbox_config.py +++ b/backend/packages/harness/deerflow/config/sandbox_config.py @@ -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", diff --git a/backend/packages/harness/deerflow/sandbox/security.py b/backend/packages/harness/deerflow/sandbox/security.py new file mode 100644 index 000000000..7c881841f --- /dev/null +++ b/backend/packages/harness/deerflow/sandbox/security.py @@ -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)) diff --git a/backend/packages/harness/deerflow/sandbox/tools.py b/backend/packages/harness/deerflow/sandbox/tools.py index ce91e8634..3301292a8 100644 --- a/backend/packages/harness/deerflow/sandbox/tools.py +++ b/backend/packages/harness/deerflow/sandbox/tools.py @@ -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"(?()]+)") _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}" diff --git a/backend/packages/harness/deerflow/subagents/__init__.py b/backend/packages/harness/deerflow/subagents/__init__.py index b33754fa2..bd78d9acc 100644 --- a/backend/packages/harness/deerflow/subagents/__init__.py +++ b/backend/packages/harness/deerflow/subagents/__init__.py @@ -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", ] diff --git a/backend/packages/harness/deerflow/subagents/registry.py b/backend/packages/harness/deerflow/subagents/registry.py index 16afa2ea7..61da0e453 100644 --- a/backend/packages/harness/deerflow/subagents/registry.py +++ b/backend/packages/harness/deerflow/subagents/registry.py @@ -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 diff --git a/backend/packages/harness/deerflow/tools/builtins/task_tool.py b/backend/packages/harness/deerflow/tools/builtins/task_tool.py index e1f8987a4..963590a29 100644 --- a/backend/packages/harness/deerflow/tools/builtins/task_tool.py +++ b/backend/packages/harness/deerflow/tools/builtins/task_tool.py @@ -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 = {} diff --git a/backend/packages/harness/deerflow/tools/tools.py b/backend/packages/harness/deerflow/tools/tools.py index 313643303..35e5a1456 100644 --- a/backend/packages/harness/deerflow/tools/tools.py +++ b/backend/packages/harness/deerflow/tools/tools.py @@ -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() diff --git a/backend/tests/test_client_e2e.py b/backend/tests/test_client_e2e.py index 166862c71..b26e5bff1 100644 --- a/backend/tests/test_client_e2e.py +++ b/backend/tests/test_client_e2e.py @@ -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), ) diff --git a/backend/tests/test_client_live.py b/backend/tests/test_client_live.py index 5c6eb61af..0271ebf21 100644 --- a/backend/tests/test_client_live.py +++ b/backend/tests/test_client_live.py @@ -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] diff --git a/backend/tests/test_local_bash_tool_loading.py b/backend/tests/test_local_bash_tool_loading.py new file mode 100644 index 000000000..1ca4ccb38 --- /dev/null +++ b/backend/tests/test_local_bash_tool_loading.py @@ -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 diff --git a/backend/tests/test_sandbox_tools_security.py b/backend/tests/test_sandbox_tools_security.py index 2be031a18..4250298aa 100644 --- a/backend/tests/test_sandbox_tools_security.py +++ b/backend/tests/test_sandbox_tools_security.py @@ -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 ---------- diff --git a/backend/tests/test_subagent_prompt_security.py b/backend/tests/test_subagent_prompt_security.py new file mode 100644 index 000000000..46433500f --- /dev/null +++ b/backend/tests/test_subagent_prompt_security.py @@ -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 diff --git a/backend/tests/test_task_tool_core_logic.py b/backend/tests/test_task_tool_core_logic.py index bae7da12a..c9ae1f849 100644 --- a/backend/tests/test_task_tool_core_logic.py +++ b/backend/tests/test_task_tool_core_logic.py @@ -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): diff --git a/config.example.yaml b/config.example.yaml index d46e05619..0f7c3c9c7 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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)