Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ The protocol provides default `to_dict` / `from_dict` implementations, so a poli

### `FileSystemToolResultStore`

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's avoid changing this documentation page. It is still valid.


`FileSystemToolResultStore(root=...)` writes each offloaded result to a file under its root directory and returns the absolute file path as the reference. The directory is created on first write. Store keys are derived from the step count, tool name, and tool call ID (for example `2_search_call-123.txt`), so results from different tools and steps do not collide. A key that would resolve outside the root directory is rejected.
`FileSystemToolResultStore(root=...)` writes each offloaded result to a file under its root directory and returns the absolute file path as the reference. The directory is created on first write. Store keys are derived from the step count, tool name, and tool call ID (for example `2_search_call-123.txt`), so results from different tools and steps do not collide. Keys and references that would resolve outside the root directory are rejected. Even though this implementation uses file paths, callers should still treat the returned reference as opaque and store-scoped.

### Custom store

Expand Down Expand Up @@ -193,23 +193,26 @@ Isolating the store per run keeps concurrent users from colliding on store keys

## Letting the Agent read offloaded results back

The pointer left in the conversation tells the model where the full result lives, but the model can only act on it if the Agent has a tool that can read from the store. With `FileSystemToolResultStore`, that can be a simple file-reading tool:
The pointer left in the conversation tells the model where the full result lives, but the model can only act on it if the Agent has a tool that can read from the store. With `FileSystemToolResultStore`, that can be a simple read-back tool bound to the same store:

```python
from typing import Annotated

from haystack.hooks.tool_result_offloading import FileSystemToolResultStore
from haystack.tools import tool

store = FileSystemToolResultStore(root="tool_results")


@tool
def read_offloaded_result(
path: Annotated[str, "Absolute path of an offloaded tool result"],
reference: Annotated[str, "Store reference returned by the offload store"],
) -> str:
"""Read back the full content of an offloaded tool result."""
return FileSystemToolResultStore(root="tool_results").read(path)
return store.read(reference)
```

With this tool available, the Agent can work with a compact conversation and selectively re-read only the offloaded results it actually needs — instead of carrying every full result in context on every LLM call.
With this tool available, the Agent can work with a compact conversation and selectively re-read only the offloaded results it actually needs — instead of carrying every full result in context on every LLM call. In multi-user setups, bind the tool to the same per-run store or root scope that your offload hook uses; do not treat the reference as an arbitrary filesystem path.

## Serialization

Expand Down
9 changes: 8 additions & 1 deletion docs-website/reference/haystack-api/hooks_api.md
Original file line number Diff line number Diff line change
Expand Up @@ -955,14 +955,21 @@ read(reference: str) -> str

Read back the content previously written to `reference`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The API reference here is automatically generated and updated, so let's remove this change.


The resolved reference must stay within the store root: callers must treat it as an opaque
store-scoped reference, not as an arbitrary filesystem path.

**Parameters:**

- **reference** (<code>str</code>) – A path returned by `write`.
- **reference** (<code>str</code>) – A store reference returned by `write`.

**Returns:**

- <code>str</code> – The stored content.

**Raises:**

- <code>ValueError</code> – If `reference` resolves to a location outside the store root.

#### to_dict

```python
Expand Down
32 changes: 26 additions & 6 deletions haystack/hooks/tool_result_offloading/stores.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,25 @@ def __init__(self, root: str | Path) -> None:
"""
self.root = Path(root)

def _resolve_in_root(self, path_like: str | Path, *, subject: str) -> Path:
"""
Resolve a path-like value and ensure it stays within the configured store root.

Relative values are interpreted relative to `self.root`; absolute values are used as-is.

:param path_like: Relative or absolute path-like value to resolve.
:param subject: Human-readable label used in the error message.
:returns: The resolved absolute path within the store root.
:raises ValueError: If the resolved path escapes the store root.
"""
root = self.root.resolve()
path = Path(path_like)
candidate = path if path.is_absolute() else root / path
resolved = candidate.resolve()
if not resolved.is_relative_to(root):
raise ValueError(f"{subject} '{path_like}' resolves outside the store root '{root}'.")
return resolved

def write(self, *, key: str, content: str) -> str:
"""
Write `content` to `<root>/<key>`, creating parent directories, and return the file path.
Expand All @@ -42,10 +61,7 @@ def write(self, *, key: str, content: str) -> str:
:returns: The absolute path the content was written to, as a string, for use with `read`.
:raises ValueError: If `key` resolves to a location outside the store root.
"""
root = self.root.resolve()
path = (root / key).resolve()
if not path.is_relative_to(root):
raise ValueError(f"Result key '{key}' resolves outside the store root '{root}'.")
path = self._resolve_in_root(key, subject="Result key")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
return str(path)
Expand All @@ -54,10 +70,14 @@ def read(self, reference: str) -> str:
"""
Read back the content previously written to `reference`.

:param reference: A path returned by `write`.
The resolved reference must stay within the store root: callers must treat it as an opaque
store-scoped reference, not as an arbitrary filesystem path.

:param reference: A store reference returned by `write`.
:returns: The stored content.
:raises ValueError: If `reference` resolves to a location outside the store root.
"""
return Path(reference).read_text(encoding="utf-8")
return self._resolve_in_root(reference, subject="Result reference").read_text(encoding="utf-8")

def to_dict(self) -> dict[str, Any]:
"""
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
security:
- |
Harden FileSystemToolResultStore.read() so it only reads references that
resolve within the configured store root. This closes a boundary gap where
callers could previously pass an arbitrary filesystem path to read()
instead of a store-scoped reference returned by write().

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Harden FileSystemToolResultStore.read() so it only reads references that
resolve within the configured store root. This closes a boundary gap where
callers could previously pass an arbitrary filesystem path to read()
instead of a store-scoped reference returned by write().
Harden ``FileSystemToolResultStore.read()`` so it only reads references that
resolve within the configured store root. This closes a boundary gap where
callers could previously pass an arbitrary filesystem path to ``read()``
instead of a store-scoped reference returned by ``write()``.

nitpick

30 changes: 30 additions & 0 deletions test/hooks/tool_result_offloading/test_stores.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
#
# SPDX-License-Identifier: Apache-2.0

import sys
from pathlib import Path

import pytest
Expand Down Expand Up @@ -42,6 +43,35 @@ def test_read_round_trips_written_content(self, tmp_path):
reference = store.write(key="a.txt", content="round trip")
assert store.read(reference) == "round trip"

def test_read_rejects_parent_traversal_reference(self, tmp_path):
store = FileSystemToolResultStore(root=tmp_path / "root")
outside = tmp_path / "outside.txt"
outside.write_text("secret", encoding="utf-8")

with pytest.raises(ValueError, match="outside the store root"):
store.read("../outside.txt")

def test_read_rejects_absolute_reference_outside_root(self, tmp_path):
store = FileSystemToolResultStore(root=tmp_path / "root")
outside = tmp_path / "outside.txt"
outside.write_text("secret", encoding="utf-8")

with pytest.raises(ValueError, match="outside the store root"):
store.read(str(outside))

@pytest.mark.skipif(sys.platform == "win32", reason="symlinks require elevated privileges on Windows")
def test_read_rejects_symlink_reference_escaping_root(self, tmp_path):
root = tmp_path / "root"
root.mkdir()
outside = tmp_path / "outside.txt"
outside.write_text("secret", encoding="utf-8")
link = root / "link.txt"
link.symlink_to(outside)
store = FileSystemToolResultStore(root=root)

with pytest.raises(ValueError, match="outside the store root"):
store.read(str(link))

def test_to_dict_from_dict_roundtrip(self, tmp_path):
store = FileSystemToolResultStore(root=tmp_path)
restored = FileSystemToolResultStore.from_dict(store.to_dict())
Expand Down
Loading