diff --git a/backend/src/sandbox/tools.py b/backend/src/sandbox/tools.py index 4bccd82..cd6a79a 100644 --- a/backend/src/sandbox/tools.py +++ b/backend/src/sandbox/tools.py @@ -14,6 +14,16 @@ from src.sandbox.exceptions import ( from src.sandbox.sandbox import Sandbox from src.sandbox.sandbox_provider import get_sandbox_provider +_ABSOLUTE_PATH_PATTERN = re.compile(r"(?()]+)") +_LOCAL_BASH_SYSTEM_PATH_PREFIXES = ( + "/bin/", + "/usr/bin/", + "/usr/sbin/", + "/sbin/", + "/opt/homebrew/bin/", + "/dev/", +) + def replace_virtual_path(path: str, thread_data: ThreadDataState | None) -> str: """Replace virtual /mnt/user-data paths with actual thread data paths. @@ -144,6 +154,35 @@ def resolve_local_tool_path(path: str, thread_data: ThreadDataState | None) -> s raise PermissionError("Access denied: path traversal detected") +def validate_local_bash_command_paths(command: str, thread_data: ThreadDataState | None) -> None: + """Validate absolute paths in local-sandbox bash commands. + + In local mode, commands must use virtual paths under /mnt/user-data for + user data access. A small allowlist of common system path prefixes is kept + for executable and device references (e.g. /bin/sh, /dev/null). + """ + if thread_data is None: + raise SandboxRuntimeError("Thread data not available for local sandbox") + + unsafe_paths: list[str] = [] + + for absolute_path in _ABSOLUTE_PATH_PATTERN.findall(command): + if absolute_path == VIRTUAL_PATH_PREFIX or absolute_path.startswith(f"{VIRTUAL_PATH_PREFIX}/"): + continue + + if any( + absolute_path == prefix.rstrip("/") or absolute_path.startswith(prefix) + for prefix in _LOCAL_BASH_SYSTEM_PATH_PREFIXES + ): + continue + + unsafe_paths.append(absolute_path) + + if unsafe_paths: + unsafe = ", ".join(sorted(dict.fromkeys(unsafe_paths))) + raise PermissionError(f"Unsafe absolute paths in command: {unsafe}. Use paths under {VIRTUAL_PATH_PREFIX}") + + def replace_virtual_paths_in_command(command: str, thread_data: ThreadDataState | None) -> str: """Replace all virtual /mnt/user-data paths in a command string. @@ -330,12 +369,15 @@ def bash_tool(runtime: ToolRuntime[ContextT, ThreadState], description: str, com ensure_thread_directories_exist(runtime) thread_data = get_thread_data(runtime) if is_local_sandbox(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) return sandbox.execute_command(command) except SandboxError as e: return f"Error: {e}" + except PermissionError as e: + return f"Error: {e}" except Exception as e: return f"Error: Unexpected error executing command: {type(e).__name__}: {e}" diff --git a/backend/tests/test_sandbox_tools_security.py b/backend/tests/test_sandbox_tools_security.py index 0fd2fbf..651701d 100644 --- a/backend/tests/test_sandbox_tools_security.py +++ b/backend/tests/test_sandbox_tools_security.py @@ -7,6 +7,7 @@ from src.sandbox.tools import ( mask_local_paths_in_output, replace_virtual_path, resolve_local_tool_path, + validate_local_bash_command_paths, ) @@ -56,3 +57,27 @@ def test_resolve_local_tool_path_rejects_path_traversal() -> None: with pytest.raises(PermissionError, match="path traversal"): resolve_local_tool_path(f"{VIRTUAL_PATH_PREFIX}/workspace/../../../../etc/passwd", thread_data) + + +def test_validate_local_bash_command_paths_blocks_host_paths() -> None: + thread_data = { + "workspace_path": "/tmp/deer-flow/threads/t1/user-data/workspace", + "uploads_path": "/tmp/deer-flow/threads/t1/user-data/uploads", + "outputs_path": "/tmp/deer-flow/threads/t1/user-data/outputs", + } + + with pytest.raises(PermissionError, match="Unsafe absolute paths"): + validate_local_bash_command_paths("cat /etc/passwd", thread_data) + + +def test_validate_local_bash_command_paths_allows_virtual_and_system_paths() -> None: + thread_data = { + "workspace_path": "/tmp/deer-flow/threads/t1/user-data/workspace", + "uploads_path": "/tmp/deer-flow/threads/t1/user-data/uploads", + "outputs_path": "/tmp/deer-flow/threads/t1/user-data/outputs", + } + + validate_local_bash_command_paths( + "/bin/echo ok > /mnt/user-data/workspace/out.txt && cat /dev/null", + thread_data, + )