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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* style: remove trailing whitespace in test_sandbox_tools_security

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
SHIYAO ZHANG 2026-03-29 23:21:06 +08:00 committed by GitHub
parent 866cf4ef73
commit cdb2a3a017
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 47 additions and 0 deletions

View File

@ -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 <workspace> &&' 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 <workspace> &&' 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)

View File

@ -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 <workspace> && 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