mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-04-25 11:18:22 +00:00
[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:
parent
8b6c333afc
commit
92c7a20cb7
@ -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.
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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**:
|
||||
|
||||
@ -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",
|
||||
|
||||
45
backend/packages/harness/deerflow/sandbox/security.py
Normal file
45
backend/packages/harness/deerflow/sandbox/security.py
Normal 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))
|
||||
@ -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}"
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 = {}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
81
backend/tests/test_local_bash_tool_loading.py
Normal file
81
backend/tests/test_local_bash_tool_loading.py
Normal 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
|
||||
@ -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 ----------
|
||||
|
||||
|
||||
|
||||
41
backend/tests/test_subagent_prompt_security.py
Normal file
41
backend/tests/test_subagent_prompt_security.py
Normal 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
|
||||
@ -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):
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user