[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
18 changed files with 322 additions and 28 deletions
+2 -2
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
+4
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:
@@ -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",
@@ -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()
+1 -1
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),
)
+4
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]
@@ -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 ----------
@@ -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
+23 -3
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):