From cdb2a3a017f61a0cbb61def39a3949ee1bd9defe Mon Sep 17 00:00:00 2001 From: SHIYAO ZHANG <834247613@qq.com> Date: Sun, 29 Mar 2026 23:21:06 +0800 Subject: [PATCH] fix(sandbox): anchor relative paths to thread workspace in local mode (#1522) * fix(task_tool): fallback to configurable thread_id when context is missing task_tool only read thread_id from runtime.context, but when invoked via LangGraph Server, thread_id lives in config.configurable instead. Add the same fallback that ThreadDataMiddleware uses (PR #1237). Fixes subagent execution failure: 'Thread ID is required in runtime context or config.configurable' * remove debug logging from task_tool * fix(sandbox): anchor relative paths to thread workspace in local mode In local sandbox mode, bash commands using relative paths were resolved against the langgraph server process cwd (backend/) instead of the per-thread workspace directory. This allowed relative-path writes to escape the thread isolation boundary. Root cause: validate_local_bash_command_paths and replace_virtual_paths_in_command only process absolute paths (scanning for '/' prefix). Relative paths pass through untouched and inherit the process cwd at subprocess.run time. Fix: after virtual path translation, prepend `cd {workspace} &&` to anchor the shell's cwd to the thread-isolated workspace directory before execution. shlex.quote() ensures paths with spaces or special characters are handled safely. This mirrors the approach used by OpenHands (fixed cwd at execution layer) and is the correct fix for local mode where each subprocess.run is an independent process with no persistent shell session. Co-Authored-By: Claude Sonnet 4.6 * refactor(sandbox): extract _apply_cwd_prefix and add unit tests Extract the workspace cd-prefix logic from bash_tool into a dedicated _apply_cwd_prefix() helper so it can be unit-tested in isolation. Add four tests covering: normal prefix, no thread_data, missing workspace_path, and paths with spaces (shlex.quote). Co-Authored-By: Claude Sonnet 4.6 * revert: remove unrelated configurable thread_id fallback from sandbox/tools.py This change belongs in a separate PR. Co-Authored-By: Claude Sonnet 4.6 * style: remove trailing whitespace in test_sandbox_tools_security --------- Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Willem Jiang --- .../harness/deerflow/sandbox/tools.py | 18 ++++++++++++ backend/tests/test_sandbox_tools_security.py | 29 +++++++++++++++++++ 2 files changed, 47 insertions(+) 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