From 47f43f79f48809274707105d6947b186a8b640a0 Mon Sep 17 00:00:00 2001 From: gus Date: Wed, 2 Sep 2026 21:35:33 +0800 Subject: [PATCH] fix: improve local environment detection guidance (#5111) Co-authored-by: angus-guo <217034332+angus-guo@users.noreply.github.com> --- README.md | 2 + .../harness/deerflow/sandbox/AGENTS.md | 2 +- .../harness/deerflow/sandbox/tools.py | 13 ++++-- backend/tests/test_sandbox_tools_security.py | 43 +++++++++++++++++++ 4 files changed, 56 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 24234e913..2410a4511 100644 --- a/README.md +++ b/README.md @@ -478,6 +478,8 @@ DeerFlow supports multiple sandbox execution modes: - **Docker Execution** (runs sandbox code in isolated Docker containers) - **Docker Execution with Kubernetes** (runs sandbox code in Kubernetes pods via provisioner service) +When host Bash is enabled for Local Execution, DeerFlow starts OS detection with `uname -s`, then uses `sw_vers` on Darwin. On Linux, it reads host system files such as `/etc/os-release` only when the active sandbox policy permits it. Host filesystem path checks still apply; after a blocked path, the agent is directed to use a permitted command-only probe or virtual path instead of repeating the rejected command. + For Docker development, service startup follows `config.yaml` sandbox mode. In Local/Docker modes, `provisioner` is not started. See the [Sandbox Configuration Guide](backend/docs/CONFIGURATION.md#sandbox) to configure your preferred mode. diff --git a/backend/packages/harness/deerflow/sandbox/AGENTS.md b/backend/packages/harness/deerflow/sandbox/AGENTS.md index 840da96b5..664a313a1 100644 --- a/backend/packages/harness/deerflow/sandbox/AGENTS.md +++ b/backend/packages/harness/deerflow/sandbox/AGENTS.md @@ -95,7 +95,7 @@ **Sandbox Tools** (in `packages/harness/deerflow/sandbox/tools.py`): - Every sandbox tool keeps a model-visible `description` field for a human-readable progress label, but the field is optional and defaults to an empty string. Tool execution must depend only on its operational arguments; the frontend supplies localized fallback labels when a provider omits `description`. -- `bash` - Execute commands with path translation and error handling. For `LocalSandbox` (host bash), output on POSIX and Windows is captured through bounded pipe-drain threads and stdin is `/dev/null`; Windows capture decodes with the platform text encoding and applies universal-newline translation, matching the former `subprocess.run(..., text=True)` behavior for locale-code-page output, Python UTF-8 Mode, CRLF, and bare CR. That translation is Windows-only so the pre-existing POSIX output contract remains byte-decoded without newline rewriting. On POSIX, a backgrounded long-lived process (`server &`) returns immediately instead of blocking the turn on an inherited pipe, while unredirected background output is drained without growing anonymous temp files. Commands that read stdin get immediate EOF. The command runs in its own process group with a wall-clock timeout (`sandbox.bash_command_timeout`, default 600s); on timeout the whole POSIX process group or Windows process tree is killed and the agent gets a notice telling it to background long-lived processes. The bash tool description itself also instructs the model to background long-lived processes (e.g. servers) up front so it doesn't waste the turn waiting on a foreground server. See `LocalSandbox.execute_command`, its platform runners, and `bash_tool`'s docstring. +- `bash` - Execute commands with path translation and error handling. For `LocalSandbox` (host bash), output on POSIX and Windows is captured through bounded pipe-drain threads and stdin is `/dev/null`; Windows capture decodes with the platform text encoding and applies universal-newline translation, matching the former `subprocess.run(..., text=True)` behavior for locale-code-page output, Python UTF-8 Mode, CRLF, and bare CR. That translation is Windows-only so the pre-existing POSIX output contract remains byte-decoded without newline rewriting. On POSIX, a backgrounded long-lived process (`server &`) returns immediately instead of blocking the turn on an inherited pipe, while unredirected background output is drained without growing anonymous temp files. Commands that read stdin get immediate EOF. The command runs in its own process group with a wall-clock timeout (`sandbox.bash_command_timeout`, default 600s); on timeout the whole POSIX process group or Windows process tree is killed and the agent gets a notice telling it to background long-lived processes. The shared bash tool description scopes host environment detection to LocalSandbox: start with `uname -s`, follow with `sw_vers` on Darwin, and read Linux host system files only when the active policy permits them. Local path and `file://` rejections provide the same conditional recovery guidance: command-only probes for environment questions, allowed virtual paths otherwise, and no repetition of the rejected path. The description also instructs the model to background long-lived processes (e.g. servers) up front so it doesn't waste the turn waiting on a foreground server. See `LocalSandbox.execute_command`, its platform runners, and `bash_tool`'s docstring. - `ls` - Directory listing (tree format, max 2 levels) - `glob` - Find files or directories below a root directory with bounded results - `grep` - Search one text file or recursively search a directory, with optional glob filtering and bounded line-level results diff --git a/backend/packages/harness/deerflow/sandbox/tools.py b/backend/packages/harness/deerflow/sandbox/tools.py index 2116d4ec8..c255f0091 100644 --- a/backend/packages/harness/deerflow/sandbox/tools.py +++ b/backend/packages/harness/deerflow/sandbox/tools.py @@ -67,6 +67,7 @@ _FILE_URL_PATTERN = re.compile(r"\bfile://\S+", re.IGNORECASE) _URL_WITH_SCHEME_PATTERN = re.compile(r"^[a-z][a-z0-9+.-]*://", re.IGNORECASE) _URL_IN_COMMAND_PATTERN = re.compile(r"\b[a-z][a-z0-9+.-]*://[^\s\"'`;&|<>()]+", re.IGNORECASE) _DOTDOT_PATH_SEGMENT_PATTERN = re.compile(r"(?:^|[/\\=])\.\.(?:$|[/\\])") +_LOCAL_BASH_PATH_RECOVERY_GUIDANCE = "For environment questions, use command-only probes such as uname; otherwise use an allowed virtual path. Do not repeat the rejected path." _LOCAL_BASH_SYSTEM_PATH_PREFIXES = ( "/bin/", "/usr/bin/", @@ -1261,7 +1262,7 @@ def validate_local_bash_command_paths(command: str, thread_data: ThreadDataState # Block file:// URLs which bypass the absolute-path regex but allow local file exfiltration file_url_match = _FILE_URL_PATTERN.search(command) if file_url_match: - raise PermissionError(f"Unsafe file:// URL in command: {file_url_match.group()}. Use paths under {VIRTUAL_PATH_PREFIX}") + raise PermissionError(f"Unsafe file:// URL in command: {file_url_match.group()}. Use paths under {VIRTUAL_PATH_PREFIX}. {_LOCAL_BASH_PATH_RECOVERY_GUIDANCE}") unsafe_paths: list[str] = [] allowed_paths = _get_mcp_allowed_paths() @@ -1281,7 +1282,7 @@ def validate_local_bash_command_paths(command: str, thread_data: ThreadDataState 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}") + 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: @@ -2004,12 +2005,18 @@ def _lark_cli_env_from_runtime(runtime: Runtime, command: str, *, sandbox_paths: @tool("bash", parse_docstring=True) def bash_tool(runtime: Runtime, command: str, description: str = "") -> str: - """Execute a bash command in a Linux environment. + """Execute a bash command in the configured execution environment. - Use `python` to run Python code. - Prefer a thread-local virtual environment in `/mnt/user-data/workspace/.venv`. - Use `python -m pip` (inside the virtual environment) to install Python packages. + - When running against the local host via host bash, inspect the current environment instead of + guessing. For OS detection, start with `uname -s`; on Darwin follow with `sw_vers`. On Linux, + start with `uname -a` and read host system files such as `/etc/os-release` only when the active + sandbox policy permits it. + - If local host bash rejects a path, do not repeat the rejected command. For environment questions, + retry with command-only probes; otherwise use allowed virtual paths or explain the restriction. - To start a long-lived process such as a web server, ALWAYS run it in the background with its output redirected, e.g. `your-command > /mnt/user-data/workspace/server.log 2>&1 &`, then check the log file or poll the port. A long-lived process run in the foreground blocks the turn until diff --git a/backend/tests/test_sandbox_tools_security.py b/backend/tests/test_sandbox_tools_security.py index 8801093f0..18e7496a7 100644 --- a/backend/tests/test_sandbox_tools_security.py +++ b/backend/tests/test_sandbox_tools_security.py @@ -613,6 +613,17 @@ def test_validate_local_bash_command_paths_blocks_host_paths() -> None: validate_local_bash_command_paths("cat /etc/passwd", _THREAD_DATA) +@pytest.mark.parametrize("command", ["cat /etc/os-release", "curl file:///etc/os-release"]) +def test_validate_local_bash_command_paths_guides_environment_probe_recovery(command: str) -> None: + with pytest.raises(PermissionError) as exc_info: + validate_local_bash_command_paths(command, _THREAD_DATA) + + message = str(exc_info.value) + assert "For environment questions, use command-only probes" in message + assert "otherwise use an allowed virtual path" in message + assert "Do not repeat the rejected path" in message + + def test_validate_local_bash_command_paths_allows_https_urls() -> None: """URLs like https://github.com/... must not be flagged as unsafe absolute paths.""" validate_local_bash_command_paths( @@ -822,6 +833,38 @@ def test_bash_tool_rejects_host_bash_when_local_sandbox_default(monkeypatch) -> assert "Host bash execution is disabled" in result +def test_bash_tool_description_scopes_environment_detection_to_local_host_bash() -> None: + description = bash_tool.description + + assert "local host via host bash" in description + assert "inspect the current environment instead of" in description + assert "`uname -s`" in description + assert "`sw_vers`" in description + assert "`uname -a`" in description + assert "`/etc/os-release` only when the active" in description + assert "sandbox policy permits it" in description + assert "do not repeat the rejected command" in description + assert "otherwise use allowed virtual paths or explain the restriction" in description + + +def test_bash_tool_guides_recovery_after_host_path_rejection(monkeypatch) -> None: + runtime = SimpleNamespace( + state={"sandbox": {"sandbox_id": "local"}, "thread_data": _THREAD_DATA.copy()}, + context={"thread_id": "thread-1"}, + ) + monkeypatch.setattr( + "deerflow.sandbox.tools.ensure_sandbox_initialized", + lambda runtime: SimpleNamespace(execute_command=lambda command: pytest.fail("unsafe command should not execute")), + ) + monkeypatch.setattr("deerflow.sandbox.tools.ensure_thread_directories_exist", lambda runtime: None) + monkeypatch.setattr("deerflow.sandbox.tools.is_host_bash_allowed", lambda: True) + + result = bash_tool.func(runtime=runtime, description="detect the OS", command="cat /etc/os-release") + + assert "For environment questions, use command-only probes" in result + assert "Do not repeat the rejected path" in result + + def test_bash_tool_blocks_relative_traversal_before_host_execution(monkeypatch) -> None: runtime = SimpleNamespace( state={"sandbox": {"sandbox_id": "local"}, "thread_data": _THREAD_DATA.copy()},