Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
6 changes: 4 additions & 2 deletions python/packages/core/AGENTS.md

Large diffs are not rendered by default.

117 changes: 116 additions & 1 deletion python/packages/core/agent_framework/_filesystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,47 @@

from __future__ import annotations

import hashlib
import stat
from base64 import b32encode
from pathlib import Path

_WINDOWS_RESERVED_FILE_STEMS: frozenset[str] = frozenset({
"CON",
"PRN",
"AUX",
"NUL",
"COM1",
"COM2",
"COM3",
"COM4",
"COM5",
"COM6",
"COM7",
"COM8",
"COM9",
"LPT1",
"LPT2",
"LPT3",
"LPT4",
"LPT5",
"LPT6",
"LPT7",
"LPT8",
"LPT9",
"COM¹",
"COM²",
"COM³",
"LPT¹",
"LPT²",
"LPT³",
})

def is_link_or_reparse_point(path: Path) -> bool:
_MAX_ENCODED_STORAGE_KEY_SEGMENT_LENGTH = 180
_DIGEST_SEGMENT_MARKER = "sha256-"


def _is_link_or_reparse_point(path: Path) -> bool: # pyright: ignore[reportUnusedFunction]
"""Return whether ``path`` is a symbolic link, junction, or other reparse point."""
path_stat = path.lstat()
if stat.S_ISLNK(path_stat.st_mode):
Expand All @@ -21,3 +57,82 @@ def is_link_or_reparse_point(path: Path) -> bool:
reparse_attribute = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0)
file_attributes = getattr(path_stat, "st_file_attributes", 0)
return bool(reparse_attribute and file_attributes & reparse_attribute)


def _is_literal_storage_key_segment_safe(value: str) -> bool:
"""Return whether an opaque identifier can be used verbatim as one path segment.

The predicate is deliberately conservative because the result is used as a
security boundary (see :func:`_storage_key_segment`). Only *lowercase* ASCII
alphanumerics and ``.``/``_``/``-`` are accepted, so a literal segment can
never be folded onto a different identifier by the filesystem:

* Rejecting non-ASCII avoids Unicode normalization folding (macOS APFS and
HFS+ map the NFD and NFC forms of the same text onto one directory entry).
* Rejecting uppercase avoids case folding (NTFS and APFS are case-insensitive
by default, so ``A`` and ``a`` would be one directory entry). Uppercase
values are encoded rather than lowercased, because lowercasing ``A`` would
collide with a genuine ``a``.

Values that are not literal-safe are encoded rather than rejected.
"""
if (
not value
or value.startswith(".")
or value.endswith((" ", "."))
or value.split(".", maxsplit=1)[0].upper() in _WINDOWS_RESERVED_FILE_STEMS
):
return False
if any(ord(character) < 32 for character in value):
return False
return all(
character.isascii() and ((character.isalnum() and not character.isupper()) or character in "._-")
for character in value
)


def _storage_key_segment(value: str, *, encoded_prefix: str) -> str: # pyright: ignore[reportUnusedFunction]
"""Return a filesystem-safe path segment for an opaque identifier.

This is the single derivation used everywhere an identifier that participates
in an isolation boundary (a session ID, an owner ID, a memory scope) is turned
into a storage location. Callers must not pre-normalize the value: a general
path canonicalizer is lossy, and identifiers that differ only in separators or
surrounding whitespace would then share one storage namespace.

The derivation has three branches:

* Literal-safe values are returned verbatim, so on-disk layouts stay readable.
* Everything else is encoded under ``encoded_prefix`` using lowercase base32.
This branch is **injective**: base32 is exactly reversible, and its alphabet
(``a``-``z`` and ``2``-``7``) is case-stable, so the mapping stays one-to-one
even on a case-insensitive filesystem. The literal and encoded namespaces
cannot overlap because :func:`_is_literal_storage_key_segment_safe` rejects
every value starting with ``~``, which each ``encoded_prefix`` begins with.
* Past :data:`_MAX_ENCODED_STORAGE_KEY_SEGMENT_LENGTH` the encoding is replaced
by a SHA-256 digest so the segment stays within filesystem name limits. This
branch maps an unbounded input space onto 256 bits, so it is
**collision-resistant** rather than injective: distinct identifiers sharing a
namespace requires finding a SHA-256 collision.

Args:
value: The opaque identifier to convert.

Keyword Args:
encoded_prefix: Component-specific prefix applied to encoded values, so
that two components encoding the same identifier stay distinguishable.
Must start with ``~`` to stay outside the literal namespace.

Returns:
A single path segment containing no path separators.
"""
if _is_literal_storage_key_segment_safe(value):
return value
encoded_value = b32encode(value.encode("utf-8")).decode("ascii").rstrip("=").lower()
encoded_segment = f"{encoded_prefix}{encoded_value}"
if len(encoded_segment) <= _MAX_ENCODED_STORAGE_KEY_SEGMENT_LENGTH:
return encoded_segment
# ``-`` is outside the base32 alphabet, so a digest segment can never collide
# with the encoding of some other (shorter) identifier.
digest = hashlib.sha256(value.encode("utf-8")).hexdigest()
return f"{encoded_prefix}{_DIGEST_SEGMENT_MARKER}{digest}"
Comment thread
westey-m marked this conversation as resolved.
20 changes: 14 additions & 6 deletions python/packages/core/agent_framework/_harness/_file_access.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
from pydantic import BaseModel, Field

from .._feature_stage import ExperimentalFeature, experimental
from .._filesystem import is_link_or_reparse_point
from .._filesystem import _is_link_or_reparse_point # pyright: ignore[reportPrivateUsage]
from .._serialization import SerializationMixin
from .._sessions import AgentSession, ContextProvider, SessionContext
from .._telemetry import FeatureIndex, mark_feature_used
Expand Down Expand Up @@ -157,6 +157,14 @@ def _normalize_relative_path(path: str, *, is_directory: bool = False) -> str:
trailing separators are not accepted (so ``"foo/"`` does not silently
become the file path ``"foo"``).

This is for *paths supplied as tool arguments*. It is deliberately lossy:
``"a/"`` and ``"a//"`` both normalize to ``"a"``, as does the equivalent
backslash-terminated spelling. Never use it to derive a storage namespace
from an identifier that participates in an isolation boundary (a session ID,
owner ID, or memory scope) — distinct identifiers would share one location.
Use :func:`~agent_framework._filesystem._storage_key_segment` for that
instead.

Args:
path: The relative path to normalize.

Expand Down Expand Up @@ -1160,7 +1168,7 @@ def _resolve_safe_directory_path(self, relative_directory: str) -> Path:
# normal and must stay cheap, but a dangling link still has to be rejected rather than
# read as absent.
if os.path.lexists(self._root_path):
if is_link_or_reparse_point(self._root_path):
if _is_link_or_reparse_point(self._root_path):
raise ValueError("Invalid path: the resolved path contains a symbolic link or reparse point.")
if self._root_path.resolve() != self._root_path:
raise ValueError("Invalid path: the resolved path escapes the root directory.")
Expand All @@ -1187,7 +1195,7 @@ def _throw_if_contains_symlink(self, candidate: Path) -> None:
for segment in relative_parts:
current = current / segment
try:
is_link = is_link_or_reparse_point(current)
is_link = _is_link_or_reparse_point(current)
except FileNotFoundError:
break
except OSError as exc:
Expand Down Expand Up @@ -1301,7 +1309,7 @@ def _list_sync(full_dir: Path) -> list[FileStoreEntry]:
files: list[FileStoreEntry] = []
for entry in full_dir.iterdir():
try:
is_link = is_link_or_reparse_point(entry)
is_link = _is_link_or_reparse_point(entry)
except OSError:
# Fail closed when an entry cannot be inspected.
continue
Expand Down Expand Up @@ -1362,7 +1370,7 @@ def _enumerate_search_files(full_dir: Path, recursive: bool) -> list[tuple[str,
current = directories.pop()
for entry in current.iterdir():
try:
is_link = is_link_or_reparse_point(entry)
is_link = _is_link_or_reparse_point(entry)
except OSError:
# Fail closed when an entry cannot be inspected.
continue
Expand Down Expand Up @@ -1392,7 +1400,7 @@ def _search_files_sync(
# Re-checked here, not only during enumeration: a candidate can be swapped for a
# link in between. O_NOFOLLOW makes the read itself atomic where the platform has
# it, and this narrows the window on Windows, where it does not exist.
if is_link_or_reparse_point(entry):
if _is_link_or_reparse_point(entry):
logger.warning("Skipping symlinked file during search: %s", entry)
skipped.append(relative_name)
continue
Expand Down
45 changes: 41 additions & 4 deletions python/packages/core/agent_framework/_harness/_file_memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@
folder (derived from the session id). Pass an explicit ``scope`` to group
memories differently, for example by user id.

A ``scope`` (and the session id it defaults to) is treated as an **opaque
namespace key**, not as a path: it is mapped to exactly one storage folder by a
derivation that is injective for all but pathologically long values, so two
byte-distinct values cannot share a working folder. Applications that derive a
scope from an externally supplied value should still canonicalize and authorize
that value themselves; the mapping here is the storage-layer guarantee, not a
substitute for that check.

The provider exposes the following tools to the agent (registered on the
per-invocation :class:`~agent_framework.SessionContext` in
:meth:`FileMemoryProvider.before_run`):
Expand All @@ -40,6 +48,7 @@

from pydantic import BaseModel, Field

from .._filesystem import _storage_key_segment # pyright: ignore[reportPrivateUsage]
from .._sessions import AgentSession, ContextProvider, SessionContext
from .._tools import tool
from .._types import Message
Expand Down Expand Up @@ -77,6 +86,10 @@
_DESCRIPTION_SUFFIX = "_description.md"
_MEMORY_INDEX_FILE_NAME = "memories.md"
_MAX_INDEX_ENTRIES = 50
# Prefix for scopes that are not usable verbatim as a folder name. Kept distinct
# from the other harness prefixes so the same value under a different component
# never lands in the same directory.
_ENCODED_SCOPE_PREFIX = "~scope-"


def _description_file_name(file_name: str) -> str:
Expand Down Expand Up @@ -247,6 +260,11 @@ class FileMemoryProvider(ContextProvider):
Memories are isolated per session: each session reads and writes under a
working folder derived from its session id. Pass an explicit ``scope`` to
group memories differently (for example, per user id) across sessions.

The scope (or session id) is an opaque namespace key, not a path. It is
mapped to a single folder by a derivation that is injective for all but
pathologically long values, so two byte-distinct values cannot share a
working folder.
"""

def __init__(
Expand All @@ -267,7 +285,11 @@ def __init__(
scope: The namespace that logically groups and isolates memories
(for example, a user ID). Used as the working folder within the
store. When ``None`` (the default), the active session's
``session_id`` is used, isolating memories per session.
``session_id`` is used, isolating memories per session. The
value is treated as an opaque key rather than a path: it is
mapped onto exactly one folder, so a multi-segment value
such as ``"tenants/alice"`` becomes a single encoded folder
instead of a nested directory.
instructions: Optional instruction override. When ``None`` the
default file-memory instructions are used.
"""
Expand All @@ -284,11 +306,26 @@ def _resolve_working_folder(self, context: SessionContext) -> str:
"""Resolve the working folder for the current invocation.

Uses the configured ``scope`` when set, otherwise the session id. The
result is normalized as a relative directory path so it cannot escape
the store root.
value is an opaque namespace key, not a path: it is mapped to exactly
one folder name by :func:`~agent_framework._filesystem._storage_key_segment`.
That derivation is injective except for pathologically long values,
which fall back to a collision-resistant digest. Two byte-distinct
scopes or session ids therefore do not resolve to the same working
folder, so a caller authorized for one of them cannot reach another's
memories.

Raises:
ValueError: When neither ``scope`` nor the session id yields a
namespace. Without one there is nothing to isolate on, and
falling back to the store root would expose every other scope.
"""
raw_scope = self.scope or context.session_id or ""
return _normalize_relative_path(raw_scope, is_directory=True)
if not raw_scope:
raise ValueError(
"FileMemoryProvider requires a memory scope: pass an explicit 'scope' or run with a session "
"that has a 'session_id'. Without one, memories cannot be isolated from other scopes."
)
return _storage_key_segment(raw_scope, encoded_prefix=_ENCODED_SCOPE_PREFIX)

async def _rebuild_index(self, working_folder: str) -> None:
"""Rebuild the ``memories.md`` index for ``working_folder``.
Expand Down
Loading
Loading