[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
@@ -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()