[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
+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):