diff --git a/README.md b/README.md index c0bbc452b6..006af101c5 100644 --- a/README.md +++ b/README.md @@ -1612,6 +1612,8 @@ names and base URLs readable. For `LocalSandboxProvider`, this is a managed tool-path boundary rather than host filesystem isolation. Explicit per-Agent skill policies are accepted only while host bash is disabled (the default), because a host subprocess can address canonical paths without using the provider's virtual-path mappings. Use Docker/AIO, the Kubernetes provisioner, or E2B when the filesystem boundary must remain enforceable alongside shell access. +When the local sandbox uses PowerShell, mapped host-path spaces and shell metacharacters are escaped for the caller's quoting context; wildcard expansion is preserved. The public `bash` tool's existing workspace prefix uses `&&` and POSIX path quoting: it requires PowerShell 7 for that separator and does not support workspace paths containing apostrophes. These workspace-prefix limitations are separate from virtual-path mapping. + On Windows, the local sandbox's `cmd.exe` fallback decodes builtin output using the console output code page, including UTF-8 after `chcp 65001`, independently of Python's UTF-8 mode. If no console page is available, it uses the system OEM diff --git a/backend/packages/harness/deerflow/sandbox/AGENTS.md b/backend/packages/harness/deerflow/sandbox/AGENTS.md index b95ffd7353..a7822c61ae 100644 --- a/backend/packages/harness/deerflow/sandbox/AGENTS.md +++ b/backend/packages/harness/deerflow/sandbox/AGENTS.md @@ -2,6 +2,8 @@ Sandbox restore requires a thread ID, even forks. +PowerShell command mapping escapes host-path whitespace/metacharacters for the caller's quote context in both user-data and provider mappings. Preserve wildcards; Bash/cmd mapping stays unchanged. Keep portable string assertions alongside native Windows smoke tests. The public bash tool's existing POSIX-quoted cwd prefix requires PowerShell 7 (`&&`) and workspace paths without apostrophes; mapping does not repair that prefix. + Local `grep` skips non-regular entries before reading. File reads and directory scans propagate errors, except `ENOENT`/`ENOTDIR`/`ELOOP`/`EISDIR`/`ESTALE` races (`ESTALE` when available). Error filenames are reverse-resolved at the local sandbox boundary, including custom mounts; permission errors identify the virtual failing path. **Network approval policy**: `SandboxMiddleware` uses diff --git a/backend/packages/harness/deerflow/sandbox/local/local_sandbox.py b/backend/packages/harness/deerflow/sandbox/local/local_sandbox.py index f4dd9d65b4..446709ed85 100644 --- a/backend/packages/harness/deerflow/sandbox/local/local_sandbox.py +++ b/backend/packages/harness/deerflow/sandbox/local/local_sandbox.py @@ -184,6 +184,59 @@ class ResolvedPath(NamedTuple): mapping: PathMapping | None +_POWERSHELL_QUOTE_TRANSLATION = str.maketrans("\u2018\u2019\u201c\u201d", "''\"\"") + + +def escape_powershell_path(match: re.Match[str], path: str) -> str: + """Keep mapped paths literal in the caller's quote context, preserving wildcards.""" + quote = None + escaped = False + subexpressions: list[str | None] = [] + prefix = match.string[: match.start()] + index = 0 + while index < len(prefix): + char = prefix[index].translate(_POWERSHELL_QUOTE_TRANSLATION) + if escaped: + escaped = False + elif char == "`" and quote != "'": + escaped = True + elif quote is None and prefix[index : index + 2] == "<#": + end = prefix.find("#>", index + 2) + index = len(prefix) if end < 0 else end + 1 + elif quote is None and char == "#" and (index == 0 or prefix[index - 1].isspace() or prefix[index - 1] in ";|&(){}="): + end = prefix.find("\n", index) + index = len(prefix) if end < 0 else end + elif quote is None and prefix[index : index + 2] in ("@'", '@"'): + delimiter = prefix[index + 1] + end = re.search(rf"(?m)^{delimiter}@", prefix[index + 2 :]) + if end is None: + if delimiter == "'": + return path + quote = '@"' + index += 1 + else: + index += end.end() + 1 + elif char == "$" and prefix[index : index + 2] == "$(" and quote != "'": + subexpressions.append(quote) + quote = None + index += 1 + elif char in "\"'" and quote in (None, char): + quote = None if quote else char + elif quote is None and char == "(": + subexpressions.append(None) + elif quote is None and char == ")" and subexpressions: + quote = subexpressions.pop() + index += 1 + # The path matcher includes a caller's closing smart quote in its tail. + closing_quote = path[-1:] if quote in ("'", '"') and match.group()[-1:].translate(_POWERSHELL_QUOTE_TRANSLATION) == quote else "" + if closing_quote: + path = path[:-1] + if quote == "'": + return re.sub(r"['\u2018\u2019]", lambda char: char.group() * 2, path) + closing_quote + characters = r'[`$"\u201c\u201d]' if quote is not None else r"[ \t`$'\";&,()@{}#\u2018\u2019\u201c\u201d]" + return re.sub(characters, lambda char: "`" + char.group(), path) + closing_quote + + class LocalSandbox(Sandbox): #: Every call is a fresh ``subprocess.run([shell, "-c", ...])`` process — #: no shell state survives into the next command. @@ -561,12 +614,13 @@ class LocalSandbox(Sandbox): return result - def _resolve_paths_in_command(self, command: str) -> str: + def _resolve_paths_in_command(self, command: str, *, shell: str | None = None) -> str: """ Resolve container paths to local paths in a command string. Args: command: Command string that may contain container paths + shell: Execution shell, or None for unescaped path resolution Returns: Command with container paths resolved to local paths @@ -583,10 +637,14 @@ class LocalSandbox(Sandbox): # canonicalizing the truncated prefix would follow an # unrelated symlink and reassemble a path to a different # file, so substitute textually there. - return self._resolve_path_textually(matched_path).replace("\\", "/") + resolved = self._resolve_path_textually(matched_path).replace("\\", "/") + else: + resolved = self._resolve_path(matched_path).replace("\\", "/") # Normalize to forward slashes so bash doesn't interpret Windows # backslash sequences (\\U, \\a, \\d, \\s, \\n, \\t) as escapes. - return self._resolve_path(matched_path).replace("\\", "/") + if self._is_powershell(shell or ""): + return escape_powershell_path(match, resolved) + return resolved return pattern.sub(replace_match, command) @@ -683,8 +741,8 @@ class LocalSandbox(Sandbox): # any new caller to use safe key names. _validate_extra_env(env) # Resolve container paths in command before execution - resolved_command = self._resolve_paths_in_command(command) shell = self._get_shell() + resolved_command = self._resolve_paths_in_command(command, shell=shell) if timeout is None: timeout = DEFAULT_COMMAND_TIMEOUT_SECONDS diff --git a/backend/packages/harness/deerflow/sandbox/tools.py b/backend/packages/harness/deerflow/sandbox/tools.py index 3aecd2bb9e..8351c7e3bb 100644 --- a/backend/packages/harness/deerflow/sandbox/tools.py +++ b/backend/packages/harness/deerflow/sandbox/tools.py @@ -40,6 +40,7 @@ from deerflow.sandbox.lease import ( sandbox_command_scope, sandbox_lease_owner, ) +from deerflow.sandbox.local.local_sandbox import LocalSandbox, escape_powershell_path from deerflow.sandbox.overwrite import unwrap_sandbox from deerflow.sandbox.path_patterns import build_output_mask_pattern, normalize_mask_tail, replace_output_path_matches from deerflow.sandbox.prewarm import claim_sandbox_prewarm @@ -1888,7 +1889,7 @@ def validate_local_bash_command_paths(command: str, thread_data: ThreadDataState raise PermissionError(f"Unsafe absolute paths in command: {unsafe}. Use paths under {VIRTUAL_PATH_PREFIX}. {_LOCAL_BASH_PATH_RECOVERY_GUIDANCE}") -def replace_virtual_paths_in_command(command: str, thread_data: ThreadDataState | None) -> str: +def replace_virtual_paths_in_command(command: str, thread_data: ThreadDataState | None, *, shell: str | None = None) -> str: """Replace /mnt/user-data virtual paths in a command string for local sandbox. Skills paths (/mnt/skills) and ACP workspace paths (/mnt/acp-workspace) @@ -1901,6 +1902,7 @@ def replace_virtual_paths_in_command(command: str, thread_data: ThreadDataState Args: command: The command string that may contain virtual paths. thread_data: The thread data containing actual paths. + shell: Local execution shell, auto-detected when omitted. Returns: The command with user-data virtual paths replaced. @@ -1934,7 +1936,10 @@ def replace_virtual_paths_in_command(command: str, thread_data: ThreadDataState pattern = re.compile(rf"{re.escape(VIRTUAL_PATH_PREFIX)}(?=/|$|[^\w./-])(/[^\s\"';&|<>():]*)?") def replace_user_data_match(match: re.Match) -> str: - return replace_virtual_path(match.group(0), thread_data).replace("\\", "/") + resolved = replace_virtual_path(match.group(0), thread_data).replace("\\", "/") + if LocalSandbox._is_powershell(shell or LocalSandbox._get_shell()): + return escape_powershell_path(match, resolved) + return resolved result = pattern.sub(replace_user_data_match, result) diff --git a/backend/tests/test_local_command_path_spaces.py b/backend/tests/test_local_command_path_spaces.py new file mode 100644 index 0000000000..a7f1988bf5 --- /dev/null +++ b/backend/tests/test_local_command_path_spaces.py @@ -0,0 +1,209 @@ +"""Mapped host-directory spaces must not split PowerShell path arguments.""" + +import os +import shutil +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from deerflow.sandbox.local.local_sandbox import LocalSandbox, PathMapping +from deerflow.sandbox.tools import bash_tool, replace_virtual_paths_in_command + + +@pytest.fixture +def powershell(monkeypatch): + if os.name != "nt": + pytest.skip("native Windows PowerShell execution") + shell = shutil.which("pwsh") or shutil.which("powershell") + if shell is None: + pytest.skip("PowerShell is not installed") + monkeypatch.setattr(LocalSandbox, "_get_shell", staticmethod(lambda: shell)) + return shell + + +@pytest.fixture +def mounted(tmp_path): + root = tmp_path / "host directory with spaces" + root.mkdir() + (root / "marker.txt").write_text("needle", encoding="utf-8") + (root / "other.md").write_text("other", encoding="utf-8") + return LocalSandbox("local", [PathMapping("/mnt/data", str(root))]), root + + +@pytest.mark.parametrize( + "command", + [ + "Get-Content /mnt/data/marker.txt", + 'Get-Content "/mnt/data/marker.txt"', + "Get-Content '/mnt/data/marker.txt'", + "Get-Content \u2018/mnt/data/marker.txt\u2019", + "Get-Content \u201c/mnt/data/marker.txt\u201d", + "Get-Content /mnt/data/*.txt", + "Get-Content /mnt/data/marker.txt | Write-Output", + "Write-Output ignored; Get-Content /mnt/data/marker.txt", + "Write-Output /mnt/data/marker.txt", + "Write-Output --file=/mnt/data/marker.txt", + 'Write-Output "$(Get-Content /mnt/data/marker.txt)"', + "# it's a directory\nGet-Content /mnt/data/marker.txt", + "Write-Output before;# it's text\nGet-Content '/mnt/data/marker.txt'", + "<# it's a directory #> Get-Content /mnt/data/marker.txt", + "Write-Output @'\nit's text\n'@\nGet-Content /mnt/data/marker.txt", + 'Write-Output @"\ntext with "quotes"\n"@\nGet-Content /mnt/data/marker.txt', + 'Write-Output @"\n$(Get-Content /mnt/data/marker.txt)\n"@', + 'Write-Output `"quoted; Get-Content /mnt/data/marker.txt', + 'Get-Content "/mnt/data/marker.txt"; Get-Content /mnt/data/marker.txt', + ], +) +def test_powershell_executes_mapped_path_with_spaces(powershell, mounted, command): + sandbox, _ = mounted + output = sandbox.execute_command(command) + expected = "/mnt/data/marker.txt" if command.startswith("Write-Output /") or "--file=" in command else "needle" + assert expected in output + assert "Exit Code:" not in output + + +@pytest.mark.parametrize("shell", ["sh", "cmd.exe"]) +def test_other_shell_path_resolution_is_unchanged(mounted, shell): + sandbox, root = mounted + command = 'echo /mnt/data/marker.txt "/mnt/data/*.txt"' + expected = command.replace("/mnt/data", str(root.resolve()).replace("\\", "/")) + assert sandbox._resolve_paths_in_command(command, shell=shell) == expected + + +@pytest.mark.parametrize("layer", ["provider", "thread"]) +@pytest.mark.parametrize( + "command,expected", + [ + ("Get-Content {virtual}/marker.txt", "Get-Content {escaped}/marker.txt"), + ('Get-Content "{virtual}/marker.txt"', 'Get-Content "{raw}/marker.txt"'), + ("Get-Content '{virtual}/marker.txt'", "Get-Content '{raw}/marker.txt'"), + ("Get-Content \u2018{virtual}/marker.txt\u2019", "Get-Content \u2018{raw}/marker.txt\u2019"), + ("Get-Content \u201c{virtual}/marker.txt\u201d", "Get-Content \u201c{raw}/marker.txt\u201d"), + ('Write-Output "$(Get-Content {virtual}/marker.txt)"', 'Write-Output "$(Get-Content {escaped}/marker.txt)"'), + ("# it's text\nGet-Content {virtual}/marker.txt", "# it's text\nGet-Content {escaped}/marker.txt"), + ("<# it's text #> Get-Content {virtual}/*.txt", "<# it's text #> Get-Content {escaped}/*.txt"), + ('Get-Content "{virtual}/marker.txt"; Get-Content {virtual}/*.txt', 'Get-Content "{raw}/marker.txt"; Get-Content {escaped}/*.txt'), + ('Write-Output @"\n$(Get-Content {virtual}/marker.txt)\n"@', 'Write-Output @"\n$(Get-Content {escaped}/marker.txt)\n"@'), + ], +) +def test_powershell_command_mapping_is_portable(mounted, layer, command, expected): + sandbox, root = mounted + virtual = "/mnt/data" if layer == "provider" else "/mnt/user-data/uploads" + raw = str(root.resolve()).replace("\\", "/") + command = command.format(virtual=virtual) + if layer == "provider": + result = sandbox._resolve_paths_in_command(command, shell="pwsh") + else: + result = replace_virtual_paths_in_command(command, {"uploads_path": str(root)}, shell="pwsh") + assert result == expected.format(raw=raw, escaped=raw.replace(" ", "` ")) + + +@pytest.mark.parametrize("layer", ["provider", "thread"]) +@pytest.mark.parametrize( + "directory,escaped", + [ + ("O'Brien", "O`'Brien"), + ("Tom & Jerry", "Tom` `&` Jerry"), + ("a;b", "a`;b"), + ("a`b", "a``b"), + ("cost$USER", "cost`$USER"), + ("a(b)", "a`(b`)"), + ("a,b", "a`,b"), + ("a{b}", "a`{b`}"), + ("a\u2019b", "a`\u2019b"), + ("a\u201db", "a`\u201db"), + ("O'Brien's Folder", "O`'Brien`'s` Folder"), + ], +) +def test_powershell_mapped_metacharacters_are_literal(tmp_path, layer, directory, escaped): + root = tmp_path / directory + root.mkdir() + virtual = "/mnt/data" if layer == "provider" else "/mnt/user-data/uploads" + command = f"Get-Content {virtual}/marker.txt" + if layer == "provider": + result = LocalSandbox("local", [PathMapping(virtual, str(root))])._resolve_paths_in_command(command, shell="pwsh") + else: + result = replace_virtual_paths_in_command(command, {"uploads_path": str(root)}, shell="pwsh") + parent = str(tmp_path.resolve()).replace("\\", "/").replace(" ", "` ") + assert result == f"Get-Content {parent}/{escaped}/marker.txt" + + +@pytest.mark.parametrize("layer", ["provider", "thread"]) +@pytest.mark.parametrize( + "directory,quote,escaped", + [ + ("O'Brien", "'", "O''Brien"), + ("a`b", '"', "a``b"), + ("cost$USER", '"', "cost`$USER"), + ("a\u2019b", "'", "a\u2019\u2019b"), + ("a\u201db", '"', "a`\u201db"), + ], +) +def test_quoted_powershell_mapped_metacharacters_are_literal(tmp_path, layer, directory, quote, escaped): + root = tmp_path / directory + root.mkdir() + virtual = "/mnt/data" if layer == "provider" else "/mnt/user-data/uploads" + command = f"Get-Content {quote}{virtual}/marker.txt{quote}" + if layer == "provider": + result = LocalSandbox("local", [PathMapping(virtual, str(root))])._resolve_paths_in_command(command, shell="pwsh") + else: + result = replace_virtual_paths_in_command(command, {"uploads_path": str(root)}, shell="pwsh") + parent = str(tmp_path.resolve()).replace("\\", "/") + assert result == f"Get-Content {quote}{parent}/{escaped}/marker.txt{quote}" + + +@pytest.mark.parametrize("directory", ["O'Brien", "O'Brien's Folder", "Tom & Jerry", "a;b", "a`b", "cost$USER", "a(b)", "a,b", "a{b}", "a\u2019b", "a\u201db", "a;Write-Output unexpected;#"]) +@pytest.mark.parametrize("quote", ["", '"', "'"]) +def test_powershell_executes_mapped_metacharacters(powershell, tmp_path, directory, quote): + root = tmp_path / directory + root.mkdir() + (root / "marker.txt").write_text("needle", encoding="utf-8") + sandbox = LocalSandbox("local", [PathMapping("/mnt/data", str(root))]) + output = sandbox.execute_command(f"Get-Content {quote}/mnt/data/marker.txt{quote}") + assert "needle" in output, output + assert "Exit Code:" not in output, output + + +@pytest.mark.parametrize("shell_name", ["sh", "cmd.exe"]) +def test_quoted_paths_still_execute_in_other_shells(mounted, monkeypatch, shell_name): + shell = shutil.which(shell_name) + if shell is None or (os.name == "nt" and shell_name == "sh"): + pytest.skip(f"native {shell_name} is unavailable") + monkeypatch.setattr(LocalSandbox, "_get_shell", staticmethod(lambda: shell)) + sandbox, _ = mounted + command = 'echo "/mnt/data/marker.txt"' if shell_name == "cmd.exe" else 'cat "/mnt/data/marker.txt"' + expected = "/mnt/data/marker.txt" if shell_name == "cmd.exe" else "needle" + output = sandbox.execute_command(command) + assert expected in output + assert "Exit Code:" not in output + + +def test_powershell_redirects_to_mapped_path_with_spaces(powershell, mounted): + sandbox, root = mounted + output = sandbox.execute_command("Write-Output saved > /mnt/data/saved.txt; Get-Content /mnt/data/saved.txt") + assert "saved" in output + data = (root / "saved.txt").read_bytes() + assert "saved" in data.decode("utf-16" if data.startswith(b"\xff\xfe") else "utf-8") + assert "Exit Code:" not in output + + +@pytest.mark.parametrize("quote", ["", '"', "'"]) +@pytest.mark.parametrize("upload_directory", ["thread data", "O'Brien", "cost$USER", "a;b"]) +def test_public_bash_tool_preserves_thread_path_argument(powershell, tmp_path, monkeypatch, quote, upload_directory): + from deerflow.config.sandbox_config import SandboxConfig + + dirs = {f"{name}_path": str(tmp_path / "thread data" / name) for name in ("workspace", "uploads", "outputs")} + dirs["uploads_path"] = str(tmp_path / upload_directory / "uploads") + for directory in dirs.values(): + Path(directory).mkdir(parents=True) + Path(dirs["uploads_path"], "marker.txt").write_text("needle", encoding="utf-8") + sandbox = LocalSandbox("local", [PathMapping(f"/mnt/user-data/{name}", dirs[f"{name}_path"]) for name in ("workspace", "uploads", "outputs")]) + runtime = SimpleNamespace(state={"sandbox": {"sandbox_id": "local"}, "thread_data": dirs}, context={"thread_id": "space-test"}) + monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda _: sandbox) + monkeypatch.setattr("deerflow.sandbox.tools.is_host_bash_allowed", lambda: True) + monkeypatch.setattr("deerflow.sandbox.tools.get_app_config", lambda: SimpleNamespace(sandbox=SandboxConfig())) + result = bash_tool.func(runtime=runtime, command=f"Get-Content {quote}/mnt/user-data/uploads/marker.txt{quote}") + assert "needle" in result + assert "Exit Code:" not in result + assert not result.startswith("Error:"), result