Port from lobehub/lobehub#18258: verify persisted tool-result archives before referencing them
Oversized tool results are archived to disk and replaced in-context with a 'Full output saved to: <path>' reference. Until now the write was trusted blind: a partially-flushed host file (ENOSPC/quota races) or a lossy sandbox write (API-body truncation on payload backends) still produced the archive reference, so the model was told the full result was recoverable when bytes had silently vanished. Both persistence paths now round-trip-verify size before building the reference and fail closed to the bounded inline truncation otherwise: - _write_to_spillover: byte-count check via os.stat after write; mismatched archives are deleted and the caller falls through to inline truncation. - _write_to_sandbox: wc -c probe after the cat; heredoc-mode backends get a +1 byte tolerance (wrap_modal_stdin_heredoc appends one newline by construction), unprobeable backends stay best-effort success. Regression tests fail without the fix (verified by stashing the source change: 4 failed). E2E-verified against a temp HERMES_HOME with real file I/O including multibyte content and a simulated short write.
This commit is contained in:
@@ -49,14 +49,15 @@ class TestWriteToSandbox:
|
||||
env.execute.return_value = {"output": "", "returncode": 0}
|
||||
result = _write_to_sandbox("hello world", "/tmp/hermes-results/abc.txt", env)
|
||||
assert result is True
|
||||
env.execute.assert_called_once()
|
||||
cmd = env.execute.call_args[0][0]
|
||||
# First call is the write; a second call round-trip-verifies the
|
||||
# persisted size (unparseable probe output = best-effort success).
|
||||
cmd = env.execute.call_args_list[0][0][0]
|
||||
assert "mkdir -p" in cmd
|
||||
# Content travels through stdin, NOT inside the command string —
|
||||
# otherwise large content would hit Linux's 128 KB MAX_ARG_STRLEN
|
||||
# ceiling on `bash -c <cmd>` (#22906).
|
||||
assert "hello world" not in cmd
|
||||
assert env.execute.call_args[1]["stdin_data"] == "hello world"
|
||||
assert env.execute.call_args_list[0][1]["stdin_data"] == "hello world"
|
||||
|
||||
|
||||
def test_large_content_via_stdin(self):
|
||||
@@ -66,9 +67,9 @@ class TestWriteToSandbox:
|
||||
env.execute.return_value = {"output": "", "returncode": 0}
|
||||
big = "x" * 200_000
|
||||
_write_to_sandbox(big, "/tmp/hermes-results/big.txt", env)
|
||||
cmd = env.execute.call_args[0][0]
|
||||
cmd = env.execute.call_args_list[0][0][0]
|
||||
assert len(cmd) < 1_000 # cmd is just `mkdir -p X && cat > Y`
|
||||
assert env.execute.call_args[1]["stdin_data"] == big
|
||||
assert env.execute.call_args_list[0][1]["stdin_data"] == big
|
||||
|
||||
|
||||
def test_path_with_spaces_is_quoted(self):
|
||||
@@ -76,7 +77,7 @@ class TestWriteToSandbox:
|
||||
env.execute.return_value = {"output": "", "returncode": 0}
|
||||
remote_path = "/tmp/hermes results/abc file.txt"
|
||||
_write_to_sandbox("content", remote_path, env)
|
||||
cmd = env.execute.call_args[0][0]
|
||||
cmd = env.execute.call_args_list[0][0][0]
|
||||
assert "'/tmp/hermes results'" in cmd
|
||||
assert "'/tmp/hermes results/abc file.txt'" in cmd
|
||||
|
||||
@@ -86,7 +87,7 @@ class TestWriteToSandbox:
|
||||
env.execute.return_value = {"output": "", "returncode": 0}
|
||||
malicious_path = "/tmp/hermes-results/$(whoami).txt"
|
||||
_write_to_sandbox("content", malicious_path, env)
|
||||
cmd = env.execute.call_args[0][0]
|
||||
cmd = env.execute.call_args_list[0][0][0]
|
||||
# The $() must not appear unquoted — shlex.quote wraps it
|
||||
assert "'/tmp/hermes-results/$(whoami).txt'" in cmd
|
||||
|
||||
@@ -95,10 +96,73 @@ class TestWriteToSandbox:
|
||||
env.execute.return_value = {"output": "", "returncode": 0}
|
||||
malicious_path = "/tmp/x; rm -rf /; echo .txt"
|
||||
_write_to_sandbox("content", malicious_path, env)
|
||||
cmd = env.execute.call_args[0][0]
|
||||
cmd = env.execute.call_args_list[0][0][0]
|
||||
# The semicolons must be inside quotes, not acting as command separators
|
||||
assert "'/tmp/x; rm -rf /; echo .txt'" in cmd
|
||||
|
||||
def test_size_mismatch_fails_closed_and_removes_archive(self):
|
||||
"""A sandbox archive that lost bytes must be discarded, not referenced.
|
||||
|
||||
Port of lobehub/lobehub#18258: verify the persisted archive before
|
||||
telling the model the full result is available; fail closed to the
|
||||
bounded inline truncation when persistence is not lossless.
|
||||
"""
|
||||
env = MagicMock()
|
||||
env._stdin_mode = "pipe"
|
||||
content = "x" * 1000
|
||||
env.execute.side_effect = [
|
||||
{"output": "", "returncode": 0}, # write
|
||||
{"output": "512\n", "returncode": 0}, # wc -c: short!
|
||||
{"output": "", "returncode": 0}, # rm -f cleanup
|
||||
]
|
||||
assert _write_to_sandbox(content, "/tmp/hermes-results/short.txt", env) is False
|
||||
rm_cmd = env.execute.call_args_list[2][0][0]
|
||||
assert rm_cmd.startswith("rm -f ")
|
||||
assert "/tmp/hermes-results/short.txt" in rm_cmd
|
||||
|
||||
def test_exact_size_match_succeeds(self):
|
||||
env = MagicMock()
|
||||
env._stdin_mode = "pipe"
|
||||
content = "abcé" # multi-byte: expected size is the UTF-8 byte count
|
||||
expected = len(content.encode("utf-8"))
|
||||
env.execute.side_effect = [
|
||||
{"output": "", "returncode": 0},
|
||||
{"output": f"{expected}\n", "returncode": 0},
|
||||
]
|
||||
assert _write_to_sandbox(content, "/tmp/hermes-results/ok.txt", env) is True
|
||||
|
||||
def test_heredoc_backend_tolerates_single_trailing_newline(self):
|
||||
"""Heredoc-mode backends append one newline by construction."""
|
||||
env = MagicMock()
|
||||
env._stdin_mode = "heredoc"
|
||||
content = "y" * 100
|
||||
env.execute.side_effect = [
|
||||
{"output": "", "returncode": 0},
|
||||
{"output": "101\n", "returncode": 0},
|
||||
]
|
||||
assert _write_to_sandbox(content, "/tmp/hermes-results/hd.txt", env) is True
|
||||
|
||||
def test_pipe_backend_rejects_extra_byte(self):
|
||||
"""The +1 tolerance is heredoc-only; pipe backends must be exact."""
|
||||
env = MagicMock()
|
||||
env._stdin_mode = "pipe"
|
||||
content = "y" * 100
|
||||
env.execute.side_effect = [
|
||||
{"output": "", "returncode": 0},
|
||||
{"output": "101\n", "returncode": 0},
|
||||
{"output": "", "returncode": 0}, # rm -f
|
||||
]
|
||||
assert _write_to_sandbox(content, "/tmp/hermes-results/hd2.txt", env) is False
|
||||
|
||||
def test_unprobeable_backend_is_best_effort_success(self):
|
||||
"""No wc / probe crash must not discard a likely-good archive."""
|
||||
env = MagicMock()
|
||||
env.execute.side_effect = [
|
||||
{"output": "", "returncode": 0},
|
||||
RuntimeError("exec transport gone"),
|
||||
]
|
||||
assert _write_to_sandbox("data", "/tmp/hermes-results/np.txt", env) is True
|
||||
|
||||
|
||||
class TestResolveStorageDir:
|
||||
def test_defaults_to_storage_dir_without_env(self):
|
||||
@@ -185,10 +249,12 @@ class TestMaybePersistToolResult:
|
||||
"""Content is persisted verbatim — no JSON extraction."""
|
||||
import json
|
||||
env = MagicMock()
|
||||
# Readability probe fails -> falls back to the in-sandbox write.
|
||||
# Readability probe fails -> falls back to the in-sandbox write,
|
||||
# whose size probe returns unparseable output (best-effort success).
|
||||
env.execute.side_effect = [
|
||||
{"output": "", "returncode": 1},
|
||||
{"output": "", "returncode": 0},
|
||||
{"output": "", "returncode": 1}, # wc -c size probe: no answer
|
||||
]
|
||||
env.get_temp_dir.return_value = ""
|
||||
raw = "line1\nline2\n" * 5_000
|
||||
@@ -203,7 +269,7 @@ class TestMaybePersistToolResult:
|
||||
assert PERSISTED_OUTPUT_TAG in result
|
||||
# Content is delivered through stdin (no longer embedded in the
|
||||
# command string — see test_large_content_via_stdin for why).
|
||||
assert env.execute.call_args[1]["stdin_data"] == content
|
||||
assert env.execute.call_args_list[1][1]["stdin_data"] == content
|
||||
|
||||
|
||||
def test_tool_use_id_cannot_escape_storage_dir(self):
|
||||
@@ -212,6 +278,7 @@ class TestMaybePersistToolResult:
|
||||
env.execute.side_effect = [
|
||||
{"output": "", "returncode": 1},
|
||||
{"output": "", "returncode": 0},
|
||||
{"output": "", "returncode": 1}, # wc -c size probe: no answer
|
||||
]
|
||||
env.get_temp_dir.return_value = ""
|
||||
content = "x" * 60_000
|
||||
@@ -222,7 +289,7 @@ class TestMaybePersistToolResult:
|
||||
env=env,
|
||||
threshold=30_000,
|
||||
)
|
||||
cmd = env.execute.call_args[0][0]
|
||||
cmd = env.execute.call_args_list[1][0][0]
|
||||
target = cmd.split("cat > ", 1)[1].split(" <<", 1)[0]
|
||||
|
||||
assert "Full output saved to: /tmp/hermes-results/outside_whoami_x_" in result
|
||||
@@ -390,6 +457,7 @@ class TestSpillover:
|
||||
env.execute.side_effect = [
|
||||
{"output": "", "returncode": 1}, # probe: not readable
|
||||
{"output": "", "returncode": 0}, # cat > sandbox path
|
||||
{"output": "60000\n", "returncode": 0}, # wc -c verification
|
||||
]
|
||||
env.get_temp_dir.return_value = "/tmp"
|
||||
content = "z" * 60_000
|
||||
@@ -402,7 +470,7 @@ class TestSpillover:
|
||||
)
|
||||
assert PERSISTED_OUTPUT_TAG in result
|
||||
assert "/tmp/hermes-results/tc_remote_2.txt" in result
|
||||
assert env.execute.call_count == 2
|
||||
assert env.execute.call_count == 3
|
||||
# Host canonical copy exists regardless.
|
||||
assert (get_spillover_dir() / "tc_remote_2.txt").exists()
|
||||
|
||||
@@ -483,3 +551,48 @@ class TestRecoveryHint:
|
||||
assert msg.startswith(PERSISTED_OUTPUT_TAG)
|
||||
assert msg.endswith(PERSISTED_OUTPUT_CLOSING_TAG)
|
||||
assert "read_file" in msg
|
||||
|
||||
|
||||
# ── host-side spillover round-trip verification ───────────────────────
|
||||
# Port of lobehub/lobehub#18258: verify the persisted archive before
|
||||
# telling the model the full result is available; fail closed otherwise.
|
||||
|
||||
class TestSpilloverWriteVerification:
|
||||
def test_lossless_write_returns_path(self):
|
||||
from tools.tool_result_storage import _write_to_spillover
|
||||
|
||||
path = _write_to_spillover("hello spill", "tc_verify_ok.txt")
|
||||
assert path is not None
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
assert fh.read() == "hello spill"
|
||||
|
||||
def test_short_write_is_discarded(self):
|
||||
"""A partially-flushed archive must not be referenced to the model."""
|
||||
from unittest.mock import patch as _patch
|
||||
|
||||
from tools.tool_result_storage import _write_to_spillover
|
||||
|
||||
real_stat = __import__("os").stat
|
||||
|
||||
class _ShortStat:
|
||||
st_size = 3 # pretend only 3 bytes landed
|
||||
|
||||
def fake_stat(p, *a, **kw):
|
||||
if str(p).endswith("tc_verify_short.txt"):
|
||||
return _ShortStat()
|
||||
return real_stat(p, *a, **kw)
|
||||
|
||||
with _patch("tools.tool_result_storage.os.stat", side_effect=fake_stat):
|
||||
path = _write_to_spillover("this is much longer than 3 bytes", "tc_verify_short.txt")
|
||||
assert path is None
|
||||
assert not (get_spillover_dir() / "tc_verify_short.txt").exists()
|
||||
|
||||
def test_multibyte_content_verified_by_byte_count(self):
|
||||
"""Verification compares UTF-8 bytes, not characters."""
|
||||
from tools.tool_result_storage import _write_to_spillover
|
||||
|
||||
content = "héllo wörld ✓" * 10
|
||||
path = _write_to_spillover(content, "tc_verify_mb.txt")
|
||||
assert path is not None
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
assert fh.read() == content
|
||||
|
||||
@@ -84,15 +84,33 @@ def _is_host_side_env(env) -> bool:
|
||||
|
||||
|
||||
def _write_to_spillover(content: str, filename: str):
|
||||
"""Write host-side to $HERMES_HOME/cache/spillover; returns path str or None."""
|
||||
"""Write host-side to $HERMES_HOME/cache/spillover; returns path str or None.
|
||||
|
||||
The write is size-verified before the caller tells the model "Full output saved":
|
||||
a partially-flushed file (quota, ENOSPC race) fails closed to the bounded inline
|
||||
truncation instead of referencing an archive that silently lost bytes.
|
||||
"""
|
||||
data = content.encode("utf-8", errors="replace")
|
||||
try:
|
||||
spill_dir = get_spillover_dir()
|
||||
spill_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = spill_dir / filename
|
||||
path.write_text(content, encoding="utf-8", errors="replace")
|
||||
path.write_bytes(data)
|
||||
persisted_size = os.stat(path).st_size
|
||||
except OSError as exc:
|
||||
logger.warning("Spillover write failed for %s: %s", filename, exc)
|
||||
return None
|
||||
if persisted_size != len(data):
|
||||
logger.warning(
|
||||
"Spillover write for %s is not lossless (%d bytes on disk, "
|
||||
"expected %d) — discarding archive",
|
||||
filename, persisted_size, len(data),
|
||||
)
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
_prune_spillover_once()
|
||||
return str(path)
|
||||
|
||||
@@ -157,10 +175,42 @@ def generate_preview(content: str, max_chars: int = DEFAULT_PREVIEW_SIZE_CHARS)
|
||||
def _write_to_sandbox(content: str, remote_path: str, env) -> bool:
|
||||
"""Write content into the sandbox via env.execute(); True on success. Content goes through
|
||||
stdin, not the command string: Linux ``MAX_ARG_STRLEN`` caps one argv element at 128 KB,
|
||||
so a heredoc-in-command silently failed for exactly the oversized results this handles."""
|
||||
so a heredoc-in-command silently failed for exactly the oversized results this handles.
|
||||
|
||||
The write is round-trip verified with ``wc -c`` (one extra exec RTT per oversized result):
|
||||
a zero exit from ``cat`` does not prove the bytes landed (quota/ENOSPC races, API-body
|
||||
truncation on payload backends). A measured mismatch removes the archive and fails closed;
|
||||
an unprobeable backend (no ``wc``, exec error, unparseable output) stays best-effort success.
|
||||
Heredoc-mode backends append exactly one trailing newline by construction
|
||||
(``wrap_modal_stdin_heredoc``), so one extra byte is accepted there."""
|
||||
storage_dir = os.path.dirname(remote_path)
|
||||
cmd = f"mkdir -p {shlex.quote(storage_dir)} && cat > {shlex.quote(remote_path)}"
|
||||
return env.execute(cmd, timeout=30, stdin_data=content).get("returncode", 1) == 0
|
||||
if env.execute(cmd, timeout=30, stdin_data=content).get("returncode", 1) != 0:
|
||||
return False
|
||||
|
||||
expected = len(content.encode("utf-8", errors="replace"))
|
||||
try:
|
||||
probe = env.execute(f"wc -c < {shlex.quote(remote_path)}", timeout=15)
|
||||
except Exception as exc:
|
||||
logger.debug("Sandbox size probe failed for %s: %s", remote_path, exc)
|
||||
return True
|
||||
if probe.get("returncode", 1) != 0:
|
||||
return True
|
||||
raw = str(probe.get("output", "") or "").strip().split()
|
||||
if not raw or not raw[-1].isdigit():
|
||||
return True
|
||||
persisted_size = int(raw[-1])
|
||||
if persisted_size == expected:
|
||||
return True
|
||||
if persisted_size == expected + 1 and getattr(env, "_stdin_mode", None) == "heredoc":
|
||||
return True
|
||||
logger.warning("Sandbox spill for %s is not lossless (%d bytes in sandbox, expected %d) — discarding archive",
|
||||
remote_path, persisted_size, expected)
|
||||
try:
|
||||
env.execute(f"rm -f {shlex.quote(remote_path)}", timeout=15)
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _build_persisted_message(preview: str, has_more: bool, original_size: int,
|
||||
|
||||
Reference in New Issue
Block a user