Skip to content

feat(tools): make the file tools' backing store pluggable - #6709

Open
joaomdmoura wants to merge 1 commit into
mainfrom
feat/file-tools-pluggable-store
Open

feat(tools): make the file tools' backing store pluggable#6709
joaomdmoura wants to merge 1 commit into
mainfrom
feat/file-tools-pluggable-store

Conversation

@joaomdmoura

@joaomdmoura joaomdmoura commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Why

FileReadTool and FileWriterTool assume a durable local disk. That holds on a developer's machine and breaks in any deployment environment where the runtime is ephemeral: whatever an agent writes is discarded when the run ends, and a later run can't read it back. A crew that generates a report in one task and reads it in the next passes locally and fails there.

This adds the seam needed to point those tools at durable storage instead. It is self-contained and changes no current behavior.

What this adds

A FileStore seam. Both tools now do every path resolution and every read/write through a store, defaulting to LocalFileStore — today's filesystem behavior, moved rather than rewritten. A deployment swaps in a different store with register_file_store_factory.

from crewai_tools.file_storage import register_file_store_factory

register_file_store_factory(lambda: MyDurableStore())   # every file tool built after this uses it

Design notes worth reviewing:

  • The store owns containment. The tools call nothing else before doing I/O, so resolve() / resolve_within() must reject anything out of bounds. Locally that stays validate_file_path plus the is_relative_to check; another store enforces whatever its own namespace requires, which may be prefix-based rather than realpath-based.
  • resolve() and normalize() are separate. The reader pins the file declared at construction so a later chdir can't repoint it — that needs canonicalization without a containment check, which normalize() provides.
  • open_text() returns a handle, not a string. That keeps the local store lazy, so start_line/line_count still reads a window out of a huge file without pulling it into memory. Stores that must fetch eagerly wrap the payload in StringIO.
  • The store binds once per tool. Swapping the factory mid-run must not move where an existing tool's paths point.

Safety

Two fallbacks make this inert until a store is actually registered, and keep a broken integration from taking file I/O down:

  • A factory returning None (its backing service isn't configured) → local filesystem.
  • A factory that raises → local filesystem, with a warning logged. Degrading to today's behavior beats breaking every file tool in the process.

Testing

123 tests pass. All pre-existing file tool tests are untouched — the refactor is behavior-preserving, which is the main thing to check.

The new tests stand in a store backed by a plain dict with no filesystem behind it. That's what proves the seam is real: a tool that reached past it to open() or os.path would fail them. They assert the writer leaves the real cwd empty, that a file written by one tool is readable by the other (the round-trip that breaks on an ephemeral runtime), that line windows work against a non-filesystem store, that containment is honoured in both directions, that both tools derive the same sandbox root from the same base_dir, that an OSError message never carries an absolute path, and both fallbacks above.

ruff and strict mypy clean, tool.specs.json regenerated.

🤖 Generated with Claude Code


Note

Medium Risk
Touches agent file I/O and path sandboxing; behavior is intended to be preserved locally, but custom stores and global factory state add integration surface area.

Overview
Introduces a FileStore seam so FileReadTool and FileWriterTool can persist to durable backends (e.g. object storage) without changing crew or tool arguments. Deployments register a process-wide factory via register_file_store_factory; tools resolve a store once at construction and route all path handling and I/O through it.

LocalFileStore preserves today’s sandboxed filesystem behavior (existing validate_file_path / display helpers), moved behind the protocol. Custom stores own containment via resolve / resolve_within, expose normalize separately for pinning declared paths, and use open_text handles so line-window reads stay lazy.

FileWriterTool anchors base_dir in model_post_init (not a field validator) so normalization uses store semantics. Factory None or raised exceptions fall back to the local store with a warning. New dict-backed tests prove tools never bypass the seam and that read/write round-trips work off-disk.

Reviewed by Cursor Bugbot for commit 0609959. Bugbot is set up for automated code reviews on this repo. Configure here.

FileReadTool and FileWriterTool assume a durable local disk. That holds on a
developer's machine and breaks in any deployment environment where the
runtime is ephemeral: whatever an agent writes is discarded when the run
ends, and a later run cannot read it back. A crew that generates a report in
one task and reads it in the next passes locally and fails there.

This adds the seam needed to point those tools at durable storage instead.
Both now route every path resolution and every read/write through a
FileStore, defaulting to LocalFileStore — the current filesystem behavior,
moved rather than rewritten. A deployment registers a different store
through register_file_store_factory and the tools pick it up.

The store owns its own containment, because the tools call nothing else
before doing I/O. For the local store that stays validate_file_path plus the
is_relative_to check; another store enforces whatever its own namespace
requires, which may be prefix-based rather than realpath-based. resolve()
and normalize() are separate so the reader can still pin its declared file
for identity without a containment check, and base_dir is anchored through
the store so both tools derive the same sandbox root from the same input.

open_text() returns a handle rather than a string, which keeps the local
store lazy: reading a small window out of a huge file does not pull the
whole thing into memory. A store that must fetch eagerly can wrap its
payload in StringIO.

Behaviour is unchanged: every pre-existing file tool test passes untouched.
The new suite stands in a store backed by a dict with no filesystem at all,
which is what proves the seam is real — a tool that reached past it to
open() or os.path would fail those assertions. It also covers the fallbacks
that keep this safe to ship before any integration exists: a factory
returning None, and a factory that raises, both leave the local filesystem
in place rather than breaking file I/O.

No new dependencies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 29, 2026 00:32
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

The pull request adds a pluggable FileStore contract, a sandboxed local implementation, and a registry with local fallback behavior. FileReadTool and FileWriterTool now use the store seam for path handling and text I/O, with in-memory tests covering routing, containment, fallback, and error behavior.

File storage seam

Layer / File(s) Summary
Storage contract and local backend
lib/crewai-tools/src/crewai_tools/file_storage/*
Defines the FileStore protocol, exports the storage API, and implements sandboxed local path resolution and text I/O.
Store registration and fallback
lib/crewai-tools/src/crewai_tools/file_storage/registry.py
Adds thread-safe factory registration and fallback to LocalFileStore when no usable configured store is available.
Reader and writer integration
lib/crewai-tools/src/crewai_tools/tools/file_read_tool/file_read_tool.py, lib/crewai-tools/src/crewai_tools/tools/file_writer_tool/file_writer_tool.py
Binds a store per tool instance and delegates normalization, containment, display, reading, directory preparation, and writing to it.
In-memory seam and behavior validation
lib/crewai-tools/tests/file_storage/test_file_store_seam.py
Adds MemoryFileStore tests for tool routing, round trips, line reads, containment, overwrite handling, fallback, store binding, shared bases, and sanitized errors.

Sequence Diagram(s)

sequenceDiagram
  participant FileWriterTool
  participant FileStore
  participant FileReadTool
  FileWriterTool->>FileStore: resolve and resolve_within target
  FileWriterTool->>FileStore: ensure_parent and write_text
  FileReadTool->>FileStore: normalize and resolve path
  FileReadTool->>FileStore: open_text resolved file
  FileStore-->>FileReadTool: text content
Loading

Suggested reviewers: copilot

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: making the file tools' backing store pluggable.
Description check ✅ Passed The description is directly related to the changeset and accurately explains the new FileStore seam, fallbacks, and tests.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/file-tools-pluggable-store

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 0609959. Configure here.

# Anchor base_dir once, so the sandbox root cannot move under a later
# chdir while the declared file stays pinned to its original location.
if base_dir is not None:
base_dir = os.path.realpath(base_dir)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Reader store unbound on reconstruction

Medium Severity

FileReadTool binds _store only in its custom __init__, while FileWriterTool binds it in model_post_init so pydantic reconstruction still works. BaseTool checkpoint deserialization uses model_validate, which skips custom __init__, so _store stays None and every read hits an AttributeError. Before this change, reconstructed readers could still call validate_file_path / open directly.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0609959. Configure here.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR introduces a pluggable FileStore seam so FileReadTool / FileWriterTool can route all path resolution and I/O through a durable backing store (while defaulting to the current local-filesystem behavior via LocalFileStore).

Changes:

  • Added a FileStore protocol plus a LocalFileStore implementation and a process-wide registry (register_file_store_factory / resolve_file_store).
  • Updated FileReadTool and FileWriterTool to bind a store once per tool instance and perform all resolve/read/write operations through it.
  • Added dict-backed tests proving file tools do not bypass the store seam and verifying sandbox/containment and fallback behavior.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
lib/crewai-tools/tests/file_storage/test_file_store_seam.py Adds seam-focused tests using an in-memory store to prove all I/O routes through FileStore.
lib/crewai-tools/src/crewai_tools/tools/file_writer_tool/file_writer_tool.py Refactors writer to resolve paths and write via a bound FileStore instance.
lib/crewai-tools/src/crewai_tools/tools/file_read_tool/file_read_tool.py Refactors reader to resolve/pin paths and read via a bound FileStore instance.
lib/crewai-tools/src/crewai_tools/file_storage/registry.py Adds the global factory registry with safe fallbacks to the local store.
lib/crewai-tools/src/crewai_tools/file_storage/local.py Implements LocalFileStore preserving existing sandboxed filesystem behavior.
lib/crewai-tools/src/crewai_tools/file_storage/base.py Defines the FileStore protocol and FileStoreError.
lib/crewai-tools/src/crewai_tools/file_storage/init.py Exposes the new storage seam API surface from the package.
Comments suppressed due to low confidence (1)

lib/crewai-tools/src/crewai_tools/tools/file_writer_tool/file_writer_tool.py:139

  • store.resolve_within(...) is only caught as ValueError. If a custom FileStore raises OSError/FileStoreError (e.g. invalid key syntax, backend failure), _run will raise instead of returning a structured error message.
        # Then keep filename inside that directory.
        try:
            resolved_filepath = store.resolve_within(resolved_directory, filename)
        except ValueError as e:
            return f"Error: Invalid file path — {e!s}"

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 126 to 129
try:
resolved_directory = Path(validate_file_path(directory, self.base_dir))
resolved_directory = store.resolve(directory, self.base_dir)
except ValueError as e:
return "Error: Invalid directory: " + format_sandbox_error(
Comment on lines 171 to 178
try:
file_path = self._resolve_path(file_path)
except ValueError as e:
return "Error: Invalid file path: " + format_sandbox_error(
e,
"Pass base_dir to FileReadTool to allow reading another "
"directory tree.",
)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@lib/crewai-tools/src/crewai_tools/tools/file_writer_tool/file_writer_tool.py`:
- Around line 121-139: Handle FileStoreError at every identified file-store
boundary, returning the same sanitized file-tool error format instead of
allowing it to escape. In file_writer_tool.py ranges 121-139 and 144-158, add
handling around store.resolve, store.resolve_within, store.ensure_parent, and
unguarded store.exists; in file_read_tool.py range 166-178, cover _resolve_path
and the read I/O boundary. Preserve existing handling for other exception types
and use each error’s details in the returned message.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: fb209830-11e6-43ab-a681-8b0101074b91

📥 Commits

Reviewing files that changed from the base of the PR and between f15844b and 0609959.

📒 Files selected for processing (7)
  • lib/crewai-tools/src/crewai_tools/file_storage/__init__.py
  • lib/crewai-tools/src/crewai_tools/file_storage/base.py
  • lib/crewai-tools/src/crewai_tools/file_storage/local.py
  • lib/crewai-tools/src/crewai_tools/file_storage/registry.py
  • lib/crewai-tools/src/crewai_tools/tools/file_read_tool/file_read_tool.py
  • lib/crewai-tools/src/crewai_tools/tools/file_writer_tool/file_writer_tool.py
  • lib/crewai-tools/tests/file_storage/test_file_store_seam.py

Comment on lines +121 to +139
store = self._store

# Confine the target directory to base_dir so an LLM-chosen directory
# cannot reach outside the sandbox. validate_file_path also resolves
# symlinks and ".." components.
# cannot reach outside the sandbox. The store also resolves symlinks
# and ".." components before checking.
try:
resolved_directory = Path(validate_file_path(directory, self.base_dir))
resolved_directory = store.resolve(directory, self.base_dir)
except ValueError as e:
return "Error: Invalid directory: " + format_sandbox_error(
e,
"Pass base_dir to FileWriterTool to allow writing to another "
"directory tree.",
)

# Keep filename inside the target directory, blocking "..", absolute
# paths and symlink escapes. is_relative_to() compares whole path
# components, so it is safe on case-insensitive filesystems and avoids
# the "//" prefix edge case. A filepath that resolves to the directory
# itself (e.g. an empty filename) is not a valid file target.
# Then keep filename inside that directory.
try:
resolved_filepath = Path(
os.path.join(resolved_directory, filename)
).resolve()
except (OSError, ValueError) as e:
# e.g. an embedded null byte, which trips the underlying syscall.
return f"Error: Invalid file path: {format_error_for_display(e)}"

display_filepath = format_path_for_display(
str(resolved_filepath), str(resolved_directory)
)
if (
not resolved_filepath.is_relative_to(resolved_directory)
or resolved_filepath == resolved_directory
):
return "Error: Invalid file path — the filename must not escape the target directory."
resolved_filepath = store.resolve_within(resolved_directory, filename)
except ValueError as e:
return f"Error: Invalid file path — {e!s}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate files =="
git ls-files | rg '(^lib/crewai-tools/src/crewai_tools/tools/file_writer_tool/file_writer_tool.py$|^lib/crewai-tools/src/crewai_tools/tools/file_read_tool/file_read_tool.py$|^lib/crewai-tools/src/crewai_tools/file_storage/base.py$)|file_storage/|FileStore|FileStoreError' || true

echo "== file_writer_tool outline =="
ast-grep outline lib/crewai-tools/src/crewai_tools/tools/file_writer_tool/file_writer_tool.py --view compact || true

echo "== file_read_tool outline =="
ast-grep outline lib/crewai-tools/src/crewai_tools/tools/file_read_tool/file_read_tool.py --view compact || true

echo "== file_storage base outline =="
ast-grep outline lib/crewai-tools/src/crewai_tools/file_storage/base.py --view compact || true

echo "== relevant file_writer_tool =="
cat -n lib/crewai-tools/src/crewai_tools/tools/file_writer_tool/file_writer_tool.py | sed -n '1,220p'

echo "== relevant file_read_tool =="
cat -n lib/crewai-tools/src/crewai_tools/tools/file_read_tool/file_read_tool.py | sed -n '1,240p'

echo "== relevant file_storage base =="
cat -n lib/crewai-tools/src/crewai_tools/file_storage/base.py | sed -n '1,260p'

Repository: crewAIInc/crewAI

Length of output: 24845


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== file_storage implementations =="
for f in lib/crewai-tools/src/crewai_tools/file_storage/*.py lib/crewai-tools/src/crewai_tools/file_storage/__init__.py; do
  echo "--- $f ---"
  if [ -f "$f" ]; then
    cat -n "$f" | sed -n '1,240p'
  fi
done

echo "== tests for FileStoreError handling =="
cat -n lib/crewai-tools/src/crewai_tools/tests/file_storage/test_file_store_seam.py | sed -n '1,260p'

echo "== search FileStoreError usages =="
rg -n "FileStoreError|except Exception|except ValueError|except FileExistsError|except OSError" lib/crewai-tools/src/crewai_tools/file_storage lib/crewai-tools/src/crewai_tools/tools/file_writer_tool lib/crewai-tools/src/crewai_tools/tools/file_read_tool

echo "== determine which FileStore methods catch generic exceptions at implementation sites =="
python3 - <<'PY'
from pathlib import Path
for path in sorted(Path("lib/crewai-tools/src/crewai_tools/file_storage").glob("*.py")):
    text = path.read_text()
    if "resolve" in text or "ensure_parent" in text or "exists" in text:
        print(f"\n{path}")
        for method in ["resolve", "resolve_within", "ensure_parent", "exists", "open_text", "write_text", "normalize", "display"]:
            # quick text context around each method body start
            if f"def {method}" in text:
                lines=text.splitlines()
                for i,line in enumerate(lines,1):
                    if f"def {method}" in line:
                        print(f"  {method}: lines {i}-{min(len(lines), i+22)}")
                        for j in range(i,min(len(lines), i+22)+1):
                            s=lines[j-1]
                            if j>i+18 or any(k in s for k in ["FileStoreError","except ","return "]):
                                print(f"    {j:4}: {s}")
PY

Repository: crewAIInc/crewAI

Length of output: 14926


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== search external repo for FileStoreError behavior =="
rg -n "(Error executing tool|error executing tool|Error.*executing|FileStoreError|error executing)" . -S --glob '*.py' | head -200 || true

echo "== git status/diff stat =="
git diff --stat || true
git diff --numstat || true

echo "== read-only behavioral probe for current local store guard coverage =="
python3 - <<'PY'
from pathlib import Path

cases = [
    ("file_writer_tool.py", "FWR", ["resolve", "resolve_within", "ensure_parent", "exists", "write_text", "open_text"]),
    ("file_read_tool.py", "FRD", ["_resolve_path", "open_text"]),
]
for root in ["lib/crewai-tools/src/crewai_tools", "tests"]:
    p = Path(root, "file_storage/test_file_store_seam.py")
    if p.exists():
        print(f"--- tests at {p} ---")
        text=p.read_text()
        for term in ["FileStoreError", "store.resolve", "store.exists", "ensure_parent", "file_writer_tool", "file_read_tool", "return"]:
            if term in text:
                print(f"{term}: present")
        print(text[:2000])

# Extract each store-boundary call inside FileReadTool<FileWriterTool._run blocks and report the exception types handled nearby.
files = {
    "lib/crewai-tools/src/crewai_tools/tools/file_writer_tool/file_writer_tool.py": range(106, 175),
    "lib/crewai-tools/src/crewai_tools/tools/file_read_tool/file_read_tool.py": range(156, 212),
}
for path, linespec in files.items():
    text = Path(path).read_text()
    lines = text.splitlines()
    print(f"\n--- {path} handle-report ---")
    in_try = None
    handler_types = set()
    for i in linespec:
        line = lines[i-1]
        if "try:" in line:
            in_try = i
            handler_types = set()
        elif line.strip().startswith("except ") and in_try is not None:
            # crude text parse: capture exception class names
            s = line.strip().replace(",", " ").replace(" as e", " ")
            for part in s.split():
                if part.isidentifier():
                    handler_types.add(part)
        elif ("store." in line or "_resolve_path" in line or "store.exists" in line) and in_try is not None and any(k in line for k in ["resolve(", "resolve_within(", "ensure_parent(", "exists(", "write_text(", "open_text(", "_resolve_path"]):
            # Find nearest prior except; for current line use current handlers
            print(f"{i}: {line.strip()} -> handlers: {sorted(handler_types)}")
PY

Repository: crewAIInc/crewAI

Length of output: 5150


Handle FileStoreError at file-store boundaries. FileStoreError is the sanctioned exception for non-local-store failures, but FileReadTool._run() and FileWriterTool._run() only catch filesystem-specific types here; a remote store that raises this will escape as Error executing tool: ... instead of a sanitized file-tool error. Add except FileStoreError as e: handling around store.resolve/store.resolve_within, store.ensure_parent, unguarded store.exists, _resolve_path, and the read path’s I/O boundary.

📍 Affects 2 files
  • lib/crewai-tools/src/crewai_tools/tools/file_writer_tool/file_writer_tool.py#L121-L139 (this comment)
  • lib/crewai-tools/src/crewai_tools/tools/file_writer_tool/file_writer_tool.py#L144-L158
  • lib/crewai-tools/src/crewai_tools/tools/file_read_tool/file_read_tool.py#L166-L178
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/crewai-tools/src/crewai_tools/tools/file_writer_tool/file_writer_tool.py`
around lines 121 - 139, Handle FileStoreError at every identified file-store
boundary, returning the same sanitized file-tool error format instead of
allowing it to escape. In file_writer_tool.py ranges 121-139 and 144-158, add
handling around store.resolve, store.resolve_within, store.ensure_parent, and
unguarded store.exists; in file_read_tool.py range 166-178, cover _resolve_path
and the read I/O boundary. Preserve existing handling for other exception types
and use each error’s details in the returned message.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants