fix(sandbox): reject shell-variable path prefixes in the host-bash guard (#6930)

* fix(sandbox): reject shell-variable path prefixes in the host-bash guard

The local path guard scans commands for absolute paths with a regex whose
lookbehind skips a `/` that follows a word character. `~/`, `${HOME}/` and
`"$HOME"/` were rejected, but the bare `$HOME/...` form (quoted or not)
was never matched, so `cat $HOME/.ssh/id_rsa` ran on the host when
`sandbox.allow_host_bash` was enabled.

Scan `$NAME/...` separately and reject it, since the variable's value is
unknown at validation time. Variables inside URLs and non-ASCII prose keep
the existing exemptions. `$PWD/...` stays allowed because it names the same
file as the equivalent relative path, which the guard already accepts,
unless the command rebinds `PWD` itself.

* docs(changelog): reference #6930 for the host-bash variable path fix

* fix(sandbox): cover positional parameters and CDPATH in the host-bash guard

Review follow-up. `cat $1/etc/passwd` slipped both scans: the new variable
scan only matched names, and the absolute-path regex skips a `/` after a
digit. `bash -c` gets no positionals, so `$1` expands to nothing and the
shell opens `/etc/passwd`. Match `$0`-`$9` as well, but keep awk-style
arithmetic such as `$1/1024` allowed, since it was accepted before and
standard host roots do not start with a digit.

`CDPATH=$HOME cd docs` moved a relative `cd` outside the workspace, taking
both `$PWD/x` and a plain relative `x` with it. Reject any command that
mentions CDPATH in the working-directory check, so the `$PWD` exemption's
equivalence with relative paths holds.

* fix(sandbox): cover named homes and non-ASCII variable paths in the host-bash guard

- Reject `~name/...`, which expands to that user's home and slips the
  absolute-path scan the same way `$HOME/...` did.
- Stop applying the non-ASCII prose exemption to the variable scan:
  `$HOME/文档/...` is a host path.
- Allow awk field division `$1/$2`, which main accepts, and capture the
  remainder in a lookahead so skipping `$1/` does not hide a later
  `$2/etc/passwd`.
This commit is contained in:
Hyeonsang Cho 2026-10-16 18:18:17 +09:00 committed by GitHub
parent 34ccd79351
commit 6a6aeb4bf2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 221 additions and 0 deletions

View File

@ -5968,6 +5968,20 @@ This release closes that milestone with **621 merged pull requests**.
whose head stamp was lost logged 29 (SQLite) or 36 (Postgres), burying the
real type mismatch the warning exists to catch. Both sides are now compared
as the connected database spells them. ([#6865])
- **sandbox:** With host bash enabled, the local path guard now rejects a
shell variable used as a path prefix, such as `cat $HOME/.ssh/id_rsa`.
`~/`, `${HOME}/` and `"$HOME"/` were already rejected, but the
absolute-path scan skips a `/` that follows a word character, so the
bare `$HOME/...` form (quoted or not) reached the host. Positional
parameters are covered too: `bash -c` gets none, so `$1/etc/passwd`
opens `/etc/passwd`; awk-style `$1/1024` and `$1/$2` stay allowed. A
named home such as `~root/.ssh/id_rsa` is rejected the same way, and a
non-ASCII segment after a variable (`$HOME/文档`) is treated as a path,
not prose. Commands that mention `CDPATH` are rejected, because it moves
a relative `cd` outside the workspace. `$PWD/...` stays allowed because it
names the same file as the equivalent relative path, unless the command
rebinds `PWD` itself. Variables inside URLs are unaffected. The guard
remains best-effort and is not an isolation boundary. ([#6930])
### Security
@ -11763,3 +11777,4 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#6876]: https://github.com/bytedance/deer-flow/pull/6876
[#6897]: https://github.com/bytedance/deer-flow/pull/6897
[#6909]: https://github.com/bytedance/deer-flow/pull/6909
[#6930]: https://github.com/bytedance/deer-flow/pull/6930

View File

@ -4528,6 +4528,16 @@
数据库升级时都会记录一条,丢失 head stamp 的 schema 会记录 29 条(SQLite)或
36 条(Postgres),淹没了这个警告本应发现的真实类型不一致。现在两侧都按所连接
数据库的写法进行比较。([#6865])
- **沙箱:** 启用 host bash 时,本地路径守卫现在会拒绝把 shell 变量用作路径前缀的
命令,例如 `cat $HOME/.ssh/id_rsa`。`~/`、`${HOME}/` 和 `"$HOME"/` 原本就会被拒绝,
但绝对路径扫描会跳过紧跟在单词字符之后的 `/`,因此不带花括号的 `$HOME/...`
(无论是否加引号)会直达主机。位置参数同样会被检查:`bash -c` 不带位置参数,
因此 `$1/etc/passwd` 会打开 `/etc/passwd`;awk 风格的 `$1/1024` 和 `$1/$2` 仍然
允许。`~root/.ssh/id_rsa` 这类指定用户的主目录同样会被拒绝,变量之后的非 ASCII
路径段(`$HOME/文档`)按路径处理,不再视为普通文本。提及 `CDPATH` 的命令会被拒绝,
因为它会把相对路径的 `cd` 移出工作区。`$PWD/...` 仍然允许,因为它与对应的相对路径
指向同一文件,除非命令自己重新绑定了 `PWD`。URL 中的变量不受影响。该守卫仍是尽力
而为的检查,并非隔离边界。([#6930])
### 安全
@ -9380,3 +9390,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#6876]: https://github.com/bytedance/deer-flow/pull/6876
[#6897]: https://github.com/bytedance/deer-flow/pull/6897
[#6909]: https://github.com/bytedance/deer-flow/pull/6909
[#6930]: https://github.com/bytedance/deer-flow/pull/6930

View File

@ -69,6 +69,25 @@ _SANDBOX_AUTHORIZATION_CHECKED: ContextVar[bool] = ContextVar(
)
_ABSOLUTE_PATH_PATTERN = re.compile(r"(?<![:\w])(?<!:/)/(?:[^\s\"'`;&|<>()]+)")
# ``$NAME/...`` expands to a host path at runtime (``$HOME/.ssh/id_rsa``), but
# the absolute-path regex skips a ``/`` that follows a word character, so the
# bare-name form needs its own scan. Positional ``$1/etc/passwd`` slips the
# same way, and ``bash -c`` gets no positionals, so it expands to ``/etc/passwd``.
# ``${NAME}/``, ``~/`` and special parameters (``$@/``, ``$?/``) already surface
# through the absolute-path regex because ``}``, ``~``, ``@`` and ``?`` are not
# word chars. The remainder is captured in a lookahead so a skipped prefix does
# not hide a later one (``$1/$2/etc/passwd``).
_SHELL_VARIABLE_PATH_PATTERN = re.compile(r"\$([A-Za-z_][A-Za-z0-9_]*|[0-9])/(?=([^\s\"'`;&|<>()]*))")
# ``~name/`` expands to that user's home (``~root/.ssh/id_rsa``) and slips the
# absolute-path regex for the same reason. Tilde expansion only applies at the
# start of a word or after ``=``/``:``, so ``HEAD~1/`` stays out of scope.
_NAMED_TILDE_PATH_PATTERN = re.compile(r"(?<![^\s=:;&|<>()\"'`])~[A-Za-z0-9._-]+/[^\s\"'`;&|<>()]*")
# ``PWD`` written anywhere except as a ``$PWD`` expansion (``PWD=/``,
# ``read PWD``, ``for PWD in``) may rebind it away from the workspace.
_PWD_REBIND_PATTERN = re.compile(r"(?<!\$)\bPWD\b")
# ``CDPATH`` makes a relative ``cd docs`` land outside the workspace without any
# absolute path in the command.
_CDPATH_PATTERN = re.compile(r"\bCDPATH\b")
# A ``{...}`` block holding a single identifier-like placeholder (e.g. ``{id}``
# in a REST template or ``{port}`` in an f-string). Bash brace expansion such as
# ``{passwd,shadow}`` or ``{,.bak}`` does NOT match (commas/dots/empty inner).
@ -1294,6 +1313,8 @@ def _validate_local_bash_shell_tokens(command: str, allowed_paths: list[str], to
"""Conservatively reject relative path escapes missed by absolute-path scanning."""
if re.search(r"\$\([^)]*\b(?:cd|pushd)\b", command):
raise PermissionError(f"Unsafe working directory change in command substitution. Use paths under {VIRTUAL_PATH_PREFIX}")
if _CDPATH_PATTERN.search(command):
raise PermissionError(f"Unsafe working directory change via CDPATH. Use paths under {VIRTUAL_PATH_PREFIX}")
for token in tokens:
if _is_shell_command_separator(token) or _is_shell_redirection_operator(token):
@ -1416,6 +1437,10 @@ def validate_local_bash_command_paths(command: str, thread_data: ThreadDataState
A small allowlist of common system path prefixes is kept for executable
and device references (e.g. /bin/sh, /dev/null). Quoted shell words are
validated whole, so a space inside quotes is not treated as a new segment.
A shell variable used as a path prefix (``$HOME/...``, ``$1/...``) or a named
home (``~root/...``) is rejected like ``${HOME}/...`` and ``~/...``, since its
runtime value is unknown here;
``$PWD/...`` is checked like the relative path it is equivalent to.
A windows-incompatible segment still rejects a path that is not already
stored on the host; an existing host file may be read or renamed.
"""
@ -1448,6 +1473,31 @@ def validate_local_bash_command_paths(command: str, thread_data: ThreadDataState
unsafe_paths.append(candidate)
# A variable's value is unknown here, so its path is never allowed. The one
# exception is ``$PWD``: ``$PWD/.venv/bin`` names the same file as the
# relative ``.venv/bin``, which this guard already accepts, unless the
# command rebinds ``PWD`` itself.
# The prose exemption used above does not apply: ``$HOME/文档`` is a real
# host directory.
pwd_is_workspace = not _PWD_REBIND_PATTERN.search(command)
for match in _SHELL_VARIABLE_PATH_PATTERN.finditer(command):
if _is_in_spans(match.start(), url_spans):
continue
name, remainder = match.group(1), match.group(2)
if name == "PWD" and pwd_is_workspace:
continue
# ``$1/1024`` and ``$1/$2`` are awk-style field arithmetic
# (``awk '{print $1/$2}'``), not paths: standard top-level host
# directories do not start with a digit.
if name.isdigit() and remainder.removeprefix("$")[:1].isdigit():
continue
unsafe_paths.append(match.group() + remainder)
for match in _NAMED_TILDE_PATH_PATTERN.finditer(command):
if _is_in_spans(match.start(), url_spans):
continue
unsafe_paths.append(match.group())
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}. {_LOCAL_BASH_PATH_RECOVERY_GUIDANCE}")

View File

@ -1013,6 +1013,151 @@ def test_validate_local_bash_command_paths_blocks_brace_expansion_host_paths(com
validate_local_bash_command_paths(command, _THREAD_DATA)
@pytest.mark.parametrize(
("command", "rejected"),
[
# The absolute-path regex skips a ``/`` right after a word character,
# so ``$HOME/...`` slipped through while ``~/``, ``${HOME}/`` and
# ``"$HOME"/`` were rejected. All four expand to the same host path.
("cat $HOME/.ssh/id_rsa", "$HOME/.ssh/id_rsa"),
('cat "$HOME/.ssh/id_rsa"', "$HOME/.ssh/id_rsa"),
("ls $HOME/", "$HOME/"),
("cp /mnt/user-data/workspace/rc $HOME/.bashrc", "$HOME/.bashrc"),
("ls $DEER_FLOW_HOME/users", "$DEER_FLOW_HOME/users"),
("echo ok && cat $_cfg/x", "$_cfg/x"),
# ``cd`` leaves OLDPWD at the Gateway's own working directory.
("cat $OLDPWD/config.yaml", "$OLDPWD/config.yaml"),
# ``$PWD`` is the workspace only while the command does not rebind it.
("PWD=/; cat $PWD/etc/passwd", "$PWD/etc/passwd"),
("read PWD; cat $PWD/x", "$PWD/x"),
("for PWD in ~; do cat $PWD/.ssh/id_rsa; done", "$PWD/.ssh/id_rsa"),
("export PWD=/etc && cat $PWD/passwd", "$PWD/passwd"),
# ``bash -c`` gets no positionals, so ``$1`` expands to nothing.
("cat $1/etc/passwd", "$1/etc/passwd"),
('cat "$2/etc/passwd"', "$2/etc/passwd"),
("ls $0/", "$0/"),
# Skipping ``$1/`` as arithmetic must not hide the ``$2/`` after it.
("cat $1/$2/etc/passwd", "$2/etc/passwd"),
# Non-ASCII after a variable prefix is still a host path.
("cat $HOME/文档/marker", "$HOME/文档/marker"),
# ``~name`` expands to that user's home.
("cat ~root/.ssh/id_rsa", "~root/.ssh/id_rsa"),
("ls ~deploy/", "~deploy/"),
("make --directory=~deploy/app", "~deploy/app"),
],
)
def test_validate_local_bash_command_paths_blocks_variable_prefixed_host_paths(command: str, rejected: str) -> None:
with pytest.raises(PermissionError, match="Unsafe absolute paths") as exc_info:
validate_local_bash_command_paths(command, _THREAD_DATA)
assert rejected in str(exc_info.value)
@pytest.mark.parametrize(
"command",
[
# Variables that are not used as a path prefix stay allowed.
"echo $HOME",
"echo $PATH",
"echo $((total/2))",
# A variable inside a URL is a URL segment, not a host path.
"curl https://api.github.com/repos/$OWNER/$REPO/issues",
"curl https://example.com/~alice/index.html",
# ``~`` mid-word is not tilde expansion.
"cat drafts~v2/notes.md",
# ``$PWD/x`` names the same file as the relative ``x``, which the guard
# already accepts (``$PWD/../x`` is rejected like ``../x``).
"cat $PWD/notes.txt",
'export PATH="$PWD/.venv/bin:$PATH"',
"cd /mnt/user-data/uploads && cat $PWD/report.pdf",
# awk field arithmetic is not a path.
"du -k /mnt/user-data/workspace | awk '{print $1/1024}'",
"awk -F, '{print $1/$2}' /mnt/user-data/uploads/data.csv",
],
)
def test_validate_local_bash_command_paths_allows_non_path_variable_uses(command: str) -> None:
validate_local_bash_command_paths(command, _THREAD_DATA)
@pytest.mark.parametrize(
"command",
[
# CDPATH relocates a relative ``cd`` outside the workspace, taking both
# ``$PWD`` and plain relative paths with it.
"CDPATH=$HOME cd docs && cat $PWD/x",
"CDPATH=/ cd etc && cat passwd",
"export CDPATH=/; cd etc; cat passwd",
],
)
def test_validate_local_bash_command_paths_rejects_cdpath_relocation(command: str) -> None:
with pytest.raises(PermissionError, match="Unsafe working directory change via CDPATH"):
validate_local_bash_command_paths(command, _THREAD_DATA)
def test_validate_local_bash_command_paths_rejects_pwd_traversal_like_relative_traversal() -> None:
for command in ("cat ../../etc/passwd", "cat $PWD/../../etc/passwd"):
with pytest.raises(PermissionError, match="path traversal"):
validate_local_bash_command_paths(command, _THREAD_DATA)
def test_bash_tool_rejects_variable_prefixed_host_path_before_execution(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="read key", command="cat $HOME/.ssh/id_rsa")
assert "Unsafe absolute paths in command: $HOME/.ssh/id_rsa" in result
@pytest.mark.skipif(os.name == "nt", reason="POSIX shell expands $HOME")
@pytest.mark.parametrize(
("command", "rejected"),
[
('cat "$HOME/.ssh/id_rsa"', "Unsafe absolute paths in command: $HOME/.ssh/id_rsa"),
('CDPATH="$HOME" cd .ssh && cat $PWD/id_rsa', "Unsafe working directory change via CDPATH"),
('cat "$HOME/文档/id_rsa"', "Unsafe absolute paths in command: $HOME/文档/id_rsa"),
],
)
def test_local_bash_tool_does_not_read_variable_prefixed_host_file(tmp_path: Path, monkeypatch, command: str, rejected: str) -> None:
"""End to end through a real ``LocalSandbox``: before the fix the shell
expanded ``$HOME`` and printed the host file."""
home = tmp_path / "home"
(home / ".ssh").mkdir(parents=True)
(home / ".ssh" / "id_rsa").write_text("host-private-key")
(home / "文档").mkdir()
(home / "文档" / "id_rsa").write_text("host-private-key")
monkeypatch.setenv("HOME", str(home))
thread_root = tmp_path / "threads" / "t1" / "user-data"
thread_data = {f"{name}_path": str(thread_root / name) for name in ("workspace", "uploads", "outputs")}
for path in thread_data.values():
Path(path).mkdir(parents=True)
workspace = str(Path(thread_data["workspace_path"]).resolve())
sandbox = LocalSandbox(
id="local",
path_mappings=[PathMapping(container_path=f"{VIRTUAL_PATH_PREFIX}/workspace", local_path=workspace)],
)
runtime = SimpleNamespace(
state={"sandbox": {"sandbox_id": "local"}, "thread_data": thread_data},
context={"thread_id": "t1"},
config={},
)
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: sandbox)
monkeypatch.setattr("deerflow.sandbox.tools.is_host_bash_allowed", lambda: True)
result = bash_tool.func(runtime=runtime, description="read key", command=command)
assert "host-private-key" not in result
assert rejected in result
def test_bash_tool_rejects_host_bash_when_local_sandbox_default(monkeypatch) -> None:
runtime = SimpleNamespace(
state={"sandbox": {"sandbox_id": "local"}, "thread_data": _THREAD_DATA.copy()},