fix(middleware): validate exact byte size on sandbox tool output externalization (#6112)

* fix(middleware): validate exact byte size on sandbox tool output externalization

* test(middleware): simulate partial write in sandbox externalization size test

* Fix PR number in CHANGELOG.md

Updated the changelog to reflect the correct issue number for a truncated file write.

* Update CHANGELOG_zh.md for issue #6112

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Shxiao 2026-10-01 09:38:08 +09:00 committed by GitHub
parent ee3159a01d
commit 52b21d13ac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 69 additions and 8 deletions

View File

@ -327,6 +327,14 @@ This release closes that milestone with **181 merged pull requests**.
### Fixed
- **middleware:** `_externalize_to_sandbox` now validates the full byte count
of externalized tool outputs instead of only testing non-emptiness with
`test -s`. When a remote sandbox write truncated the file part-way (e.g. disk
full or pipe failure), the partial file previously passed validation, handing
the model a truncated file path. It now verifies the file size exactly matches
the payload, returning `None` and falling back to inline truncation if a write
was truncated. ([#6112])
- **gateway:** A non-ASCII CSRF token, GitHub webhook signature, internal auth
token, OIDC `state`, or provisioner `X-API-Key` is now rejected with the
usual 403/401 instead of a 500. `hmac.compare_digest` raises `TypeError` for
@ -6304,4 +6312,5 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#6070]: https://github.com/bytedance/deer-flow/pull/6070
[#6076]: https://github.com/bytedance/deer-flow/pull/6076
[#6088]: https://github.com/bytedance/deer-flow/pull/6088
[#6112]: https://github.com/bytedance/deer-flow/pull/6112

View File

@ -332,6 +332,11 @@
### 修复
- **中间件:** `_externalize_to_sandbox` 现在校验外部化工具输出的完整字节大小,
而不再仅使用 `test -s` 检查文件是否非空。此前当远程沙箱写入中途被截断时(如磁盘满
或管道故障),残缺的文件也会通过校验并把错误路径交付给模型;现在会严格核对字节数与负载一致,
发生截断时返回 `None` 并回退到内联截断。([#6112])
- **Gateway:** 含非 ASCII 字符的 CSRF token、GitHub webhook 签名、内部认证 token、
OIDC `state` 或 provisioner `X-API-Key` 现在按常规返回 403/401,而不是 500。
`hmac.compare_digest` 遇到含非 ASCII 字符的 `str` 参数会抛出 `TypeError`,而
@ -5344,3 +5349,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#6070]: https://github.com/bytedance/deer-flow/pull/6070
[#6076]: https://github.com/bytedance/deer-flow/pull/6076
[#6088]: https://github.com/bytedance/deer-flow/pull/6088
[#6112]: https://github.com/bytedance/deer-flow/pull/6112

View File

@ -223,14 +223,18 @@ def _externalize_to_sandbox(
# raising, so we cannot rely on exception propagation here.
sandbox.execute_command(f"mkdir -p {shlex.quote(virtual_dir)}")
sandbox.write_file(virtual_path, content)
# Validate the file landed: execute_command may have silently failed
# to create the directory, and write_file backends differ. Refuse to
# hand the model an unreadable read_file path.
check = sandbox.execute_command(f"test -s {shlex.quote(virtual_path)} && echo OK || echo MISSING")
# Validate the file landed completely: execute_command may have silently
# failed to create the directory, or write_file may have truncated the
# content (disk full, backend pipe error). Refuse to hand the model an
# incomplete or unreadable read_file path.
expected_bytes = len(content.encode("utf-8"))
quoted_path = shlex.quote(virtual_path)
check = sandbox.execute_command(f'test -f {quoted_path} && test "$(wc -c < {quoted_path})" -eq {expected_bytes} && echo OK || echo MISSING')
if not isinstance(check, str) or check.strip() != "OK":
logger.warning(
"Sandbox externalize validation failed: path=%s, check=%r",
"Sandbox externalize validation failed: path=%s, expected_bytes=%d, check=%r",
virtual_path,
expected_bytes,
check,
)
return None

View File

@ -12,6 +12,7 @@ import contextlib
import json
import os
import pathlib
import re
import tempfile
from types import SimpleNamespace
@ -1270,9 +1271,10 @@ class TestConfigVersion:
class _FakeSandbox:
"""In-memory stand-in for a Sandbox. Records calls and supports failure injection."""
def __init__(self, *, write_ok: bool = True, check_result: str = "OK") -> None:
def __init__(self, *, write_ok: bool = True, check_result: str | None = None) -> None:
self.commands: list[str] = []
self.writes: list[tuple[str, str]] = []
self.files: dict[str, str] = {}
self._write_ok = write_ok
self._check_result = check_result
@ -1284,14 +1286,28 @@ class _FakeSandbox:
) -> str:
del env, timeout
self.commands.append(command)
if command.startswith("test -s"):
if self._check_result is not None:
return self._check_result
# Simulate shell execution of:
# test -f <path> && test "$(wc -c < <path>)" -eq <expected> && echo OK || echo MISSING
match = re.search(r'test -f (\S+) && test "\$\(wc -c < \S+\)" -eq (\d+)', command)
if match:
path, expected_bytes = match.group(1), int(match.group(2))
if path in self.files:
actual_bytes = len(self.files[path].encode("utf-8"))
if actual_bytes == expected_bytes:
return "OK"
return "MISSING"
return ""
def write_file(self, path: str, content: str, append: bool = False) -> None:
if not self._write_ok:
raise RuntimeError("simulated write failure")
self.writes.append((path, content))
if append and path in self.files:
self.files[path] += content
else:
self.files[path] = content
class _FakeProvider:
@ -1323,7 +1339,8 @@ class TestExternalizeToSandbox:
assert result.startswith("/mnt/user-data/outputs/.tool-results/bash-")
assert result.endswith(".log")
assert any(c.startswith("mkdir -p ") for c in sb.commands)
assert any(c.startswith("test -s ") for c in sb.commands)
assert any("wc -c" in c for c in sb.commands)
assert any("-eq 100" in c for c in sb.commands)
assert sb.writes and sb.writes[0][0] == result
assert sb.writes[0][1] == "x" * 100
@ -1355,6 +1372,31 @@ class TestExternalizeToSandbox:
)
assert result is None
def test_returns_none_when_byte_size_is_mismatched(self):
"""A truncated write (fewer bytes than expected) fails validation and returns None."""
from deerflow.agents.middlewares.tool_output_budget_middleware import (
_externalize_to_sandbox,
)
class _TruncatingSandbox(_FakeSandbox):
def write_file(self, path: str, content: str, append: bool = False) -> None:
# Simulate a truncated write (e.g. disk full / broken pipe) where only half lands
super().write_file(path, content[: len(content) // 2], append=append)
sb = _TruncatingSandbox()
result = _externalize_to_sandbox(
"x" * 100,
tool_name="bash",
tool_call_id="tc-3-truncated",
storage_subdir=".tool-results",
sandbox=sb,
)
assert result is None
assert any("-eq 100" in c for c in sb.commands)
# Confirm the file was actually written with half size, triggering the real byte mismatch
assert sb.writes and len(sb.writes[0][1]) == 50
assert any(len(content) == 50 for content in sb.files.values())
def test_rejects_unsafe_storage_subdir(self):
from deerflow.agents.middlewares.tool_output_budget_middleware import (
_externalize_to_sandbox,