From 5a7fda9ce1eac70147e67097b1bd3b3e6c059014 Mon Sep 17 00:00:00 2001 From: Aarkin7 Date: Fri, 17 Jul 2026 22:11:46 +0530 Subject: [PATCH 1/6] docs: clarify that offloaded result references are store-scoped --- .../agents-1/tool-result-offloading.mdx | 13 ++++++++----- docs-website/reference/haystack-api/hooks_api.md | 9 ++++++++- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/docs-website/docs/pipeline-components/agents-1/tool-result-offloading.mdx b/docs-website/docs/pipeline-components/agents-1/tool-result-offloading.mdx index 78b51326f8a..762cf75f85b 100644 --- a/docs-website/docs/pipeline-components/agents-1/tool-result-offloading.mdx +++ b/docs-website/docs/pipeline-components/agents-1/tool-result-offloading.mdx @@ -146,7 +146,7 @@ The protocol provides default `to_dict` / `from_dict` implementations, so a poli ### `FileSystemToolResultStore` -`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 @@ -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 diff --git a/docs-website/reference/haystack-api/hooks_api.md b/docs-website/reference/haystack-api/hooks_api.md index 4a7a3f61061..785b70ffa20 100644 --- a/docs-website/reference/haystack-api/hooks_api.md +++ b/docs-website/reference/haystack-api/hooks_api.md @@ -955,14 +955,21 @@ read(reference: str) -> str Read back the content previously written to `reference`. +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** (str) – A path returned by `write`. +- **reference** (str) – A store reference returned by `write`. **Returns:** - str – The stored content. +**Raises:** + +- ValueError – If `reference` resolves to a location outside the store root. + #### to_dict ```python From 08ce252eaef306ce69e6c6a822b542fa54b765f4 Mon Sep 17 00:00:00 2001 From: Aarkin7 Date: Fri, 17 Jul 2026 22:12:24 +0530 Subject: [PATCH 2/6] fix: constrain FileSystemToolResultStore reads to the configured root --- .../hooks/tool_result_offloading/stores.py | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/haystack/hooks/tool_result_offloading/stores.py b/haystack/hooks/tool_result_offloading/stores.py index eb23c5426d2..7eff22edf74 100644 --- a/haystack/hooks/tool_result_offloading/stores.py +++ b/haystack/hooks/tool_result_offloading/stores.py @@ -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 `/`, creating parent directories, and return the file path. @@ -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) @@ -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]: """ From 46545a3fcfc608dc338a7dc9954a18584a680285 Mon Sep 17 00:00:00 2001 From: Aarkin7 Date: Fri, 17 Jul 2026 22:12:51 +0530 Subject: [PATCH 3/6] test: add regressions for out-of-root and symlink store reads --- .../tool_result_offloading/test_stores.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/test/hooks/tool_result_offloading/test_stores.py b/test/hooks/tool_result_offloading/test_stores.py index 6522639d085..66697dd0cec 100644 --- a/test/hooks/tool_result_offloading/test_stores.py +++ b/test/hooks/tool_result_offloading/test_stores.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: Apache-2.0 +import sys from pathlib import Path import pytest @@ -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()) From f911af0c174aad52a22cfaaf69f8458b3cbb8213 Mon Sep 17 00:00:00 2001 From: Aarkin7 Date: Fri, 17 Jul 2026 22:13:13 +0530 Subject: [PATCH 4/6] chore: add release note for tool result store read hardening --- .../tool-result-store-read-boundary-2d1720bfa728e738.yaml | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 releasenotes/notes/tool-result-store-read-boundary-2d1720bfa728e738.yaml diff --git a/releasenotes/notes/tool-result-store-read-boundary-2d1720bfa728e738.yaml b/releasenotes/notes/tool-result-store-read-boundary-2d1720bfa728e738.yaml new file mode 100644 index 00000000000..678cddb01c1 --- /dev/null +++ b/releasenotes/notes/tool-result-store-read-boundary-2d1720bfa728e738.yaml @@ -0,0 +1,6 @@ +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()`. From 67750d9769fb9fbdf0e78431a9af2cc8472e0054 Mon Sep 17 00:00:00 2001 From: Aarkin7 Date: Fri, 17 Jul 2026 22:32:24 +0530 Subject: [PATCH 5/6] chore: remove inline code markup from release note --- .../tool-result-store-read-boundary-2d1720bfa728e738.yaml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/releasenotes/notes/tool-result-store-read-boundary-2d1720bfa728e738.yaml b/releasenotes/notes/tool-result-store-read-boundary-2d1720bfa728e738.yaml index 678cddb01c1..c2f483b78a1 100644 --- a/releasenotes/notes/tool-result-store-read-boundary-2d1720bfa728e738.yaml +++ b/releasenotes/notes/tool-result-store-read-boundary-2d1720bfa728e738.yaml @@ -1,6 +1,7 @@ +--- security: - | - Harden `FileSystemToolResultStore.read()` so it only reads references that + 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()`. + callers could previously pass an arbitrary filesystem path to read() + instead of a store-scoped reference returned by write(). From a158e529847e4c5f597dd336e58b75965893dbfd Mon Sep 17 00:00:00 2001 From: Aarkin7 Date: Tue, 21 Jul 2026 16:06:54 +0530 Subject: [PATCH 6/6] chore: apply PR 12059 review follow-ups --- .../agents-1/tool-result-offloading.mdx | 13 +++++-------- docs-website/reference/haystack-api/hooks_api.md | 9 +-------- ...result-store-read-boundary-2d1720bfa728e738.yaml | 6 +++--- 3 files changed, 9 insertions(+), 19 deletions(-) diff --git a/docs-website/docs/pipeline-components/agents-1/tool-result-offloading.mdx b/docs-website/docs/pipeline-components/agents-1/tool-result-offloading.mdx index 762cf75f85b..78b51326f8a 100644 --- a/docs-website/docs/pipeline-components/agents-1/tool-result-offloading.mdx +++ b/docs-website/docs/pipeline-components/agents-1/tool-result-offloading.mdx @@ -146,7 +146,7 @@ The protocol provides default `to_dict` / `from_dict` implementations, so a poli ### `FileSystemToolResultStore` -`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. +`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. ### Custom store @@ -193,26 +193,23 @@ 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 read-back tool bound to the same store: +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: ```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( - reference: Annotated[str, "Store reference returned by the offload store"], + path: Annotated[str, "Absolute path of an offloaded tool result"], ) -> str: """Read back the full content of an offloaded tool result.""" - return store.read(reference) + return FileSystemToolResultStore(root="tool_results").read(path) ``` -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. +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. ## Serialization diff --git a/docs-website/reference/haystack-api/hooks_api.md b/docs-website/reference/haystack-api/hooks_api.md index 785b70ffa20..4a7a3f61061 100644 --- a/docs-website/reference/haystack-api/hooks_api.md +++ b/docs-website/reference/haystack-api/hooks_api.md @@ -955,21 +955,14 @@ read(reference: str) -> str Read back the content previously written to `reference`. -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** (str) – A store reference returned by `write`. +- **reference** (str) – A path returned by `write`. **Returns:** - str – The stored content. -**Raises:** - -- ValueError – If `reference` resolves to a location outside the store root. - #### to_dict ```python diff --git a/releasenotes/notes/tool-result-store-read-boundary-2d1720bfa728e738.yaml b/releasenotes/notes/tool-result-store-read-boundary-2d1720bfa728e738.yaml index c2f483b78a1..f6cea175771 100644 --- a/releasenotes/notes/tool-result-store-read-boundary-2d1720bfa728e738.yaml +++ b/releasenotes/notes/tool-result-store-read-boundary-2d1720bfa728e738.yaml @@ -1,7 +1,7 @@ --- security: - | - Harden FileSystemToolResultStore.read() so it only reads references that + 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(). + callers could previously pass an arbitrary filesystem path to ``read()`` + instead of a store-scoped reference returned by ``write()``.