Skip to content
Draft
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 src/rath/backend/persistence/paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from uuid import UUID

from rath.config.paths import resolve_config_dir
from rath.utils.ids import coerce_uuid_str

__all__ = [
"SANDBOXES_DIR_NAME",
Expand Down Expand Up @@ -48,7 +49,7 @@ def local_root() -> Path:

def local_sandbox_dir(sandbox_id: UUID | str) -> Path:
"""``<local_root>/<sandbox_id>`` — the stable working_dir for a Local sandbox."""
return local_root() / str(sandbox_id)
return local_root() / coerce_uuid_str(sandbox_id, field="sandbox_id")


def opensandbox_root() -> Path:
Expand All @@ -58,7 +59,8 @@ def opensandbox_root() -> Path:

def opensandbox_index_path(sandbox_id: UUID | str) -> Path:
"""``<opensandbox_root>/<sandbox_id>.json`` — registry entry for a remote sandbox."""
return opensandbox_root() / f"{sandbox_id}{OPENSANDBOX_INDEX_SUFFIX}"
sid = coerce_uuid_str(sandbox_id, field="sandbox_id")
return opensandbox_root() / f"{sid}{OPENSANDBOX_INDEX_SUFFIX}"


def ensure_local_root() -> Path:
Expand Down
8 changes: 4 additions & 4 deletions src/rath/flow/memory_inject.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@
A policy reads a :class:`~rath.session.session.Session` and a
:class:`~rath.memory.abc.MemoryStore`, then returns a tuple of
:class:`~rath.session.chunk.ChunkRow` to prepend to the next loop turn
(typically :attr:`ChunkKind.SYSTEM` notes that summarize relevant
recalled memories).
as untrusted user-context notes that summarize relevant recalled memories.

The injection step must NEVER raise into the session loop — on store
errors or a closed store, return an empty tuple and log a warning so
Expand Down Expand Up @@ -101,5 +100,6 @@ def _last_user_message(session: Session) -> str | None:

def _hit_to_chunk(hit: MemoryHit) -> ChunkRow:
snippet = (hit.snippet or "").strip()
body = f"[memory:{hit.uri}] {snippet}" if snippet else f"[memory:{hit.uri}]"
return ChunkRow(kind=ChunkKind.SYSTEM, payload={"content": body})
prefix = f"[untrusted memory:{hit.uri}]"
body = f"{prefix} {snippet}" if snippet else prefix
return ChunkRow(kind=ChunkKind.USER, payload={"content": body})
191 changes: 183 additions & 8 deletions src/rath/memory/adapters/local.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@
from __future__ import annotations

import hashlib
import ipaddress
import json
import logging
import math
import re
import socket
import urllib.error
import urllib.parse
import urllib.request
Expand Down Expand Up @@ -76,6 +78,8 @@
_VEC_SUFFIX = ".vec"
_META_SUFFIX = ".meta.json"
_HIDDEN_SUFFIXES: frozenset[str] = frozenset({_VEC_SUFFIX, _META_SUFFIX})
_DEFAULT_RESOURCE_MAX_BYTES = 10 * 1024 * 1024
_SAFE_SEGMENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")


_CAPABILITIES = MemoryCapabilities(
Expand Down Expand Up @@ -118,6 +122,14 @@ class _LocalHandle:
chat_init_failed: bool = field(default=False)


@dataclass(frozen=True, slots=True)
class _ResourcePolicy:
local_roots: tuple[Path, ...]
allowed_http_hosts: frozenset[str]
allow_private_hosts: bool
max_bytes: int


@register("local")
class LocalMemoryBackend(MemoryBackend):
"""Filesystem-backed memory backend, default for ``pip install openrath``."""
Expand Down Expand Up @@ -306,13 +318,22 @@ def _dispatch_resource(
if isinstance(target_path, MemoryExecutionFailure):
return target_path

policy = _resource_policy(bound.options)
try:
raw_bytes, original_name, source_label = _fetch_resource(op.source)
raw_bytes, original_name, source_label = _fetch_resource(
op.source,
policy=policy,
)
except FileNotFoundError as exc:
return MemoryExecutionFailure(
kind="not_found",
message=f"resource source not found: {exc}",
)
except _ResourceAccessDenied as exc:
return MemoryExecutionFailure(
kind="unauthorized",
message=f"resource source not allowed: {exc}",
)
except _ResourceFetchError as exc:
return MemoryExecutionFailure(
kind="transport",
Expand Down Expand Up @@ -362,9 +383,20 @@ def _dispatch_commit(
kind="invalid_uri",
message="MemoryOpCommit requires non-empty session_id",
)
session_id = _safe_storage_segment(op.session_id, field="session_id")
if isinstance(session_id, MemoryExecutionFailure):
return session_id
# Archive messages.json under session/<sid>/commits/<timestamp>/.
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%f")
commit_root = bound.path / "session" / op.session_id / "commits" / stamp
commit_root = _contained_child(
bound.path,
"session",
session_id,
"commits",
stamp,
)
if isinstance(commit_root, MemoryExecutionFailure):
return commit_root
commit_root.mkdir(parents=True, exist_ok=True)
archive_path = commit_root / "messages.json"
normalized = [_normalize_message(m) for m in op.messages]
Expand All @@ -373,7 +405,7 @@ def _dispatch_commit(
encoding="utf-8",
)
archived_uri = (
f"{MEMORY_URI_PREFIX}session/{op.session_id}/commits/{stamp}/messages.json"
f"{MEMORY_URI_PREFIX}session/{session_id}/commits/{stamp}/messages.json"
)

if not op.wait:
Expand Down Expand Up @@ -987,23 +1019,85 @@ class _ResourceFetchError(Exception):
"""Wraps transport errors when fetching a remote resource."""


def _fetch_resource(source: str) -> tuple[bytes, str, str]:
class _ResourceAccessDenied(Exception):
"""Raised when a resource source violates the configured policy."""


class _NoRedirect(urllib.request.HTTPRedirectHandler):
"""Reject redirects so each fetched URL is policy-checked directly."""

def redirect_request(self, *args: Any, **kwargs: Any) -> None: # noqa: D401
return None


_NO_REDIRECT_OPENER = urllib.request.build_opener(_NoRedirect)


def _resource_policy(options: dict[str, Any]) -> _ResourcePolicy:
roots = tuple(
Path(p).expanduser().resolve(strict=False)
for p in _option_strings(options.get("resource_import_roots"))
)
hosts = frozenset(
h.lower() for h in _option_strings(options.get("resource_allowed_http_hosts"))
)
raw_max = options.get("resource_max_bytes", _DEFAULT_RESOURCE_MAX_BYTES)
try:
max_bytes = int(raw_max)
except (TypeError, ValueError):
max_bytes = _DEFAULT_RESOURCE_MAX_BYTES
return _ResourcePolicy(
local_roots=roots,
allowed_http_hosts=hosts,
allow_private_hosts=bool(options.get("resource_allow_private_hosts", False)),
max_bytes=max(1, max_bytes),
)


def _option_strings(raw: Any) -> tuple[str, ...]:
if raw is None:
return ()
if isinstance(raw, str):
return (raw,)
try:
return tuple(str(x) for x in raw)
except TypeError:
return (str(raw),)


def _fetch_resource(
source: str,
*,
policy: _ResourcePolicy,
) -> tuple[bytes, str, str]:
"""Resolve ``source`` to ``(bytes, original_name, source_label)``.

``source`` can be a local filesystem path, a ``file://`` URI, or an
``http(s)://`` URL. Anything else is treated as a local path.
Local paths and HTTP(S) URLs are disabled by default. Callers must opt in
with ``resource_import_roots`` or ``resource_allowed_http_hosts`` in the
store options so untrusted resource ingest cannot read host files or SSRF
internal services.
"""
parsed = urllib.parse.urlparse(source)
scheme = parsed.scheme.lower()
# Windows drive letters look like ``c:\foo`` to urlparse — treat as path.
if len(scheme) == 1 and scheme.isalpha():
scheme = ""
if scheme in ("http", "https"):
_validate_resource_url(parsed, policy=policy)
try:
with urllib.request.urlopen(source, timeout=30) as resp: # noqa: S310
data = resp.read()
with _NO_REDIRECT_OPENER.open(source, timeout=30) as resp:
raw_len = resp.headers.get("Content-Length")
if raw_len is not None and int(raw_len) > policy.max_bytes:
raise _ResourceFetchError(
f"resource exceeds {policy.max_bytes} bytes",
)
data = resp.read(policy.max_bytes + 1)
except urllib.error.URLError as exc:
raise _ResourceFetchError(str(exc)) from exc
except ValueError as exc:
raise _ResourceFetchError(str(exc)) from exc
if len(data) > policy.max_bytes:
raise _ResourceFetchError(f"resource exceeds {policy.max_bytes} bytes")
name = Path(parsed.path).name or "resource"
return data, name, source
if scheme == "file":
Expand All @@ -1012,11 +1106,92 @@ def _fetch_resource(source: str) -> tuple[bytes, str, str]:
local = Path(source)
else:
raise _ResourceFetchError(f"unsupported scheme: {scheme!r}")
local = local.expanduser().resolve(strict=False)
_validate_local_resource_path(local, policy=policy)
if not local.is_file():
raise FileNotFoundError(str(local))
size = local.stat().st_size
if size > policy.max_bytes:
raise _ResourceFetchError(f"resource exceeds {policy.max_bytes} bytes")
return local.read_bytes(), local.name, str(local)


def _validate_local_resource_path(local: Path, *, policy: _ResourcePolicy) -> None:
if not policy.local_roots:
raise _ResourceAccessDenied("local paths require resource_import_roots")
for root in policy.local_roots:
try:
local.relative_to(root)
except ValueError:
continue
return
raise _ResourceAccessDenied(f"{local} is outside resource_import_roots")


def _validate_resource_url(
parsed: urllib.parse.ParseResult,
*,
policy: _ResourcePolicy,
) -> None:
host = (parsed.hostname or "").lower()
if not host:
raise _ResourceAccessDenied("HTTP(S) resource URL requires a host")
if host not in policy.allowed_http_hosts:
raise _ResourceAccessDenied("HTTP(S) host is not allowed")
try:
infos = socket.getaddrinfo(
host,
parsed.port or (443 if parsed.scheme == "https" else 80),
type=socket.SOCK_STREAM,
)
except OSError as exc:
raise _ResourceFetchError(str(exc)) from exc
for info in infos:
address = info[4][0]
try:
ip = ipaddress.ip_address(address)
except ValueError as exc:
raise _ResourceAccessDenied(f"cannot validate address {address!r}") from exc
if _is_restricted_address(ip) and not policy.allow_private_hosts:
raise _ResourceAccessDenied("HTTP(S) host resolves to a private address")


def _is_restricted_address(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
return (
ip.is_loopback
or ip.is_private
or ip.is_link_local
or ip.is_multicast
or ip.is_reserved
or ip.is_unspecified
)


def _safe_storage_segment(value: str, *, field: str) -> str | MemoryExecutionFailure:
text = str(value)
if not text or text in (".", "..") or not _SAFE_SEGMENT_RE.fullmatch(text):
return MemoryExecutionFailure(
kind="invalid_uri",
message=f"{field} must be a single safe path segment",
)
return text


def _contained_child(
root: Path,
*parts: str,
) -> Path | MemoryExecutionFailure:
try:
resolved = root.joinpath(*parts).resolve(strict=False)
resolved.relative_to(root.resolve(strict=False))
except (OSError, ValueError) as exc:
return MemoryExecutionFailure(
kind="invalid_uri",
message=f"path escapes store root: {exc}",
)
return resolved


def _walk_tree(
dir_path: Path,
uri_base: str,
Expand Down
3 changes: 2 additions & 1 deletion src/rath/memory/persistence/paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from uuid import UUID

from rath.config.paths import resolve_config_dir
from rath.utils.ids import coerce_uuid_str

__all__ = [
"MEMORY_DIR_NAME",
Expand All @@ -41,7 +42,7 @@ def local_memory_root() -> Path:

def local_store_dir(store_id: UUID | str) -> Path:
"""``<local_memory_root>/<store_id>`` — the per-store directory."""
return local_memory_root() / str(store_id)
return local_memory_root() / coerce_uuid_str(store_id, field="store_id")


def ensure_local_memory_root() -> Path:
Expand Down
7 changes: 5 additions & 2 deletions src/rath/session/persistence/paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from uuid import UUID

from rath.config.paths import resolve_config_dir
from rath.utils.ids import coerce_uuid_str

__all__ = [
"SESSIONS_DIR_NAME",
Expand Down Expand Up @@ -41,7 +42,8 @@ def session_file(session_id: UUID | str) -> Path:
Accepts either a :class:`uuid.UUID` or a string; both are normalized via
``str(id)`` so callers don't have to think about it.
"""
return sessions_dir() / f"{session_id}{SESSION_FILE_SUFFIX}"
sid = coerce_uuid_str(session_id, field="session_id")
return sessions_dir() / f"{sid}{SESSION_FILE_SUFFIX}"


def session_partial_file(session_id: UUID | str) -> Path:
Expand All @@ -53,7 +55,8 @@ def session_partial_file(session_id: UUID | str) -> Path:
writing process crashed mid-session (or that the runtime drain timed
out and abandoned the writer).
"""
return sessions_dir() / f"{session_id}{SESSION_PARTIAL_SUFFIX}"
sid = coerce_uuid_str(session_id, field="session_id")
return sessions_dir() / f"{sid}{SESSION_PARTIAL_SUFFIX}"


def ensure_sessions_dir() -> Path:
Expand Down
23 changes: 23 additions & 0 deletions src/rath/utils/ids.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""Identifier normalization helpers for filesystem-backed persistence."""

from __future__ import annotations

from uuid import UUID

__all__ = ["coerce_uuid_str"]


def coerce_uuid_str(value: UUID | str, *, field: str = "id") -> str:
"""Return ``value`` as a canonical UUID string or raise ``ValueError``.

Persistence identifiers are used as path components. Accepting arbitrary
strings here would make path traversal possible, so string inputs must be
parseable UUIDs before they can reach filesystem helpers.
"""

if isinstance(value, UUID):
return str(value)
try:
return str(UUID(str(value)))
except ValueError as exc:
raise ValueError(f"{field} must be a UUID") from exc
Loading
Loading