diff --git a/backend/packages/harness/deerflow/sandbox/tools.py b/backend/packages/harness/deerflow/sandbox/tools.py index 3301292a8..432669b65 100644 --- a/backend/packages/harness/deerflow/sandbox/tools.py +++ b/backend/packages/harness/deerflow/sandbox/tools.py @@ -1,5 +1,6 @@ import posixpath import re +import shlex from pathlib import Path from langchain.tools import ToolRuntime, tool @@ -593,6 +594,22 @@ def replace_virtual_paths_in_command(command: str, thread_data: ThreadDataState return result +def _apply_cwd_prefix(command: str, thread_data: ThreadDataState | None) -> str: + """Prepend 'cd &&' so relative paths are anchored to the thread workspace. + + Args: + command: The bash command to execute. + thread_data: The thread data containing the workspace path. + + Returns: + The command prefixed with 'cd &&' if workspace_path is available, + otherwise the original command unchanged. + """ + if thread_data and (workspace := thread_data.get("workspace_path")): + return f"cd {shlex.quote(workspace)} && {command}" + return command + + def get_thread_data(runtime: ToolRuntime[ContextT, ThreadState] | None) -> ThreadDataState | None: """Extract thread_data from runtime state.""" if runtime is None: @@ -762,6 +779,7 @@ def bash_tool(runtime: ToolRuntime[ContextT, ThreadState], description: str, com thread_data = get_thread_data(runtime) validate_local_bash_command_paths(command, thread_data) command = replace_virtual_paths_in_command(command, thread_data) + command = _apply_cwd_prefix(command, thread_data) output = sandbox.execute_command(command) return mask_local_paths_in_output(output, thread_data) ensure_thread_directories_exist(runtime) diff --git a/backend/tests/test_sandbox_tools_security.py b/backend/tests/test_sandbox_tools_security.py index 4250298aa..bfcff5d72 100644 --- a/backend/tests/test_sandbox_tools_security.py +++ b/backend/tests/test_sandbox_tools_security.py @@ -6,6 +6,7 @@ import pytest from deerflow.sandbox.tools import ( VIRTUAL_PATH_PREFIX, + _apply_cwd_prefix, _is_acp_workspace_path, _is_skills_path, _reject_path_traversal, @@ -448,6 +449,34 @@ def test_mask_local_paths_in_output_hides_acp_workspace_host_paths() -> None: assert "/mnt/acp-workspace/hello.py" in masked +# ---------- _apply_cwd_prefix ---------- + + +def test_apply_cwd_prefix_prepends_workspace() -> None: + """Command is prefixed with cd && when workspace_path is set.""" + result = _apply_cwd_prefix("ls -la", _THREAD_DATA) + assert result.startswith("cd ") + assert "ls -la" in result + assert "/tmp/deer-flow/threads/t1/user-data/workspace" in result + + +def test_apply_cwd_prefix_no_thread_data() -> None: + """Command is returned unchanged when thread_data is None.""" + assert _apply_cwd_prefix("ls -la", None) == "ls -la" + + +def test_apply_cwd_prefix_missing_workspace_path() -> None: + """Command is returned unchanged when workspace_path is absent from thread_data.""" + assert _apply_cwd_prefix("ls -la", {}) == "ls -la" + + +def test_apply_cwd_prefix_quotes_path_with_spaces() -> None: + """Workspace path containing spaces is properly shell-quoted.""" + thread_data = {**_THREAD_DATA, "workspace_path": "/tmp/my workspace/t1"} + result = _apply_cwd_prefix("echo hello", thread_data) + assert result == "cd '/tmp/my workspace/t1' && echo hello" + + def test_validate_local_bash_command_paths_allows_mcp_filesystem_paths() -> None: """Bash commands referencing MCP filesystem server paths should be allowed.""" from deerflow.config.extensions_config import ExtensionsConfig, McpServerConfig