Skip to content
Closed
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
30 changes: 30 additions & 0 deletions lib/crewai-tools/src/crewai_tools/file_storage/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""Pluggable backing store for :class:`FileReadTool` / :class:`FileWriterTool`.

The tools default to :class:`LocalFileStore`, which reads and writes the
local filesystem exactly as they always have. A deployment environment where
the local disk is ephemeral can register a different store, so the same tools
persist somewhere durable without the agent, the crew definition, or the tool
arguments changing.

A store owns its own containment. ``resolve`` and ``resolve_within`` must
reject any path the caller should not reach, because the tools call nothing
else before doing I/O.
"""

from crewai_tools.file_storage.base import FileStore, FileStoreError
from crewai_tools.file_storage.local import LocalFileStore
from crewai_tools.file_storage.registry import (
register_file_store_factory,
reset_file_store_factory,
resolve_file_store,
)


__all__ = [
"FileStore",
"FileStoreError",
"LocalFileStore",
"register_file_store_factory",
"reset_file_store_factory",
"resolve_file_store",
]
103 changes: 103 additions & 0 deletions lib/crewai-tools/src/crewai_tools/file_storage/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""The store protocol the file tools are written against."""

from __future__ import annotations

from contextlib import AbstractContextManager
from typing import Protocol, TextIO, runtime_checkable


class FileStoreError(Exception):
"""A store failed for a reason with no stdlib exception that fits.

Stores should prefer the built-in filesystem exceptions where one
applies — ``FileNotFoundError``, ``PermissionError``,
``IsADirectoryError``, ``FileExistsError`` — because the tools already
translate those into their established messages. Raise this only for
failures specific to the backing service, such as an unreachable
endpoint or a size limit the local filesystem does not have.
"""


@runtime_checkable
class FileStore(Protocol):
"""Where :class:`FileReadTool` and :class:`FileWriterTool` do their I/O.

Paths crossing this boundary are *store paths*: whatever ``resolve``
returned. For the local store those are absolute filesystem paths; for a
remote store they may be keys or workspace-relative paths. The tools
never interpret them, they only pass them back.
"""

#: Short human-readable name, used in error messages so a failure makes
#: clear which store produced it (e.g. ``"local filesystem"``).
label: str

def resolve(self, path: str, base_dir: str | None = None) -> str:
"""Normalize *path* and confirm the caller may touch it.

Args:
path: The caller-supplied path, absolute or relative.
base_dir: Optional containment root supplied by the tool.

Returns:
The store path to use for subsequent calls.

Raises:
ValueError: If the path falls outside what the store allows.
"""

def normalize(self, path: str, base_dir: str | None = None) -> str:
"""Normalize *path* for identity comparison, without containment.

:class:`FileReadTool` uses this to pin the file declared at
construction, so it can recognize that path again later even if the
working directory has since moved. Unlike :meth:`resolve` it never
rejects: a path outside the sandbox still has a canonical form.
"""

def resolve_within(self, directory: str, filename: str) -> str:
"""Join *filename* under the already-resolved *directory*.

Kept separate from :meth:`resolve` because the writer applies two
levels of containment: the directory must be inside the store's
sandbox, and the filename must then stay inside that directory.

Raises:
ValueError: If *filename* escapes *directory*, or names the
directory itself.
"""

def display(self, resolved: str, base: str | None = None) -> str:
"""Return a label for *resolved* that is safe to show an LLM.

Must not leak absolute directory prefixes; the tools put the result
straight into agent-visible output.
"""

def exists(self, resolved: str) -> bool:
"""Whether something already lives at *resolved*."""

def ensure_parent(self, resolved: str) -> None:
"""Create the container *resolved* will live in, if it needs one.

Raises:
FileExistsError: If a non-container already occupies that name.
"""

def open_text(self, resolved: str, encoding: str) -> AbstractContextManager[TextIO]:
"""Open *resolved* for reading as text.

Returning a file-like object rather than a string keeps the local
store lazy, so reading a small window out of a huge file does not
pull the whole thing into memory. Remote stores that must fetch
eagerly can wrap the payload in ``io.StringIO``.
"""

def write_text(
self, resolved: str, content: str, encoding: str, *, overwrite: bool
) -> None:
"""Write *content* to *resolved*.

Raises:
FileExistsError: If the path exists and *overwrite* is false.
"""
88 changes: 88 additions & 0 deletions lib/crewai-tools/src/crewai_tools/file_storage/local.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""The default store: the local filesystem, sandboxed to a base directory."""

from __future__ import annotations

from contextlib import AbstractContextManager
import os
from pathlib import Path
from typing import TextIO

from crewai_tools.security.safe_path import (
format_error_for_display,
format_path_for_display,
validate_file_path,
)


class LocalFileStore:
"""Reads and writes the local filesystem.

Containment is :func:`validate_file_path`: a resolved path must stay
inside ``base_dir`` (the working directory by default), with symlinks and
``..`` segments resolved first.
"""

label = "local filesystem"

def resolve(self, path: str, base_dir: str | None = None) -> str:
"""Resolve *path*, confining it to *base_dir*."""
return validate_file_path(path, base_dir)

def normalize(self, path: str, base_dir: str | None = None) -> str:
"""Resolve *path* the way the sandbox does, without rejecting it.

``validate_file_path`` and ``format_path_for_display`` both join a
relative path onto *base_dir* rather than the working directory.
Normalization has to agree with them, or the same relative string
would mean two different files.
"""
if os.path.isabs(path):
return os.path.realpath(path)
base = os.path.realpath(base_dir) if base_dir is not None else os.getcwd()
return os.path.realpath(os.path.join(base, path))

def resolve_within(self, directory: str, filename: str) -> str:
"""Join *filename* under *directory*, blocking every escape route.

``..``, absolute paths and symlinks are all resolved before the
check. ``is_relative_to`` compares whole path components, so it is
safe on case-insensitive filesystems and avoids the "//" prefix edge
case. A filename resolving to the directory itself (an empty
filename, say) is not a valid file target.
"""
root = Path(directory)
try:
resolved = Path(os.path.join(directory, filename)).resolve()
except (OSError, ValueError) as exc:
# e.g. an embedded null byte or an over-long name, which trip the
# underlying syscall. str() on an OSError carries the absolute
# filename, and the tools put this message straight into
# agent-visible output, so strip it back to the reason.
raise ValueError(format_error_for_display(exc)) from exc

if not resolved.is_relative_to(root) or resolved == root:
raise ValueError("the filename must not escape the target directory")
return str(resolved)

def display(self, resolved: str, base: str | None = None) -> str:
"""Return a path label with absolute prefixes stripped."""
return format_path_for_display(resolved, base)

def exists(self, resolved: str) -> bool:
return os.path.exists(resolved)

def ensure_parent(self, resolved: str) -> None:
"""Create the parent directory, including any missing ancestors."""
os.makedirs(os.path.dirname(resolved) or ".", exist_ok=True)

def open_text(self, resolved: str, encoding: str) -> AbstractContextManager[TextIO]:
return open(resolved, "r", encoding=encoding)

def write_text(
self, resolved: str, content: str, encoding: str, *, overwrite: bool
) -> None:
# "x" makes the create-exclusive check atomic, so an existence race
# surfaces as FileExistsError rather than silently clobbering.
mode = "w" if overwrite else "x"
with open(resolved, mode, encoding=encoding) as handle:
handle.write(content)
74 changes: 74 additions & 0 deletions lib/crewai-tools/src/crewai_tools/file_storage/registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""How a deployment swaps in a different store.

Kept as a process-wide factory rather than a tool argument on purpose: the
crews that need this are already written and deployed, and the point is that
they keep working unchanged when the runtime is ephemeral. An integration
package registers its factory at import time, and every file tool constructed
afterwards picks it up.
"""

from __future__ import annotations

from collections.abc import Callable
import logging
import threading

from crewai_tools.file_storage.base import FileStore
from crewai_tools.file_storage.local import LocalFileStore


logger = logging.getLogger(__name__)

#: A factory returns the store to use, or ``None`` to decline — which lets an
#: integration arm itself only when its backing service is actually
#: configured, and fall back to the local filesystem everywhere else.
FileStoreFactory = Callable[[], FileStore | None]

_lock = threading.Lock()
_factory: FileStoreFactory | None = None
_local = LocalFileStore()


def register_file_store_factory(factory: FileStoreFactory | None) -> None:
"""Install the factory consulted for every new file tool.

Args:
factory: Callable returning a :class:`FileStore`, or ``None`` to
decline and leave the local filesystem in place. Passing
``None`` as the factory itself unregisters.
"""
global _factory
with _lock:
_factory = factory


def reset_file_store_factory() -> None:
"""Drop any registered factory. Intended for tests."""
register_file_store_factory(None)


def resolve_file_store() -> FileStore:
"""Return the store the file tools should use.

A factory that raises is not allowed to take the tools down with it: an
integration failing to initialize should degrade to the local filesystem
— the behavior before it was installed — not break file I/O outright.
"""
with _lock:
factory = _factory

if factory is None:
return _local

try:
store = factory()
except Exception:
logger.warning(
"file store factory raised; falling back to the local filesystem",
exc_info=True,
)
return _local

if store is None:
return _local
return store
Loading
Loading